From 2b77daa2180e2d72427eca38daeeec9a2c4993a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 03:19:20 +0000 Subject: [PATCH] [patch] Let functions write module-level fields in Rust and Python (#296) A function that assigned to a module-level field produced code that did not work. Rust refused to compile it, because an immutable static cannot be assigned to. Python raised UnboundLocalError, because the assignment made the name local to the function. Rust now makes a written field a `static mut` and reaches it through `unsafe`. Python now declares it `global` in each function that writes it. A local or a parameter with the same name still shadows the field. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01L1CFbhzg3saCCjeDAZC7Pd --- .../PythonGeneratedSourceImportsTests.cs | 46 ++++++++ .../RustGeneratedSourceCompilesTests.cs | 52 +++++++++ Coder/Languages/PythonGenerator.cs | 60 ++++++++++ Coder/Languages/RustGenerator.cs | 110 +++++++++++++++++- Coder/Languages/StandardLanguageGenerator.cs | 80 +++++++++++++ 5 files changed, 345 insertions(+), 3 deletions(-) diff --git a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs index c3f34cf..c2e5090 100644 --- a/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs +++ b/Coder.Test/Languages/PythonGeneratedSourceImportsTests.cs @@ -351,6 +351,30 @@ raise AssertionError("a write-only property could be read") }); } + /// + /// Tests that a function assigning to a module-level field declares it global, so the + /// write reaches the module instead of raising UnboundLocalError (#296). + /// + [TestMethod] + public void ModuleFieldWrittenByAFunction_RunsAndKeepsItsValue() + { + string? python = ToolchainHarness.FindOnPath("--version", "python3", "python"); + if (python is null) + { + Assert.Inconclusive("No Python interpreter on the path, so nothing was run."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + string source = new PythonGenerator().Generate(Counter()); + File.WriteAllText(Path.Combine(directory, "counter.py"), source); + + (int exitCode, string output) = ToolchainHarness.Run(python, "counter.py", directory); + Assert.AreEqual(2, exitCode, $"{output}{Environment.NewLine}{source}"); + }); + } + /// /// Builds a file whose declarations are each written over themselves. /// @@ -376,4 +400,26 @@ private static SourceFile Exemplar() return file; } + + /// + /// Builds a file whose function assigns to a module-level field and returns it, and whose entry + /// point calls it twice, exiting with what the second call returns (#296). + /// + /// The file to generate; run, it exits with 2. + private static SourceFile Counter() + { + SourceFile file = new("counter"); + file.Members.Add(new FieldDeclaration("count", new TypeReference("int")) { InitialValue = Literal.Number(0) }); + + FunctionDeclaration bump = new("bump") { ReturnType = new TypeReference("int") }; + bump.Body.Add(new AssignmentStatement(new VariableReference("count"), Literal.Number(1), AssignmentOperator.AddAssign)); + bump.Body.Add(new ReturnStatement(new VariableReference("count"))); + file.Members.Add(bump); + + EntryPoint main = new() { ReturnsExitCode = true }; + main.Body.Add(new ExpressionStatement(new CallExpression("bump"))); + main.Body.Add(new ReturnStatement(new CallExpression("bump"))); + file.Members.Add(main); + return file; + } } diff --git a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs index 47fe63b..92bc53a 100644 --- a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs @@ -668,10 +668,62 @@ private static ClassDeclaration DescribesPoint() return specialisation; } + /// + /// Tests that a module-level field a function assigns to is a static mut reached through + /// unsafe, so the program compiles and the writes stick (#296). + /// + [TestMethod] + public void ModuleFieldWrittenByAFunction_CompilesAndKeepsItsValue() + { + if (ToolchainHarness.FindOnPath("--version", "rustc") is null) + { + Assert.Inconclusive("No Rust compiler on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + string program = OperatingSystem.IsWindows() ? "counter.exe" : "counter"; + string source = new RustGenerator().Generate(Counter()); + File.WriteAllText(Path.Combine(directory, "counter.rs"), source); + + (int built, string output) = ToolchainHarness.Run( + "rustc", + $"--edition 2021 -o {program} counter.rs", + directory); + Assert.AreEqual(0, built, $"rustc rejected the generated source:{Environment.NewLine}{output}{Environment.NewLine}{source}"); + + (int counted, _) = ToolchainHarness.Run(Path.Combine(directory, program), string.Empty, directory); + Assert.AreEqual(2, counted, source); + }); + } + /// /// Gets a string the holder does not own, which is the one Rust asks for wherever it is only read. /// /// The type. private static TypeReference BorrowedString() => new("str") { Indirection = TypeIndirection.Reference, IsReadOnly = true }; + + /// + /// Builds a file whose function assigns to a module-level field and returns it, and whose entry + /// point calls it twice, exiting with what the second call returns (#296). + /// + /// The file to generate; run, it exits with 2. + private static SourceFile Counter() + { + SourceFile file = new("counter"); + file.Members.Add(new FieldDeclaration("count", new TypeReference("int")) { InitialValue = Literal.Number(0) }); + + FunctionDeclaration bump = new("bump") { ReturnType = new TypeReference("int") }; + bump.Body.Add(new AssignmentStatement(new VariableReference("count"), Literal.Number(1), AssignmentOperator.AddAssign)); + bump.Body.Add(new ReturnStatement(new VariableReference("count"))); + file.Members.Add(bump); + + EntryPoint main = new() { ReturnsExitCode = true }; + main.Body.Add(new ExpressionStatement(new CallExpression("bump"))); + main.Body.Add(new ReturnStatement(new CallExpression("bump"))); + file.Members.Add(main); + return file; + } } diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index ddc297d..01285a6 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -22,6 +22,16 @@ public class PythonGenerator : StandardLanguageGenerator { private HashSet? instanceInitialisedFields; + /// + /// The root was worked out for. + /// + private AstNode? writtenModuleFieldsRoot; + + /// + /// The module-level fields something in the document assigns to. + /// + private HashSet writtenModuleFields = new(StringComparer.Ordinal); + /// /// Gets the unique identifier for this language generator. /// @@ -204,6 +214,7 @@ private void WriteAccessorBody(PropertyDeclaration declaration, bool isSetter, C code.WriteLine(); using IndentScope statements = new(code); + WriteGlobalDeclarations(body, [], code); if (body.Count == 0) { @@ -487,6 +498,52 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo code.WriteLine(); } + /// + /// Declares global the module-level fields a body assigns to. + /// + /// The statements of the body about to be written. + /// The parameters the body can see, which shadow a module field of the same name. + /// The writer to emit into. + /// + /// Python decides a name's scope from the whole function before running any of it: one assignment + /// anywhere makes the name local everywhere in that body, so the read in count += 1 finds an + /// unbound local and raises UnboundLocalError. A global line is what says the + /// assignment means the module's. Only fields the body itself assigns are listed, because a read + /// alone already reaches the module and a declaration nothing needs is noise. + /// + private void WriteGlobalDeclarations(IEnumerable body, IEnumerable parameters, CodeBlocker code) + { + if (!ReferenceEquals(writtenModuleFieldsRoot, GenerationRoot)) + { + writtenModuleFieldsRoot = GenerationRoot; + writtenModuleFields = WrittenFileScopeFields(); + } + + if (writtenModuleFields.Count == 0) + { + return; + } + + AstNode[] nodes = [.. body.SelectMany(SelfAndDescendants)]; + HashSet shadowed = new( + parameters.Select(parameter => parameter.Name).OfType() + .Concat(nodes.OfType().Select(declaration => declaration.Name)), + StringComparer.Ordinal); + + List globals = [.. nodes + .OfType() + .Select(assignment => assignment.Target) + .OfType() + .Select(reference => reference.Name.Split('.')[0]) + .Where(name => writtenModuleFields.Contains(name) && !shadowed.Contains(name)) + .Distinct(StringComparer.Ordinal)]; + + if (globals.Count > 0) + { + code.WriteLine($"global {string.Join(", ", globals)}"); + } + } + /// protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code) { @@ -511,6 +568,7 @@ protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl // Python's body is delimited by indentation alone, so there is no brace scope to open. using IndentScope body = new(code); + WriteGlobalDeclarations(funcDecl.Body, funcDecl.Parameters, code); // Function body if (funcDecl.Body.Count == 0) @@ -675,6 +733,7 @@ private void GenerateMethod(FunctionDeclaration method, ClassDeclaration declara WriteMethodSignature(method, code); using IndentScope body = new(code); + WriteGlobalDeclarations(method.Body, method.Parameters, code); // A method a derived class has to supply is a method whose body is a refusal. Python has no // declaration without a definition, so the definition says what calling it means. @@ -846,6 +905,7 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co using (IDisposable types = OpenStaticTypeScope(entryPoint)) using (IndentScope body = new(code)) { + WriteGlobalDeclarations(entryPoint.Body, [], code); if (entryPoint.Body.Count == 0) { code.WriteLine("pass"); diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 73f5e9f..66ad8b9 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -147,6 +147,31 @@ public class RustGenerator : StandardLanguageGenerator /// private IReadOnlySet reassignedParameters = new HashSet(StringComparer.Ordinal); + /// + /// The parameters and locals the function being written has bound so far. + /// + /// + /// A local or parameter named like a mutable static shadows it, and is reached without + /// unsafe. Cleared once each body has been written. + /// + private readonly HashSet boundLocals = new(StringComparer.Ordinal); + + /// + /// The root was worked out for. + /// + private AstNode? writtenStaticsRoot; + + /// + /// The module-level fields something in the document assigns to, which Rust declares + /// static mut. + /// + private HashSet writtenStatics = new(StringComparer.Ordinal); + + /// + /// Whether the code being written is already inside an unsafe block. + /// + private bool insideUnsafe; + /// /// Spells type parameters where they are being declared, bounds and all. /// @@ -1137,9 +1162,73 @@ protected override void GenerateAssignmentStatement(AssignmentStatement assignme return; } + if (assignment.Target is VariableReference { Name: string name } && IsMutableStatic(RootOf(name))) + { + // Every access to a static mut is unsafe, because nothing stops two threads reaching it at + // once. The block holds the whole statement so a value read from the same static is + // covered by it rather than needing a block of its own. + code.WriteLine("unsafe {"); + code.Indent(); + insideUnsafe = true; + try + { + base.GenerateAssignmentStatement(assignment, code); + } + finally + { + insideUnsafe = false; + } + + code.Outdent(); + code.WriteLine("}"); + return; + } + base.GenerateAssignmentStatement(assignment, code); } + /// + /// + /// A read of a static mut is unsafe as well as a write, so outside a block that is + /// already unsafe it is written as an unsafe expression that yields the value. + /// + protected override void GenerateVariableReference(VariableReference reference, CodeBlocker code) + { + Ensure.NotNull(reference); + Ensure.NotNull(code); + + if (!insideUnsafe && IsMutableStatic(RootOf(reference.Name))) + { + code.Write($"unsafe {{ {reference.Name} }}"); + return; + } + + base.GenerateVariableReference(reference, code); + } + + /// + /// Tells whether a name, as a body sees it, is a module-level field declared static mut. + /// + /// The name to look up. + /// Whether it names a mutable static rather than a local, parameter or constant. + private bool IsMutableStatic(string name) => + !boundLocals.Contains(name) && WrittenStatics().Contains(name); + + /// + /// Reads the module-level fields written to in the document being generated, once per document. + /// + /// Their names. + private HashSet WrittenStatics() + { + if (!ReferenceEquals(writtenStaticsRoot, GenerationRoot)) + { + writtenStaticsRoot = GenerationRoot; + writtenStatics = WrittenFileScopeFields(); + } + + return writtenStatics; + } + /// /// How a body names a field of the instance its function was called on. /// @@ -1190,9 +1279,16 @@ private static bool IsOwnedString(TypeReference? type) => /// The writer to emit into. private void WriteBody(IEnumerable statements, CodeBlocker code) { - foreach (AstNode statement in statements) + try { - GenerateInternal(statement, code); + foreach (AstNode statement in statements) + { + GenerateInternal(statement, code); + } + } + finally + { + boundLocals.Clear(); } } @@ -1545,6 +1641,11 @@ protected override void GenerateParameter(Parameter parameter, CodeBlocker code, code.Write("mut "); } + if (parameter.Name is not null) + { + boundLocals.Add(parameter.Name); + } + code.Write($"{parameter.Name ?? $"param{position}"}: "); code.Write(SpellType(parameter.Type ?? new TypeReference(UnknownTypeName))); @@ -1573,6 +1674,7 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, code.Write(varDecl.IsConstant ? "let " : "let mut "); code.Write(varDecl.Name); + boundLocals.Add(varDecl.Name); bool inferred = varDecl.IsTypeInferred || varDecl.Type is null; TypeReference? annotation = !inferred ? varDecl.Type @@ -1638,7 +1740,9 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo } string visibility = IsAssociatedItem ? string.Empty : SpellVisibilityKeyword(field.Visibility); - string storage = IsAssociatedItem || field.IsConstant ? "const" : "static"; + string storage = IsAssociatedItem || field.IsConstant ? "const" + : field.Name is not null && WrittenStatics().Contains(field.Name) ? "static mut" + : "static"; code.Write($"{visibility}{storage} "); code.Write($"{field.Name}: {SpellStorageType(type)} = "); diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index b3a22e2..14e3e69 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -482,6 +482,82 @@ or BinaryOperator.LogicalAnd return staticTypeScopes.TryPeek(out StaticTypeScope? scope) ? scope.ReturnType : null; } + /// + /// Emits a reference to a variable, field or parameter by name. + /// + /// The reference to emit. + /// The writer to emit into. + /// + /// The name is written as it stands. A language that reaches some names through more than the + /// name itself, such as Rust's mutable statics, overrides this. + /// + protected virtual void GenerateVariableReference(VariableReference reference, CodeBlocker code) + { + Ensure.NotNull(reference); + Ensure.NotNull(code); + code.Write(reference.Name); + } + + /// + /// Finds the file-scope fields some statement in what is being generated assigns to. + /// + /// The names of the written fields. + /// + /// A field is file-scope when it is a member of a or of a namespace in + /// one, rather than of a type. A constant is never counted: an assignment to one is the document's + /// mistake, and treating it as written would quietly make it mutable. A target that names a member + /// of the field (count.total) writes the field too, so the name it starts from is what is + /// matched. A function's own local or parameter of the same name is not told apart here; each + /// language decides what shadowing means for the names this returns. + /// + protected HashSet WrittenFileScopeFields() + { + HashSet written = new(StringComparer.Ordinal); + if (GenerationRoot is null) + { + return written; + } + + HashSet fields = new(StringComparer.Ordinal); + foreach (AstNode node in SelfAndDescendants(GenerationRoot)) + { + IEnumerable members = node switch + { + SourceFile file => file.Members, + NamespaceDeclaration space => space.Members, + _ => [], + }; + + foreach (FieldDeclaration field in members.OfType()) + { + if (!field.IsConstant && !string.IsNullOrEmpty(field.Name)) + { + fields.Add(field.Name); + } + } + } + + if (fields.Count == 0) + { + return written; + } + + foreach (AssignmentStatement assignment in SelfAndDescendants(GenerationRoot).OfType()) + { + if (assignment.Target is VariableReference { Name: string name }) + { + int dot = name.IndexOf('.', StringComparison.Ordinal); + string root = dot < 0 ? name : name[..dot]; + if (fields.Contains(root)) + { + written.Add(root); + } + } + } + + return written; + } + /// /// Reads the return type of the one function a receiver-less call can name. /// @@ -676,6 +752,10 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) GenerateAssignmentStatement(assignment, code); break; + case VariableReference reference: + GenerateVariableReference(reference, code); + break; + default: if (!TryGenerateCommonNode(node, code)) {