Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions Coder.Test/Languages/ModuleScopeVariableTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// C#'s <c>var</c> is only for a local (CS0825), Rust's <c>let</c> is a statement and Go's
/// <c>:=</c> 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.
/// </remarks>
[TestClass]
public class ModuleScopeVariableTests
{
/// <summary>
/// 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.
/// </summary>
/// <returns>The file.</returns>
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;
}

/// <summary>
/// Tests that C# says the type an inferred held variable's initialiser establishes.
/// </summary>
[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);
}

/// <summary>
/// Tests that C# writes a held variable with nothing to infer from as an object, and says so.
/// </summary>
[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);
}

/// <summary>
/// Tests that Rust writes a module's variables as statics and constants, and a function's as
/// <c>let</c>.
/// </summary>
[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);
}

/// <summary>
/// Tests that Go writes a package's inferred variable with <c>var</c>, and a function's with
/// <c>:=</c>.
/// </summary>
[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);
}

/// <summary>
/// Tests that the C# for the file builds.
/// </summary>
[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"), """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
</PropertyGroup>
</Project>
""");
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}");
});
}

/// <summary>
/// Tests that the Rust for the file compiles.
/// </summary>
[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}");
});
}

/// <summary>
/// Tests that the Go for the file compiles.
/// </summary>
[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}");
});
}
}
40 changes: 39 additions & 1 deletion Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1746,7 +1746,7 @@
return string.Equals(name, "void", StringComparison.OrdinalIgnoreCase) ? "void" : name;
}

private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocker code)

Check failure on line 1749 in Coder/Languages/CSharpGenerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYDbqOCI6c8fQabkmn&open=AaFYDbqOCI6c8fQabkmn&pullRequest=325
{
if (!varDecl.IsTypeInferred && varDecl.Type is TypeReference declaredType)
{
Expand All @@ -1766,6 +1766,21 @@
// 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} ");
Expand All @@ -1774,7 +1789,12 @@
// 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 ");
Expand All @@ -1796,6 +1816,24 @@
code.WriteLine(";");
}

/// <summary>
/// Reads the type a field's initialiser establishes, for a field C# will not let infer it.
/// </summary>
/// <param name="initialValue">The initialiser, if there is one.</param>
/// <returns>The C# type, or null when the initialiser does not establish one.</returns>
private static string? InferredFieldType(Expression? initialValue) => initialValue switch
{
{ ExpectedType: string expected } => MapToCSType(new TypeReference(expected)),
LiteralExpression<string> => "string",
LiteralExpression<int> => "int",
LiteralExpression<long> => "long",
LiteralExpression<float> => "float",
LiteralExpression<double> => "double",
LiteralExpression<bool> => "bool",
ConstructionExpression { Type: TypeReference constructed } => MapToCSType(constructed),
_ => null,
};

/// <summary>
/// Emits the program's entry point as C#'s <c>Main</c> method.
/// </summary>
Expand Down
5 changes: 3 additions & 2 deletions Coder/Languages/GoGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions Coder/Languages/RustGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@
/// The associated constants of <c>f64</c>, which need nothing imported.
/// </remarks>
protected override string SpellNonFiniteDouble(double value) =>
double.IsNaN(value) ? "f64::NAN" : value > 0 ? "f64::INFINITY" : "f64::NEG_INFINITY";

Check warning on line 350 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
/// <remarks>
Expand All @@ -355,7 +355,7 @@
/// <c>f32</c> implicitly.
/// </remarks>
protected override string SpellNonFiniteSingle(float value) =>
float.IsNaN(value) ? "f32::NAN" : value > 0 ? "f32::INFINITY" : "f32::NEG_INFINITY";

Check warning on line 358 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
/// <remarks>
Expand Down Expand Up @@ -813,7 +813,7 @@
/// <param name="funcDecl">The declaration to emit.</param>
/// <param name="typeName">The type it belongs to.</param>
/// <param name="code">The writer to emit into.</param>
private void GenerateTraitImplementation(FunctionDeclaration funcDecl, string typeName, CodeBlocker code)

Check warning on line 816 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.
{
if (funcDecl.Kind == FunctionKind.ConversionOperator)
{
Expand Down Expand Up @@ -865,7 +865,7 @@
.. operands.Select(parameter => new KeyValuePair<string, TypeReference?>(OperandName(parameter), parameter.Type)),
]);
bool rebind = receiverName != SelfName;
string self = op.ReturnsBool ? "&self" : reassigned.Contains(receiverName) && !rebind ? $"mut {SelfName}" : SelfName;

Check warning on line 868 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
code.Write($"fn {op.Method}({self}");

foreach (Parameter parameter in operands)
Expand Down Expand Up @@ -1636,12 +1636,28 @@
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);

bool inferred = varDecl.IsTypeInferred || varDecl.Type is null;
TypeReference? annotation = !inferred ? varDecl.Type
: varDecl.InitialValue is null ? new TypeReference(UnknownTypeName)

Check warning on line 1660 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
: InferredLiteralWidth(varDecl.InitialValue);

if (annotation is not null)
Expand Down
31 changes: 31 additions & 0 deletions Coder/Languages/StandardLanguageGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
{
private readonly Stack<StaticTypeScope> staticTypeScopes = new();

/// <summary>
/// The variables declared directly in a namespace or a file, which are items rather than locals.
/// </summary>
private readonly HashSet<VariableDeclaration> moduleVariables = new(ReferenceEqualityComparer.Instance);

/// <summary>
/// Determines whether this generator can generate code for the specified AST node.
/// </summary>
Expand Down Expand Up @@ -145,7 +150,7 @@
/// without this its body has no scope and a typed local or the setter's value is not known to be
/// an integer.
/// </remarks>
protected IDisposable OpenStaticTypeScope(PropertyDeclaration property, bool isSetter, string valueName = "value")

Check warning on line 153 in Coder/Languages/StandardLanguageGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'value' 5 times.

Check warning on line 153 in Coder/Languages/StandardLanguageGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'OpenStaticTypeScope' method overloads should be adjacent.
{
Ensure.NotNull(property);
Ensure.NotNull(valueName);
Expand Down Expand Up @@ -272,7 +277,7 @@
return null;
}

return left == "double" || right == "double" ? "double" : "float";

Check warning on line 280 in Coder/Languages/StandardLanguageGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'float' 5 times.

Check warning on line 280 in Coder/Languages/StandardLanguageGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'double' 6 times.
}

private static string? FloatingTypeName(TypeReference? type) =>
Expand Down Expand Up @@ -393,6 +398,30 @@
}
}

/// <summary>
/// Reports whether a variable is declared directly in a namespace or a file rather than in a body.
/// </summary>
/// <param name="declaration">The declaration to test.</param>
/// <returns>True when it is a module-scope variable.</returns>
/// <remarks>
/// Several targets spell a local as a statement — Rust's <c>let</c>, Go's <c>:=</c> — 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.
/// </remarks>
protected bool IsModuleVariable(VariableDeclaration declaration) => moduleVariables.Contains(declaration);

/// <summary>
/// Records the variables a namespace or a file declares directly.
/// </summary>
/// <param name="members">The scope's members.</param>
private void HoldModuleVariables(IEnumerable<AstNode> members)
{
foreach (VariableDeclaration variable in members.OfType<VariableDeclaration>())
{
moduleVariables.Add(variable);
}
}

/// <summary>
/// Reads the width an inferred local must be written with, because the target would infer another.
/// </summary>
Expand Down Expand Up @@ -754,10 +783,12 @@
break;

case SourceFile file:
HoldModuleVariables(file.Members);
GenerateSourceFile(WithEntryPointsAtFileScope(file), code);
break;

case NamespaceDeclaration namespaceDecl:
HoldModuleVariables(namespaceDecl.Members);
GenerateNamespaceDeclaration(namespaceDecl, code);
break;

Expand Down Expand Up @@ -1098,7 +1129,7 @@
}

string name = fallback;
for (int suffix = 2; taken.Contains(name); suffix++)

Check warning on line 1132 in Coder/Languages/StandardLanguageGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

This loop's stop condition tests 'name' and 'taken' but the incrementer updates 'suffix'.
{
name = string.Create(CultureInfo.InvariantCulture, $"{fallback}{suffix}");
}
Expand Down
Loading