From 39d50df0a54b61ec54bf6e83c672adf33df469a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 03:36:42 +0000 Subject: [PATCH] Hold a C# namespace's fields and functions in a static class [patch] C# has no member outside a type, but CSharpGenerator wrote a namespace's fields, functions and variables straight into the namespace (CS0116), and a file's straight into the file (CS0106). Every other target has somewhere to put them, so the AST allows them. They are now gathered into a public static class, written where the first of them was declared and named after the namespace's last segment, or Globals at file scope. A type of the same name beside it gets the Globals suffix instead. Each held member is written static, except a const, which already is; locals in a held function's body are untouched. Types, enums, aliases and nested namespaces stay put. The file-scope loop moves from LanguageGeneratorBase.GenerateSourceFile into an overridable GenerateFileMembers so C# can do the same there. CSharpGeneratedSourceCompilesTests builds the output with dotnet build, like the C, C++, Rust and Go compile tests. Fixes ktsu-dev/Coder#155 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018FP1DMG4WYMABYwTfyb2Xd --- .../CSharpGeneratedSourceCompilesTests.cs | 89 ++++++++++ .../Languages/CSharpScopeMemberTests.cs | 138 ++++++++++++++++ Coder/Languages/CSharpGenerator.cs | 155 ++++++++++++++++-- Coder/Languages/LanguageGeneratorBase.cs | 16 ++ 4 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs create mode 100644 Coder.Test/Languages/CSharpScopeMemberTests.cs diff --git a/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs new file mode 100644 index 0000000..405f296 --- /dev/null +++ b/Coder.Test/Languages/CSharpGeneratedSourceCompilesTests.cs @@ -0,0 +1,89 @@ +// 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; + +/// +/// Compiles what writes, with dotnet build. +/// +/// +/// Where C# can declare a member is a rule the text does not show: a field or function written +/// straight into a namespace reads like any other and fails with CS0116. A test that pinned the +/// spelling passed on exactly that output, which is why it was not noticed. +/// +/// A driver in the same project uses every declaration, so a declaration that compiles on its own +/// but cannot be reached from outside fails here too. The test is inconclusive rather than failing +/// where no dotnet is on the path, which is the honest result: nothing was checked. +/// +/// +[TestClass] +public class CSharpGeneratedSourceCompilesTests +{ + /// + /// The project the generated file is compiled as: a library, with warnings left as warnings, + /// because what is under test is whether C# accepts the file rather than what it thinks of it. + /// + private const string Project = """ + + + net10.0 + disable + disable + + + """; + + /// + /// The consumer of the generated declarations, written the way a person would write one. + /// + private const string Driver = """ + public static class Driver + { + public static int Use() + { + global::Geometry.Point point = new global::Geometry.Point(); + global::Geometry.Geometry.count = point is null ? 0 : 1; + return global::Geometry.Geometry.twice(global::Geometry.Geometry.Origins) + + global::Geometry.Geometry.count + + Globals.half(4) + + (new Tool() is null ? 0 : 1); + } + } + """; + + /// + /// Tests that a namespace and a file holding fields, functions and a variable beside their types + /// compile, and that a second file can reach all of them. + /// + [TestMethod] + public void ScopeMembers_Compile() + { + if (ToolchainHarness.FindOnPath("--version", "dotnet") is null) + { + Assert.Inconclusive("No dotnet on the path, so nothing was compiled."); + return; + } + + SourceFile file = new("Generated"); + file.Members.Add(CSharpScopeMemberTests.Geometry()); + + FunctionDeclaration half = new("half") { ReturnType = "int" }; + half.Parameters.Add(new Parameter("n", "int")); + half.Body.Add(new ReturnStatement(new BinaryExpression(new VariableReference("n"), BinaryOperator.Divide, Literal.Number(2)))); + file.Members.Add(half); + file.Members.Add(new ClassDeclaration("Tool")); + + 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}"); + }); + } +} diff --git a/Coder.Test/Languages/CSharpScopeMemberTests.cs b/Coder.Test/Languages/CSharpScopeMemberTests.cs new file mode 100644 index 0000000..ac109e6 --- /dev/null +++ b/Coder.Test/Languages/CSharpScopeMemberTests.cs @@ -0,0 +1,138 @@ +// 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 the fields, functions and variables of a namespace or a file are written into a static +/// class, which is the only place C# can declare them. +/// +/// +/// C# has no member outside a type: one written straight into a namespace fails with CS0116, and one +/// at file scope with CS0106. builds the same shapes. +/// +[TestClass] +public class CSharpScopeMemberTests +{ + /// + /// Builds the namespace from the issue: a constant and a free function. + /// + /// The namespace. + internal static NamespaceDeclaration Geometry() + { + NamespaceDeclaration geometry = new("Geometry"); + geometry.Members.Add(new FieldDeclaration("Origins", new TypeReference("int")) { IsConstant = true, InitialValue = Literal.Number(2) }); + + FunctionDeclaration twice = new("twice") { ReturnType = "int" }; + twice.Parameters.Add(new Parameter("n", "int")); + twice.Body.Add(new VariableDeclaration("doubled", "int", new BinaryExpression(new VariableReference("n"), BinaryOperator.Multiply, Literal.Number(2)))); + twice.Body.Add(new ReturnStatement(new VariableReference("doubled"))); + geometry.Members.Add(twice); + + geometry.Members.Add(new ClassDeclaration("Point")); + geometry.Members.Add(new VariableDeclaration("count", "int", Literal.Number(0))); + return geometry; + } + + /// + /// Tests that a namespace's fields, functions and variables are static members of a class named + /// after it, and that its types stay where they are. + /// + [TestMethod] + public void NamespaceMembers_AreHeldInAStaticClass() + { + string code = new CSharpGenerator().Generate(Geometry()); + + Assert.Contains("public static class Geometry", code, StringComparison.Ordinal, code); + Assert.Contains("public static readonly int Origins = 2;", code, StringComparison.Ordinal, code); + Assert.Contains("public static int twice(int n)", code, StringComparison.Ordinal, code); + Assert.Contains("public static int count = 0;", code, StringComparison.Ordinal, code); + Assert.Contains("public class Point", code, StringComparison.Ordinal, code); + + // The holder is written once, where the first of its members was, and the type after it is + // outside it rather than nested. + Assert.AreEqual(1, code.Split("static class").Length - 1, code); + Assert.IsLessThan(code.IndexOf("public class Point", StringComparison.Ordinal), code.IndexOf("static class Geometry", StringComparison.Ordinal), code); + Assert.IsGreaterThan(code.IndexOf("public static int twice", StringComparison.Ordinal), code.IndexOf("public static int count", StringComparison.Ordinal), code); + } + + /// + /// Tests that a local inside a held function is not made static. + /// + [TestMethod] + public void LocalsOfAHeldFunction_StayLocals() + { + string code = new CSharpGenerator().Generate(Geometry()); + + Assert.Contains("int doubled = (n * 2);", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("static int doubled", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a held constant variable is const and not also static (CS0504). + /// + [TestMethod] + public void HeldConstantVariable_IsConstWithoutStatic() + { + NamespaceDeclaration limits = new("Limits"); + limits.Members.Add(new VariableDeclaration("Max", "int", Literal.Number(9)) { IsConstant = true }); + + string code = new CSharpGenerator().Generate(limits); + + Assert.Contains("public const int Max = 9;", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("static const", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that the holder takes another name when a type in the namespace already has its own. + /// + [TestMethod] + public void HolderName_AvoidsATypeOfTheSameName() + { + NamespaceDeclaration geometry = new("Shapes.Geometry"); + geometry.Members.Add(new ClassDeclaration("Geometry")); + geometry.Members.Add(new FieldDeclaration("Sides", new TypeReference("int")) { IsConstant = true, InitialValue = Literal.Number(4) }); + + string code = new CSharpGenerator().Generate(geometry); + + Assert.Contains("public class Geometry", code, StringComparison.Ordinal, code); + Assert.Contains("public static class GeometryGlobals", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a file's own fields and functions are held in a static class too. + /// + [TestMethod] + public void FileMembers_AreHeldInAStaticClass() + { + SourceFile file = new("tools"); + FunctionDeclaration half = new("half") { ReturnType = "int" }; + half.Parameters.Add(new Parameter("n", "int")); + half.Body.Add(new ReturnStatement(new BinaryExpression(new VariableReference("n"), BinaryOperator.Divide, Literal.Number(2)))); + file.Members.Add(half); + file.Members.Add(new ClassDeclaration("Tool")); + + string code = new CSharpGenerator().Generate(file); + + Assert.Contains("public static class Globals", code, StringComparison.Ordinal, code); + Assert.Contains("public static int half(int n)", code, StringComparison.Ordinal, code); + Assert.Contains("public class Tool", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that a namespace holding only types gets no holder. + /// + [TestMethod] + public void NamespaceOfTypes_HasNoHolder() + { + NamespaceDeclaration shapes = new("Shapes"); + shapes.Members.Add(new ClassDeclaration("Square")); + + string code = new CSharpGenerator().Generate(shapes); + + Assert.DoesNotContain("static class", code, StringComparison.Ordinal, code); + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 14087d8..47d055d 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -347,22 +347,137 @@ private void GenerateNamespace(NamespaceDeclaration namespaceDecl, CodeBlocker c { WritePreamble(namespaceDecl, code, PreambleSite.Type); - code.WriteLine($"namespace {string.Join(".", NamespaceDeclaration.Split(namespaceDecl.Name))}"); + IReadOnlyList segments = NamespaceDeclaration.Split(namespaceDecl.Name); + code.WriteLine($"namespace {string.Join(".", segments)}"); using Scope members = new(code); + GenerateScopeMembers(namespaceDecl.Members, segments.Count > 0 ? segments[^1] : GlobalsClassName, code); + } + + /// + protected override void GenerateFileMembers(SourceFile file, CodeBlocker code) + { + Ensure.NotNull(file); + Ensure.NotNull(code); + GenerateScopeMembers(file.Members, GlobalsClassName, code); + } + + /// + /// The name of the class that holds a file's fields and functions. + /// + private const string GlobalsClassName = "Globals"; + + /// + /// Emits the members of a namespace or a file, gathering the ones C# cannot declare there into a + /// static class. + /// + /// The members, in the order declared. + /// What to call the class, unless a type beside it already has the name. + /// The writer to emit into. + /// + /// C# has no field, function or variable outside a type: one in a namespace fails with CS0116, + /// 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. + /// + private void GenerateScopeMembers(IEnumerable members, string holderName, CodeBlocker code) + { + AstNode[] declared = [.. members]; + AstNode[] typeless = [.. declared.Where(IsTypelessMember)]; bool first = true; - foreach (AstNode member in namespaceDecl.Members) + bool holderWritten = false; + + foreach (AstNode member in declared) { + bool isTypeless = IsTypelessMember(member); + if (isTypeless && holderWritten) + { + continue; + } + if (!first) { code.NewLine(); } first = false; - GenerateInternal(member, code); + + if (isTypeless) + { + holderWritten = true; + GenerateMemberHolder(UnclaimedName(holderName, declared), typeless, code); + } + else + { + GenerateInternal(member, code); + } } } + /// + /// Emits the static class that holds a scope's fields, functions and variables. + /// + /// The class's name. + /// The members it holds, in the order declared. + /// The writer to emit into. + private void GenerateMemberHolder(string name, IEnumerable members, CodeBlocker code) + { + code.WriteLine($"{DefaultVisibility} static class {name}"); + + using Scope body = new(code); + bool first = true; + foreach (AstNode member in members) + { + if (!first) + { + code.NewLine(); + } + + first = false; + + // A static class may hold only static members (CS0708), and the AST's member is static in + // every sense that matters already: there is no instance of a namespace for it to belong to. + heldMember = member; + try + { + GenerateInternal(member, code); + } + finally + { + heldMember = null; + } + } + } + + /// + /// Determines whether a member of a namespace or file is one C# can declare only inside a type. + /// + /// The member. + /// True for a field, a function or a variable. + private static bool IsTypelessMember(AstNode member) => + member is FieldDeclaration or FunctionDeclaration or VariableDeclaration; + + /// + /// Picks a name for the class holding a scope's members that no type beside it already has. + /// + /// The name wanted. + /// Everything else declared in the scope. + /// The name, or the name with a suffix when a sibling has it (CS0101). + private static string UnclaimedName(string name, IEnumerable siblings) + { + bool claimed = siblings.Any(sibling => sibling switch + { + ClassDeclaration type => string.Equals(type.Name, name, StringComparison.Ordinal), + EnumDeclaration type => string.Equals(type.Name, name, StringComparison.Ordinal), + UsingAlias alias => string.Equals(alias.Name, name, StringComparison.Ordinal), + _ => false, + }); + + return claimed ? $"{name}{GlobalsClassName}" : name; + } + /// /// Emits an alias giving a type a second name. /// @@ -628,7 +743,7 @@ private void GenerateField(FieldDeclaration field, CodeBlocker code) { code.Write("static readonly "); } - else if (field.IsStatic) + else if (field.IsStatic || ReferenceEquals(field, heldMember)) { code.Write("static "); } @@ -775,13 +890,15 @@ private static void WriteFunctionAttributes(FunctionDeclaration function, CodeBl /// /// The declaration being emitted. /// The writer to emit into. - private static void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker code) + private void WriteFunctionModifiers(FunctionDeclaration function, CodeBlocker code) { // 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} "); - if (function.IsStatic || function.Kind is FunctionKind.Operator or FunctionKind.ConversionOperator) + if (function.IsStatic + || ReferenceEquals(function, heldMember) + || function.Kind is FunctionKind.Operator or FunctionKind.ConversionOperator) { // A C# operator is always static, whether or not the declaration thought to say so. code.Write("static "); @@ -859,6 +976,16 @@ private static void GenerateParameter(Parameter parameter, CodeBlocker code) /// private bool inReadOnlyStruct; + /// + /// 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. + /// + /// + /// The member itself rather than a flag, because a function's body declares locals and a flag + /// would make them static too. + /// + private AstNode? heldMember; + /// /// What a declaration that never said what type it is gets. /// @@ -949,8 +1076,10 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke : MapToCSType(varDecl.Type); // A local has no access modifier and a field usually does, and the AST distinguishes the two - // by whether one was set: emitting it only when present keeps both correct. - if (SpellVisibility(varDecl.Visibility) is string modifier) + // by whether one was set: emitting it only when present keeps both correct. A variable of a + // namespace or file is a field of the class holding it, and as visible as the scope it was in. + bool isHeld = ReferenceEquals(varDecl, heldMember); + if ((isHeld ? SpellVisibility(varDecl.Visibility) ?? DefaultVisibility : SpellVisibility(varDecl.Visibility)) is string modifier) { code.Write($"{modifier} "); } @@ -958,12 +1087,18 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke // A C# constant must name its type and hold a compile-time value, so an inferred one, or one // computed from a parameter or a call, stays a plain declaration rather than becoming source // that does not compile. C# has no readonly local to say the rest with. - if (varDecl.IsConstant + bool isConst = varDecl.IsConstant && !string.Equals(type, "var", StringComparison.Ordinal) - && IsCompileTimeValue(varDecl.InitialValue)) + && IsCompileTimeValue(varDecl.InitialValue); + if (isConst) { code.Write("const "); } + else if (isHeld) + { + // A const is static already, and saying so as well is CS0504. + code.Write("static "); + } code.Write($"{type} {varDecl.Name}"); diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 43125a2..e110e72 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -664,6 +664,22 @@ protected void GenerateSourceFile(SourceFile file, CodeBlocker code) } WriteImports(file, code); + GenerateFileMembers(file, code); + } + + /// + /// Emits the declarations a file holds, after its header and imports. + /// + /// The file being emitted. + /// The writer to emit into. + /// + /// Overridable for the one target that cannot write every declaration where the file puts it: C# + /// has no field or function outside a type, so it gathers those into one. + /// + protected virtual void GenerateFileMembers(SourceFile file, CodeBlocker code) + { + Ensure.NotNull(file); + Ensure.NotNull(code); AstNode? previous = null; foreach (AstNode member in file.Members)