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;