diff --git a/Coder.Test/Ast/FunctionShapeTests.cs b/Coder.Test/Ast/FunctionShapeTests.cs index 7772bc1..27dbfe5 100644 --- a/Coder.Test/Ast/FunctionShapeTests.cs +++ b/Coder.Test/Ast/FunctionShapeTests.cs @@ -103,15 +103,18 @@ public void Cpp_SpellsEachKindOfDefinition() } /// - /// A trailing const says a call leaves the receiver unchanged, which C# spells readonly. + /// A trailing const says a call leaves the receiver unchanged, which C# spells readonly on the + /// one kind of member that may carry it, an instance member of a struct. /// [TestMethod] public void ReadOnly_IsSpelledWhereALanguageHasIt() { FunctionDeclaration find = new("find") { ReturnType = "int", IsReadOnly = true }; + ClassDeclaration index = new("Index") { Kind = TypeDeclarationKind.Struct }; + index.Members.Add(find); Assert.Contains("int find() const", new CppGenerator().Generate(find), StringComparison.Ordinal); - Assert.Contains("readonly int find(", new CSharpGenerator().Generate(find), StringComparison.Ordinal); + Assert.Contains("readonly int find(", new CSharpGenerator().Generate(index), StringComparison.Ordinal); } /// diff --git a/Coder.Test/Languages/CSharpMemberModifierTests.cs b/Coder.Test/Languages/CSharpMemberModifierTests.cs new file mode 100644 index 0000000..af5e867 --- /dev/null +++ b/Coder.Test/Languages/CSharpMemberModifierTests.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a member's modifiers are written only where C# allows them. +/// +/// +/// The AST's modifiers are C++'s: a trailing const on any member function, a pure virtual +/// member in an otherwise ordinary class, a destructor with an access specifier. C# allows +/// readonly only on an instance member of a struct (CS0106), no access modifier on a finalizer +/// (CS0106), no finalizer in a struct (CS0575), and an abstract member only in a class that says it is +/// abstract (CS0513). +/// +[TestClass] +public class CSharpMemberModifierTests +{ + /// + /// Tests that a class method that does not modify its instance says so in a note rather than with + /// readonly. + /// + [TestMethod] + public void ReadOnlyClassMethod_IsNotSpelledReadonly() + { + ClassDeclaration counter = new("Counter"); + counter.Members.Add(new FieldDeclaration("count", new TypeReference("int"))); + FunctionDeclaration get = new("get") { ReturnType = "int", IsReadOnly = true }; + get.Body.Add(new ReturnStatement(new VariableReference("count"))); + counter.Members.Add(get); + + string code = new CSharpGenerator().Generate(counter); + + Assert.Contains("public int get()", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("readonly int get(", code, StringComparison.Ordinal, code); + Assert.Contains("// readonly: get does not modify its instance", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that an instance member of a struct is still spelled readonly, and a static one is + /// not. + /// + [TestMethod] + public void ReadOnlyStructMethod_IsSpelledReadonly() + { + ClassDeclaration point = new("Point") { Kind = TypeDeclarationKind.Struct }; + point.Members.Add(new FieldDeclaration("x", new TypeReference("int"))); + FunctionDeclaration getX = new("getX") { ReturnType = "int", IsReadOnly = true }; + getX.Body.Add(new ReturnStatement(new VariableReference("x"))); + point.Members.Add(getX); + FunctionDeclaration origin = new("origin") { ReturnType = "int", IsReadOnly = true, IsStatic = true }; + origin.Body.Add(new ReturnStatement(Literal.Number(0))); + point.Members.Add(origin); + + string code = new CSharpGenerator().Generate(point); + + Assert.Contains("public readonly int getX()", code, StringComparison.Ordinal, code); + Assert.Contains("public static int origin()", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("readonly:", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that an interface member is neither readonly nor written abstract, which an + /// interface member with no body already is. + /// + [TestMethod] + public void InterfaceMember_HasNeitherReadonlyNorAbstract() + { + string code = new CSharpGenerator().Generate(CompiledExemplar.Shape()); + + Assert.Contains("public double area();", code, StringComparison.Ordinal, code); + Assert.Contains("public void draw(double scale);", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("abstract", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("readonly double", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a class with an abstract member is itself abstract. + /// + [TestMethod] + public void ClassWithAbstractMember_IsAbstract() + { + ClassDeclaration animal = new("Animal"); + animal.Members.Add(new FunctionDeclaration("speak") { ReturnType = "string", IsAbstract = true }); + + string code = new CSharpGenerator().Generate(animal); + + Assert.Contains("public abstract class Animal", code, StringComparison.Ordinal, code); + Assert.Contains("public abstract string speak();", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a class with no abstract member is not marked abstract. + /// + [TestMethod] + public void ClassWithoutAbstractMember_IsNotAbstract() + { + ClassDeclaration animal = new("Animal"); + animal.Members.Add(new FunctionDeclaration("speak") { ReturnType = "string", IsVirtual = true }); + + string code = new CSharpGenerator().Generate(animal); + + Assert.Contains("public class Animal", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("abstract", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a class's destructor is a finalizer with no access modifier. + /// + [TestMethod] + public void ClassDestructor_HasNoAccessModifier() + { + ClassDeclaration vec = new("Vec"); + vec.Members.Add(new FunctionDeclaration("Vec") { Kind = FunctionKind.Destructor }); + + string code = new CSharpGenerator().Generate(vec); + + Assert.Contains("~Vec()", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("public ~Vec", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a struct's destructor is written down rather than declared. + /// + [TestMethod] + public void StructDestructor_IsANote() + { + ClassDeclaration vec = new("Vec") { Kind = TypeDeclarationKind.Struct }; + vec.Members.Add(new FunctionDeclaration("Vec") { Kind = FunctionKind.Destructor }); + + string code = new CSharpGenerator().Generate(vec); + + Assert.Contains("// ~Vec is a destructor, which a C# struct cannot declare.", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("~Vec()", code, StringComparison.Ordinal, code); + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 47d055d..775a54c 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -200,13 +200,14 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) _ => "class", }; - // In the order C# takes them: access, then the promise the type makes about itself, then - // the permission to declare the rest of it elsewhere, then what kind of type it is. A - // record is written before the keyword rather than instead of it, which is what makes - // `record struct` reachable. + // In the order C# takes them: access, then whether it can be instantiated, then the promise + // the type makes about itself, then the permission to declare the rest of it elsewhere, then + // what kind of type it is. A record is written before the keyword rather than instead of it, + // which is what makes `record struct` reachable. string[] modifiers = [ SpellVisibility(classDecl.Visibility) ?? DefaultVisibility, + .. IsAbstractClass(classDecl) ? (string[])["abstract"] : [], .. isReadOnlyStruct ? (string[])["readonly"] : [], .. classDecl.IsPartial ? (string[])["partial"] : [], .. classDecl.IsRecord ? (string[])["record"] : [], @@ -241,7 +242,7 @@ .. classDecl.Interfaces.Select(MapToCSType), { if (member is FunctionDeclaration method) { - GenerateFunction(method, code, classDecl.Name); + GenerateFunction(method, code, classDecl); } else { @@ -781,7 +782,7 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code) => /// /// The declaration to emit. /// The writer to emit into. - /// The name of the type it belongs to, when it belongs to one. + /// The type it belongs to, when it belongs to one. /// /// C# expresses a defaulted or deleted member by not declaring it: the compiler supplies the one /// and the absence of the other is what makes a call fail to compile. So neither is emitted, and @@ -793,22 +794,29 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code) => /// = default means there. /// /// - private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, string? enclosingType) + private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, ClassDeclaration? enclosingType) { WritePreamble(function, code, enclosingType is null ? PreambleSite.Function : PreambleSite.Method); + string name = SpellFunctionName(function, enclosingType?.Name); + if (function.Definition != FunctionDefinition.Provided && !(function.Kind == FunctionKind.Constructor && function.Definition == FunctionDefinition.Defaulted)) { string state = function.Definition == FunctionDefinition.Defaulted ? "supplied by the language" : "deleted"; - WriteInexpressible(code, $"{SpellFunctionName(function, enclosingType)} is {state}, which C# expresses by not declaring it."); + WriteInexpressible(code, $"{name} is {state}, which C# expresses by not declaring it."); + return; + } + + if (!WriteMemberNotes(function, name, enclosingType, code)) + { return; } WriteFunctionAttributes(function, code); - WriteFunctionModifiers(function, code); + WriteFunctionModifiers(function, code, enclosingType); - code.Write(SpellFunctionName(function, enclosingType)); + code.Write(name); code.Write(SpellTypeParameters(function.TypeParameters)); code.Write("("); @@ -836,7 +844,21 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st WriteConstraintClauses(function.TypeParameters, code); using Scope body = new(code); + WriteInitialisers(function, code); + + foreach (AstNode statement in function.Body) + { + GenerateInternal(statement, code); + } + } + /// + /// Writes a constructor's member initialisers as the assignments that open its body. + /// + /// The declaration being emitted. + /// The writer to emit into. + private void WriteInitialisers(FunctionDeclaration function, CodeBlocker code) + { // C# assigns where C++ initialises. Written before the body's own statements and in the order // declared, which is what the initialiser means where there is no initialiser list. One with no // value value-initialises the member, as C++'s `x()` does, and `default` is C#'s spelling. @@ -855,11 +877,34 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st EndStatement(code); } + } - foreach (AstNode statement in function.Body) + /// + /// Writes down what a member asks for that C# cannot say where the member is. + /// + /// The declaration being emitted. + /// The name it is written under. + /// The type it belongs to, when it belongs to one. + /// The writer to emit into. + /// False when the note stands in for the whole declaration, so nothing else is written. + private bool WriteMemberNotes(FunctionDeclaration function, string name, ClassDeclaration? enclosingType, CodeBlocker code) + { + // A struct cannot have a finalizer (CS0575), so the destructor is written down rather than + // declared, the way a member C# expresses by not declaring it is. + if (function.Kind == FunctionKind.Destructor && enclosingType?.Kind == TypeDeclarationKind.Struct) { - GenerateInternal(statement, code); + WriteInexpressible(code, $"{name} is a destructor, which a C# struct cannot declare."); + return false; + } + + // readonly is a modifier only an instance member of a struct may carry (CS0106). Anywhere else + // the member still makes the claim, so it is written down the way the type's own claim is. + if (function.IsReadOnly && !function.IsStatic && !IsReadOnlySpelled(function, enclosingType)) + { + WriteInexpressible(code, $"readonly: {name} does not modify its instance"); } + + return true; } /// @@ -890,11 +935,16 @@ private static void WriteFunctionAttributes(FunctionDeclaration function, CodeBl /// /// The declaration being emitted. /// The writer to emit into. - private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker code) + /// The type it belongs to, when it belongs to one. + private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker code, ClassDeclaration? enclosingType) { // A function nobody has given a visibility to is public: an inaccessible method is not what - // someone who wrote no modifier meant. - code.Write($"{SpellVisibility(function.Visibility) ?? DefaultVisibility} "); + // someone who wrote no modifier meant. A finalizer is the exception, because it takes no + // access modifier at all (CS0106): only the runtime calls it. + if (function.Kind != FunctionKind.Destructor) + { + code.Write($"{SpellVisibility(function.Visibility) ?? DefaultVisibility} "); + } if (function.IsStatic || ReferenceEquals(function, heldMember) @@ -904,16 +954,20 @@ private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker co code.Write("static "); } + // An interface member with no body is abstract already, and says so by having none. if (function.IsAbstract) { - code.Write("abstract "); + if (enclosingType?.Kind != TypeDeclarationKind.Interface) + { + code.Write("abstract "); + } } else if (function.IsVirtual) { code.Write("virtual "); } - if (function.IsReadOnly) + if (IsReadOnlySpelled(function, enclosingType)) { code.Write("readonly "); } @@ -925,6 +979,33 @@ private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker co } } + /// + /// Determines whether a declaration's is spelled as + /// C#'s readonly. + /// + /// The declaration being emitted. + /// The type it belongs to, when it belongs to one. + /// True when the declaration is an instance member of a struct that claims it. + /// + /// The claim is C++'s trailing const, which any member function may make. C# lets only an + /// instance member of a struct make it, so everywhere else readonly fails with CS0106. + /// + private static bool IsReadOnlySpelled(FunctionDeclaration function, ClassDeclaration? enclosingType) => + function.IsReadOnly && !function.IsStatic && enclosingType?.Kind == TypeDeclarationKind.Struct; + + /// + /// Determines whether a class is written abstract. + /// + /// The declaration being emitted. + /// True when it is a class with a member that has no definition. + /// + /// The AST says abstract of a member, as C++ does, and a C++ class with a pure virtual member is + /// abstract without saying so. C# requires the class to say it (CS0513). + /// + private static bool IsAbstractClass(ClassDeclaration classDecl) => + classDecl.Kind == TypeDeclarationKind.Class + && classDecl.Members.OfType().Any(member => member.IsAbstract); + /// /// Spells the name a declaration is written under. ///