From 85bd5aabcc2038be5aafef528d3c8e1a5521771f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:30:11 +0000 Subject: [PATCH] Write namespace- and file-scope variables as declarations, not locals [patch] A VariableDeclaration directly in a namespace or a file came out in local form: C# wrote `public static var` (CS0825), Rust wrote `let` outside a function, and Go wrote `:=` at package scope. C# now says the type the initialiser establishes (or object, with a note), Rust writes a static or const item, and Go writes `var name = value`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018tZnrna9oooMZHDaLyXumk --- .../Languages/ModuleScopeVariableTests.cs | 181 ++++++++++++++++++ Coder/Languages/CSharpGenerator.cs | 33 ++++ Coder/Languages/GoGenerator.cs | 5 +- Coder/Languages/RustGenerator.cs | 15 ++ Coder/Languages/StandardLanguageGenerator.cs | 31 +++ 5 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 Coder.Test/Languages/ModuleScopeVariableTests.cs diff --git a/Coder.Test/Languages/ModuleScopeVariableTests.cs b/Coder.Test/Languages/ModuleScopeVariableTests.cs new file mode 100644 index 0000000..793c476 --- /dev/null +++ b/Coder.Test/Languages/ModuleScopeVariableTests.cs @@ -0,0 +1,181 @@ +// 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 a variable declared directly in a namespace or a file is written as the declaration +/// that scope holds, rather than as a local (issue #232). +/// +/// +/// C#'s var is only for a local (CS0825), Rust's let is a statement and Go's +/// := is one too, so all three were refused outside a function body. A local in a function +/// of the same scope is still a local. +/// +[TestClass] +public class ModuleScopeVariableTests +{ + /// + /// Builds a file holding a namespace, each with an inferred and a typed variable, and a function + /// that declares an inferred local of its own. + /// + /// The file. + internal static SourceFile Scoped() + { + NamespaceDeclaration geometry = new("geometry"); + geometry.Members.Add(new VariableDeclaration("count", initialValue: Literal.Number(0))); + geometry.Members.Add(new VariableDeclaration("ratio", "int", Literal.Number(2))); + + FunctionDeclaration measure = new("measure") { ReturnType = "int" }; + measure.Body.Add(new VariableDeclaration("local", initialValue: Literal.Number(3))); + measure.Body.Add(new ReturnStatement(new VariableReference("local"))); + geometry.Members.Add(measure); + + SourceFile file = new("scoped"); + file.Members.Add(new VariableDeclaration("label", initialValue: Literal.Text("a"))); + file.Members.Add(new VariableDeclaration("limit", initialValue: Literal.Number(4)) { IsConstant = true }); + file.Members.Add(geometry); + return file; + } + + /// + /// Tests that C# says the type an inferred held variable's initialiser establishes. + /// + [TestMethod] + public void CSharp_InferredHeldVariable_SaysItsType() + { + string code = new CSharpGenerator().Generate(Scoped()); + + Assert.Contains("public static int count = 0;", code, StringComparison.Ordinal, code); + Assert.Contains("public static int ratio = 2;", code, StringComparison.Ordinal, code); + Assert.Contains("public static string label = \"a\";", code, StringComparison.Ordinal, code); + Assert.Contains("public const int limit = 4;", code, StringComparison.Ordinal, code); + Assert.Contains("var local = 3;", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("static var", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that C# writes a held variable with nothing to infer from as an object, and says so. + /// + [TestMethod] + public void CSharp_HeldVariableWithNoType_IsAnObjectWithANote() + { + NamespaceDeclaration scope = new("Scope"); + scope.Members.Add(new VariableDeclaration("unknown", initialValue: new VariableReference("elsewhere"))); + + string code = new CSharpGenerator().Generate(scope); + + Assert.Contains("public static object unknown = elsewhere;", code, StringComparison.Ordinal, code); + Assert.Contains("// unknown: C# infers no type for a field", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that Rust writes a module's variables as statics and constants, and a function's as + /// let. + /// + [TestMethod] + public void Rust_ModuleVariables_AreItems() + { + string code = new RustGenerator().Generate(Scoped()); + + Assert.Contains("static count: i32 = 0;", code, StringComparison.Ordinal, code); + Assert.Contains("static ratio: i32 = 2;", code, StringComparison.Ordinal, code); + Assert.Contains("static label: &'static str = \"a\";", code, StringComparison.Ordinal, code); + Assert.Contains("const limit: i32 = 4;", code, StringComparison.Ordinal, code); + Assert.Contains("let mut local = 3;", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("let mut count", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that Go writes a package's inferred variable with var, and a function's with + /// :=. + /// + [TestMethod] + public void Go_PackageVariables_AreDeclaredWithVar() + { + string code = new GoGenerator().Generate(Scoped()); + + Assert.Contains("var count = 0", code, StringComparison.Ordinal, code); + Assert.Contains("var label = \"a\"", code, StringComparison.Ordinal, code); + Assert.Contains("local := 3", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("count :=", code, StringComparison.Ordinal, code); + Assert.DoesNotContain("label :=", code, StringComparison.Ordinal, code); + } + + /// + /// Tests that the C# for the file builds. + /// + [TestMethod] + public void CSharp_Compiles() + { + if (ToolchainHarness.FindOnPath("--version", "dotnet") is null) + { + Assert.Inconclusive("No .NET SDK on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "Scoped.csproj"), """ + + + net10.0 + Library + + + """); + File.WriteAllText(Path.Combine(directory, "Scoped.cs"), new CSharpGenerator().Generate(Scoped())); + + (int exitCode, string output) = ToolchainHarness.Run("dotnet", "build -nologo", directory); + Assert.AreEqual(0, exitCode, $"C# rejected the generated source:{Environment.NewLine}{output}"); + }); + } + + /// + /// Tests that the Rust for the file compiles. + /// + [TestMethod] + public void Rust_Compiles() + { + if (ToolchainHarness.FindOnPath("--version", "rustc") is null) + { + Assert.Inconclusive("No Rust compiler on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + string source = new RustGenerator().Generate(Scoped()); + File.WriteAllText(Path.Combine(directory, "scoped.rs"), $"#![allow(dead_code, non_upper_case_globals)]{Environment.NewLine}{source}"); + + (int exitCode, string output) = ToolchainHarness.Run("rustc", "--crate-type lib --edition 2021 -o scoped.rlib scoped.rs", directory); + Assert.AreEqual(0, exitCode, $"rustc rejected the generated source:{Environment.NewLine}{source}{Environment.NewLine}{output}"); + }); + } + + /// + /// Tests that the Go for the file compiles. + /// + [TestMethod] + public void Go_Compiles() + { + if (ToolchainHarness.FindOnPath("version", "go") is null) + { + Assert.Inconclusive("No Go toolchain on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + string source = new GoGenerator().Generate(Scoped()); + File.WriteAllText(Path.Combine(directory, "go.mod"), $"module scoped{Environment.NewLine}{Environment.NewLine}go 1.21{Environment.NewLine}"); + File.WriteAllText(Path.Combine(directory, "scoped.go"), source); + + (int exitCode, string output) = ToolchainHarness.Run("go", "vet .", directory); + Assert.AreEqual(0, exitCode, $"go rejected the generated source:{Environment.NewLine}{source}{Environment.NewLine}{output}"); + }); + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index a40841b..9596917 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -1591,6 +1591,21 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke // 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); + + // `var` is only for a local (CS0825), so a held variable says the type its initialiser + // establishes, and one whose initialiser establishes none is an object with a note saying so. + if (isHeld && string.Equals(type, "var", StringComparison.Ordinal)) + { + if (InferredFieldType(varDecl.InitialValue) is string inferred) + { + type = inferred; + } + else + { + WriteInexpressible(code, $"{varDecl.Name}: C# infers no type for a field, and nothing here says one"); + type = UnknownTypeName; + } + } if ((isHeld ? SpellVisibility(varDecl.Visibility) ?? DefaultVisibility : SpellVisibility(varDecl.Visibility)) is string modifier) { code.Write($"{modifier} "); @@ -1623,6 +1638,24 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke code.WriteLine(";"); } + /// + /// Reads the type a field's initialiser establishes, for a field C# will not let infer it. + /// + /// The initialiser, if there is one. + /// The C# type, or null when the initialiser does not establish one. + private static string? InferredFieldType(Expression? initialValue) => initialValue switch + { + { ExpectedType: string expected } => MapToCSType(new TypeReference(expected)), + LiteralExpression => "string", + LiteralExpression => "int", + LiteralExpression => "long", + LiteralExpression => "float", + LiteralExpression => "double", + LiteralExpression => "bool", + ConstructionExpression { Type: TypeReference constructed } => MapToCSType(constructed), + _ => null, + }; + /// /// Emits the program's entry point as C#'s Main method. /// diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index 56f7f51..d47e3f5 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -1419,8 +1419,9 @@ private void WriteVariableDeclaration(VariableDeclaration varDecl, CodeBlocker c } // The short form declares a variable, so it is not open to a constant — which is the one - // reason the decision has to be made before the keyword is written rather than with it. - if (inferred && varDecl.InitialValue is not null && !IsConstant(varDecl.IsConstant, varDecl.InitialValue)) + // reason the decision has to be made before the keyword is written rather than with it. It is + // a statement, too, so a package-level variable takes `var x = v` instead. + if (!IsModuleVariable(varDecl) && inferred && varDecl.InitialValue is not null && !IsConstant(varDecl.IsConstant, varDecl.InitialValue)) { code.Write($"{varDecl.Name} := "); GenerateInternal(varDecl.InitialValue, code); diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 73f5e9f..3464af1 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -1571,6 +1571,21 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, Ensure.NotNull(varDecl); Ensure.NotNull(code); + // `let` is a statement, so a variable of a module or a file is the item that holds one there: + // a `const` or a `static`, each of which has to say its type. + if (IsModuleVariable(varDecl)) + { + TypeReference? declared = varDecl.IsTypeInferred ? null : varDecl.Type; + GenerateFieldDeclaration(new FieldDeclaration(varDecl.Name) + { + Type = declared ?? (varDecl.InitialValue is Expression initialValue ? StaticType(initialValue) : null), + InitialValue = varDecl.InitialValue, + Visibility = varDecl.Visibility, + IsConstant = varDecl.IsConstant, + }, code); + return; + } + code.Write(varDecl.IsConstant ? "let " : "let mut "); code.Write(varDecl.Name); diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index b3a22e2..633c24b 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -31,6 +31,11 @@ public abstract class StandardLanguageGenerator : LanguageGeneratorBase { private readonly Stack staticTypeScopes = new(); + /// + /// The variables declared directly in a namespace or a file, which are items rather than locals. + /// + private readonly HashSet moduleVariables = new(ReferenceEqualityComparer.Instance); + /// /// Determines whether this generator can generate code for the specified AST node. /// @@ -392,6 +397,30 @@ protected void RegisterStaticType(VariableDeclaration declaration) } } + /// + /// Reports whether a variable is declared directly in a namespace or a file rather than in a body. + /// + /// The declaration to test. + /// True when it is a module-scope variable. + /// + /// Several targets spell a local as a statement — Rust's let, Go's := — which is + /// refused outside a function, so a variable of a namespace or a file has to be told apart by + /// where it was declared rather than by its own shape. + /// + protected bool IsModuleVariable(VariableDeclaration declaration) => moduleVariables.Contains(declaration); + + /// + /// Records the variables a namespace or a file declares directly. + /// + /// The scope's members. + private void HoldModuleVariables(IEnumerable members) + { + foreach (VariableDeclaration variable in members.OfType()) + { + moduleVariables.Add(variable); + } + } + /// /// Reads the width an inferred local must be written with, because the target would infer another. /// @@ -605,10 +634,12 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) break; case SourceFile file: + HoldModuleVariables(file.Members); GenerateSourceFile(file, code); break; case NamespaceDeclaration namespaceDecl: + HoldModuleVariables(namespaceDecl.Members); GenerateNamespaceDeclaration(namespaceDecl, code); break;