diff --git a/Coder.Test/Languages/TypeMemberDeclarationTests.cs b/Coder.Test/Languages/TypeMemberDeclarationTests.cs new file mode 100644 index 0000000..709d3bf --- /dev/null +++ b/Coder.Test/Languages/TypeMemberDeclarationTests.cs @@ -0,0 +1,186 @@ +// 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 an alias or a class declared as a member of a class comes out where the target accepts +/// one, in C# and JavaScript. +/// +/// +/// Neither language takes either declaration in a class body as written at namespace scope: C# has a +/// using alias only at file or namespace scope (CS1513, CS1529, CS1022), and a JavaScript class body +/// holds only methods and fields, so const Id = … and class Inner { } are both syntax +/// errors there. +/// +[TestClass] +public class TypeMemberDeclarationTests +{ + /// + /// A class whose field is typed by an alias the class declares. + /// + private static ClassDeclaration Holder() + { + ClassDeclaration holder = new("Holder"); + holder.Members.Add(new UsingAlias("Id", "int")); + holder.Members.Add(new FieldDeclaration("id", "Id") { Visibility = Visibility.Public }); + return holder; + } + + /// + /// A class holding another, which holds an alias of its own. + /// + private static ClassDeclaration Outer() + { + ClassDeclaration inner = new("Inner"); + inner.Members.Add(new UsingAlias("Count", "long")); + inner.Members.Add(new FieldDeclaration("count", "Count") { Visibility = Visibility.Public }); + + ClassDeclaration outer = new("Outer"); + outer.Members.Add(inner); + outer.Members.Add(new FieldDeclaration("size", "int") { Visibility = Visibility.Public }); + return outer; + } + + /// + /// The project the generated C# is compiled as. + /// + private const string Project = """ + + + net10.0 + disable + disable + + + """; + + /// + /// Tests that an alias a C# type declares is written ahead of the type rather than inside it. + /// + [TestMethod] + public void CSharp_MemberAlias_IsWrittenAheadOfItsType() + { + string code = new CSharpGenerator().Generate(Holder()); + + int alias = code.IndexOf("using Id = int;", StringComparison.Ordinal); + int type = code.IndexOf("class Holder", StringComparison.Ordinal); + + Assert.IsGreaterThanOrEqualTo(0, alias, code); + Assert.IsLessThan(type, alias, code); + Assert.AreEqual(1, CountOf(code, "using Id"), code); + } + + /// + /// Tests that every alias a namespace holds, its own and those of the types in it, is written + /// ahead of everything else in it, which is the only place C# takes one. + /// + [TestMethod] + public void CSharp_NamespaceAliases_PrecedeItsTypes() + { + NamespaceDeclaration shapes = new("Shapes"); + shapes.Members.Add(Outer()); + shapes.Members.Add(new UsingAlias("Name", "string")); + + string code = new CSharpGenerator().Generate(shapes); + + int type = code.IndexOf("class Outer", StringComparison.Ordinal); + Assert.IsLessThan(type, code.IndexOf("using Count = long;", StringComparison.Ordinal), code); + Assert.IsLessThan(type, code.IndexOf("using Name = string;", StringComparison.Ordinal), code); + Assert.AreEqual(1, CountOf(code, "using Count"), code); + } + + /// + /// Tests that C# accepts a file holding a class-member alias, a nested class with its own alias, + /// and a namespace alias declared after a type. + /// + [TestMethod] + public void CSharp_MemberAliasesAndNestedClasses_Compile() + { + if (ToolchainHarness.FindOnPath("--version", "dotnet") is null) + { + Assert.Inconclusive("No dotnet on the path, so nothing was compiled."); + return; + } + + NamespaceDeclaration shapes = new("Shapes"); + shapes.Members.Add(Holder()); + shapes.Members.Add(Outer()); + shapes.Members.Add(new UsingAlias("Name", "string")); + + SourceFile file = new("Generated"); + file.Members.Add(shapes); + + const string driver = """ + public static class Driver + { + public static long Use() => + new global::Shapes.Holder().id + new global::Shapes.Outer.Inner().count + new global::Shapes.Outer().size; + } + """; + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "Generated.csproj"), Project); + File.WriteAllText(Path.Combine(directory, "Driver.cs"), driver); + File.WriteAllText(Path.Combine(directory, "Generated.cs"), new CSharpGenerator().Generate(file)); + + (int exitCode, string output) = ToolchainHarness.Run("dotnet", "build -nologo", directory); + Assert.AreEqual(0, exitCode, $"C# rejected the generated source:{Environment.NewLine}{output}"); + }); + } + + /// + /// Tests that JavaScript writes a member alias and a nested class as static members, the shape a + /// nested enumeration already takes. + /// + [TestMethod] + public void JavaScript_MemberAliasAndNestedClass_AreStaticMembers() + { + JavaScriptGenerator generator = new(); + + StringAssert.Contains(generator.Generate(Holder()), "static Id = int;", StringComparison.Ordinal); + StringAssert.Contains(generator.Generate(Outer()), "static Inner = class Inner {", StringComparison.Ordinal); + } + + /// + /// Tests that Node.js parses a class holding an alias and one holding a nested class. + /// + [TestMethod] + public void JavaScript_MemberAliasAndNestedClass_Parse() + { + string? node = ToolchainHarness.FindOnPath("--version", "node"); + if (node is null) + { + Assert.Inconclusive("No Node.js runtime on the path, so nothing was parsed."); + return; + } + + SourceFile file = new("members"); + file.Members.Add(Holder()); + file.Members.Add(Outer()); + string generated = new JavaScriptGenerator().Generate(file); + + ToolchainHarness.InTemporaryDirectory(directory => + { + string path = Path.Combine(directory, "members.mjs"); + File.WriteAllText(path, generated); + (int exitCode, string output) = ToolchainHarness.Run(node, $"--check {path}", directory); + Assert.AreEqual(0, exitCode, $"Node.js rejected the generated source:{Environment.NewLine}{generated}{Environment.NewLine}{output}"); + }); + } + + private static int CountOf(string text, string value) + { + int count = 0; + for (int index = text.IndexOf(value, StringComparison.Ordinal); index >= 0; index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 47d055d..6dac0a7 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -175,6 +175,12 @@ private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code) /// private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) { + bool aliasesWereHoisted = memberAliasesHoisted; + if (!aliasesWereHoisted) + { + WriteHoistedAliases(MemberAliases(classDecl), code); + } + WritePreamble(classDecl, code, PreambleSite.Type); // C++ can attach a declaration to a type it does not own, by specialising a template on it. @@ -235,6 +241,7 @@ .. classDecl.Interfaces.Select(MapToCSType), using Scope members = new(code); bool enclosingIsReadOnlyStruct = inReadOnlyStruct; inReadOnlyStruct = isReadOnlyStruct; + memberAliasesHoisted = true; try { foreach (AstNode member in classDecl.Members) @@ -243,7 +250,7 @@ .. classDecl.Interfaces.Select(MapToCSType), { GenerateFunction(method, code, classDecl.Name); } - else + else if (member is not UsingAlias) { GenerateInternal(member, code); } @@ -252,7 +259,44 @@ .. classDecl.Interfaces.Select(MapToCSType), finally { inReadOnlyStruct = enclosingIsReadOnlyStruct; + memberAliasesHoisted = aliasesWereHoisted; + } + } + + /// + /// Collects the aliases a type and the types nested in it declare as members. + /// + /// The type. + /// The aliases, outermost type first and each type's in the order declared. + /// + /// C# has a using alias only at file or namespace scope, never inside a type (CS1513), so these are + /// written in the scope that encloses the type instead. The name then resolves everywhere the type + /// used it, which is what declaring it as a member was for. + /// + private static IEnumerable MemberAliases(ClassDeclaration classDecl) => + classDecl.Members.SelectMany(member => member switch + { + UsingAlias alias => [alias], + ClassDeclaration nested => MemberAliases(nested), + _ => [], + }); + + /// + /// Writes aliases ahead of the declarations they were lifted out of. + /// + /// The aliases to write. + /// The writer to emit into. + /// Whether anything was written. + private bool WriteHoistedAliases(IEnumerable aliases, CodeBlocker code) + { + bool any = false; + foreach (UsingAlias alias in aliases) + { + GenerateUsingAlias(alias, code); + any = true; } + + return any; } /// @@ -379,41 +423,60 @@ protected override void GenerateFileMembers(SourceFile file, CodeBlocker code) /// and one at file scope with CS0106. Every other target has somewhere to put them, so the AST /// allows them, and a static class is what C# has in their place — a reader names /// Geometry.twice where C++ names Geometry::twice. The class is written where the - /// first of them was declared, and types, enums, aliases and nested namespaces stay where they - /// are. + /// first of them was declared, and types, enums and nested namespaces stay where they are. Aliases + /// come first, those of the scope's types with them, because C# takes them nowhere else. /// private void GenerateScopeMembers(IEnumerable members, string holderName, CodeBlocker code) { AstNode[] declared = [.. members]; AstNode[] typeless = [.. declared.Where(IsTypelessMember)]; - bool first = true; bool holderWritten = false; - foreach (AstNode member in declared) + // Every alias the scope holds goes first, its own and those lifted out of its types: C# takes + // a using alias only ahead of everything else declared in a namespace or file (CS1529). + IEnumerable aliases = declared.SelectMany(member => member switch { - bool isTypeless = IsTypelessMember(member); - if (isTypeless && holderWritten) - { - continue; - } + UsingAlias alias => [alias], + ClassDeclaration type => MemberAliases(type), + _ => [], + }); - if (!first) + bool first = !WriteHoistedAliases(aliases, code); + + bool aliasesWereHoisted = memberAliasesHoisted; + memberAliasesHoisted = true; + try + { + foreach (AstNode member in declared) { - code.NewLine(); - } + bool isTypeless = IsTypelessMember(member); + if ((isTypeless && holderWritten) || member is UsingAlias) + { + continue; + } - first = false; + if (!first) + { + code.NewLine(); + } - if (isTypeless) - { - holderWritten = true; - GenerateMemberHolder(UnclaimedName(holderName, declared), typeless, code); - } - else - { - GenerateInternal(member, code); + first = false; + + if (isTypeless) + { + holderWritten = true; + GenerateMemberHolder(UnclaimedName(holderName, declared), typeless, code); + } + else + { + GenerateInternal(member, code); + } } } + finally + { + memberAliasesHoisted = aliasesWereHoisted; + } } /// @@ -485,8 +548,8 @@ private static string UnclaimedName(string name, IEnumerable siblings) /// The writer to emit into. /// /// C# has a using alias but only at file or namespace scope, never inside a type. One declared as - /// a member is therefore said rather than written, since a generated file that silently drops it - /// looks complete and is not. + /// a member is lifted to the scope enclosing its type rather than written where it stands; see + /// . /// private void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code) { @@ -976,6 +1039,16 @@ private static void GenerateParameter(Parameter parameter, CodeBlocker code) /// private bool inReadOnlyStruct; + /// + /// Whether the aliases declared in the types being written have already been written in front of + /// them, by the scope that encloses those types or by an enclosing type. + /// + /// + /// A type generated on its own has no enclosing scope to lift its aliases into, so it writes them + /// in front of itself; one inside a namespace, a file or another type leaves them to that. + /// + private bool memberAliasesHoisted; + /// /// The member of a namespace or file being written into the static class that holds it, which /// is written static whether or not it says so. diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 9125aa0..e6f451c 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -175,6 +175,18 @@ protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assert WriteInexpressible(code, $"asserted at build time: {assertion.Condition}"); } + /// + /// Emits an alias declared inside a class, as a static member of it. + /// + /// The alias to emit. + /// The writer to emit into. + private void GenerateNestedAlias(UsingAlias usingAlias, CodeBlocker code) + { + WritePreamble(usingAlias, code, PreambleSite.Type); + code.Write($"static {usingAlias.Name} = {usingAlias.AliasedType?.Name ?? "Object"}"); + EndStatement(code); + } + /// /// /// JavaScript has no types to alias. The name is bound to whatever the alias named, which is a @@ -379,6 +391,47 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + WriteClassHeading(classDecl, code, string.Empty); + + // The line is left open, so the scope's brace lands on it: JavaScript braces hang. + code.Write(" "); + + using Scope body = new(code); + WriteClassMembers(classDecl, code); + } + + /// + /// Emits a class declared inside another, as a static member of it. + /// + /// The declaration to emit. + /// The writer to emit into. + /// + /// A class body holds only methods and fields, so a class declaration there is a syntax error. + /// A static field holding a class expression is the same class reachable by the same name, which + /// is the shape already gives an enumeration. + /// + private void GenerateNestedClass(ClassDeclaration classDecl, CodeBlocker code) + { + WriteClassHeading(classDecl, code, $"static {classDecl.Name ?? "UnnamedClass"} = "); + code.WriteLine(" {"); + + using (IndentScope members = new(code)) + { + WriteClassMembers(classDecl, code); + } + + code.WriteLine("};"); + } + + /// + /// Writes what comes before a class's body: what it promised, and the class line itself, + /// left open. + /// + /// The declaration whose heading to write. + /// The writer to emit into. + /// What goes in front of the class keyword on its line. + private void WriteClassHeading(ClassDeclaration classDecl, CodeBlocker code, string binding) + { // C++ can attach a declaration to a type it does not own, by specialising a template on it. // Nothing here can, so the fact is written down rather than lost: what follows is an // ordinary declaration, and the comment says which type it was the declaration for. @@ -399,17 +452,21 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod WriteInexpressible(code, $"implements {string.Join(", ", classDecl.Interfaces.Select(contract => contract.Name))}"); } - code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); + code.Write($"{binding}class {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.BaseType is TypeReference baseType) { code.Write($" extends {baseType.Name}"); } + } - // The line is left open, so the scope's brace lands on it: JavaScript braces hang. - code.Write(" "); - - using Scope body = new(code); + /// + /// Writes a class's members, each in the shape a class body accepts. + /// + /// The declaration whose members to write. + /// The writer to emit into. + private void WriteClassMembers(ClassDeclaration classDecl, CodeBlocker code) + { foreach (AstNode member in classDecl.Members) { switch (member) @@ -422,6 +479,16 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod GenerateNestedEnum(nested, code); break; + case ClassDeclaration nested: + GenerateNestedClass(nested, code); + break; + + // `const` is a statement keyword and a syntax error in a class body, so an alias is a + // static field bound to the same thing the namespace-scope spelling binds it to. + case UsingAlias alias: + GenerateNestedAlias(alias, code); + break; + case FieldDeclaration classField: GenerateClassField(classField, code); break;