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 83c1f21..9196bc8 100644
--- a/Coder/Languages/CSharpGenerator.cs
+++ b/Coder/Languages/CSharpGenerator.cs
@@ -1766,6 +1766,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} ");
@@ -1774,7 +1789,12 @@ 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.
- bool isConst = IsWrittenAsConst(varDecl);
+ // A held variable has had its type said for it above, which is as good as declaring one.
+ bool isConst = IsWrittenAsConst(varDecl)
+ || (isHeld
+ && varDecl.IsConstant
+ && !string.Equals(type, UnknownTypeName, StringComparison.Ordinal)
+ && IsCompileTimeValue(varDecl.InitialValue));
if (isConst)
{
code.Write("const ");
@@ -1796,6 +1816,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 84f5378..2485223 100644
--- a/Coder/Languages/GoGenerator.cs
+++ b/Coder/Languages/GoGenerator.cs
@@ -1421,8 +1421,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 83cde19..81b869a 100644
--- a/Coder/Languages/RustGenerator.cs
+++ b/Coder/Languages/RustGenerator.cs
@@ -1636,6 +1636,22 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl,
Ensure.NotNull(code);
RegisterStaticType(varDecl);
+
+ // `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 93cfbb6..aea48d3 100644
--- a/Coder/Languages/StandardLanguageGenerator.cs
+++ b/Coder/Languages/StandardLanguageGenerator.cs
@@ -32,6 +32,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.
///
@@ -393,6 +398,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.
///
@@ -754,10 +783,12 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code)
break;
case SourceFile file:
+ HoldModuleVariables(file.Members);
GenerateSourceFile(WithEntryPointsAtFileScope(file), code);
break;
case NamespaceDeclaration namespaceDecl:
+ HoldModuleVariables(namespaceDecl.Members);
GenerateNamespaceDeclaration(namespaceDecl, code);
break;