From 02e16da8b171878cf971219c6abfd791f3379d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 05:41:19 +0000 Subject: [PATCH] Lift C# member aliases out of their type and nest JS classes as statics [patch] A class member that is a UsingAlias or a nested ClassDeclaration came out where neither target accepts it. CSharpGenerator.GenerateUsingAlias wrote `using Id = int;` inside the type body (CS1513, CS1529, CS1022), despite its own remark. JavaScriptGenerator wrote `const Id = int;` and a bare `class Inner { }` into the class body, both syntax errors there. C#: a type's aliases, and those of the types nested in it, are now written in the namespace or file that encloses it, ahead of everything else in that scope, which is the only place C# takes a using alias. The scope's own aliases move there too, so one declared after a type no longer fails with CS1529. A type generated on its own writes its aliases in front of itself. JavaScript: a nested class becomes `static Inner = class Inner { ... };` and a member alias `static Id = int;`, the shape a nested enum already takes. TypeMemberDeclarationTests compiles the C# with dotnet build and parses the JavaScript with node --check. Fixes ktsu-dev/Coder#179 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NW9fJSB9VKsuPv7M2UmznC --- .../Languages/TypeMemberDeclarationTests.cs | 186 ++++++++++++++++++ Coder/Languages/CSharpGenerator.cs | 121 +++++++++--- Coder/Languages/JavaScriptGenerator.cs | 77 +++++++- 3 files changed, 355 insertions(+), 29 deletions(-) create mode 100644 Coder.Test/Languages/TypeMemberDeclarationTests.cs 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;