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)