diff --git a/Coder.Test/Languages/CSharpConstantLocalTests.cs b/Coder.Test/Languages/CSharpConstantLocalTests.cs
new file mode 100644
index 0000000..ad7191b
--- /dev/null
+++ b/Coder.Test/Languages/CSharpConstantLocalTests.cs
@@ -0,0 +1,56 @@
+// 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 constant local is written as a C# const only when C# can hold it as one.
+///
+///
+/// A C# const local must be a compile-time constant, so const int doubled = (n * 2); is
+/// error CS0133. C# has no readonly local, so a constant computed at run time is an ordinary local,
+/// the same as an inferred one already was.
+///
+[TestClass]
+public class CSharpConstantLocalTests
+{
+ ///
+ /// Tests that a constant computed from a parameter or a call is not written as const.
+ ///
+ [TestMethod]
+ public void NonLiteralConstant_IsAPlainLocal()
+ {
+ FunctionDeclaration twice = new("Twice") { ReturnType = "int", IsStatic = true };
+ twice.Parameters.Add(new Parameter("n", "int"));
+ BinaryExpression product = new(new VariableReference("n"), BinaryOperator.Multiply, Literal.Number(2));
+ twice.Body.Add(new VariableDeclaration("doubled", "int", product) { IsConstant = true });
+ CallExpression call = new("Math.Abs");
+ call.Arguments.Add(new VariableReference("doubled"));
+ twice.Body.Add(new VariableDeclaration("size", "int", call) { IsConstant = true });
+ twice.Body.Add(new ReturnStatement(new VariableReference("size")));
+
+ string code = new CSharpGenerator().Generate(twice);
+
+ Assert.Contains("int doubled = (n * 2);", code, StringComparison.Ordinal, code);
+ Assert.Contains("int size = Math.Abs(doubled);", code, StringComparison.Ordinal, code);
+ Assert.DoesNotContain("const", code, StringComparison.Ordinal, code);
+ }
+
+ ///
+ /// Tests that a constant holding a literal is still written as const.
+ ///
+ [TestMethod]
+ public void LiteralConstant_IsConst()
+ {
+ FunctionDeclaration limit = new("Limit") { ReturnType = "int", IsStatic = true };
+ limit.Body.Add(new VariableDeclaration("max", "int", Literal.Number(7)) { IsConstant = true });
+ limit.Body.Add(new ReturnStatement(new VariableReference("max")));
+
+ string code = new CSharpGenerator().Generate(limit);
+
+ Assert.Contains("const int max = 7;", code, StringComparison.Ordinal, code);
+ }
+}
diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs
index 772f358..5d4b552 100644
--- a/Coder/Languages/CSharpGenerator.cs
+++ b/Coder/Languages/CSharpGenerator.cs
@@ -900,9 +900,12 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke
code.Write($"{modifier} ");
}
- // A C# constant must name its type, so an inferred one stays a plain declaration rather than
- // becoming source that does not compile.
- if (varDecl.IsConstant && !string.Equals(type, "var", StringComparison.Ordinal))
+ // 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
+ && !string.Equals(type, "var", StringComparison.Ordinal)
+ && IsCompileTimeValue(varDecl.InitialValue))
{
code.Write("const ");
}
diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs
index 01353d7..c5cfcf9 100644
--- a/Coder/Languages/GoGenerator.cs
+++ b/Coder/Languages/GoGenerator.cs
@@ -1331,26 +1331,6 @@ private void WriteStorageKeyword(string? name, bool wanted, AstNode? value, Code
/// True when Go will hold it as a constant.
private static bool IsConstant(bool wanted, AstNode? value) => wanted && IsCompileTimeValue(value);
- ///
- /// Reports whether a value is one Go will let a const hold.
- ///
- /// The value to test.
- /// True when it is.
- ///
- /// A literal, and nothing else. Go's constants are the untyped ones the compiler evaluates, which
- /// rules out every value with a field or an element in it however fixed its contents are.
- ///
- private static bool IsCompileTimeValue(AstNode? value) =>
- value is LiteralExpression
- or LiteralExpression
- or LiteralExpression
- or LiteralExpression
- or LiteralExpression
- or LiteralExpression
- or AstLeafNode
- or AstLeafNode
- or AstLeafNode;
-
///
/// Writes what a declaration starts at, giving a bare list the declaration's own type.
///
diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs
index 5a72e9d..0e2c06f 100644
--- a/Coder/Languages/LanguageGeneratorBase.cs
+++ b/Coder/Languages/LanguageGeneratorBase.cs
@@ -448,6 +448,28 @@ protected static IEnumerable SelfAndDescendants(AstNode root)
protected static bool ContainsNonFiniteDouble(SourceFile file) =>
SelfAndDescendants(file).Any(node => node is LiteralExpression literal && !double.IsFinite(literal.Value));
+ ///
+ /// Reports whether a value is one the compiler evaluates, and so one a constant can hold.
+ ///
+ /// The value to test.
+ /// True when it is.
+ ///
+ /// A literal, and nothing else. Go's constants are the untyped ones the compiler evaluates, and a
+ /// C# const local must be a compile-time constant, which rules out a parameter, a call, an
+ /// expression over either, and every value with a field or an element in it however fixed its
+ /// contents are. Sharing the rule keeps the two generators from drifting apart on it.
+ ///
+ protected static bool IsCompileTimeValue(AstNode? value) =>
+ value is LiteralExpression
+ or LiteralExpression
+ or LiteralExpression
+ or LiteralExpression
+ or LiteralExpression
+ or LiteralExpression
+ or AstLeafNode
+ or AstLeafNode
+ or AstLeafNode;
+
///
/// Writes down the types a declaration is written over, for a target that has no generics.
///