Skip to content
Merged
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
293 changes: 293 additions & 0 deletions Coder.Test/Languages/EntryPointPlacementTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
// 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 an entry point declared at file scope, in a namespace or in a type is written where
/// each language's toolchain looks for it, rather than where the document happened to put it (#128).
/// </summary>
[TestClass]
public class EntryPointPlacementTests
{
/// <summary>
/// The exit code the programs below return, which is what proves the body ran.
/// </summary>
private const int Expected = 3;

/// <summary>
/// The project the C# program is built as.
/// </summary>
private const string Project = """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
""";

/// <summary>
/// Tests that C# puts an entry point outside a type into a static class, since C# declares a method
/// nowhere else.
/// </summary>
/// <param name="placement">Where the document declared the entry point.</param>
[TestMethod]
[DataRow("file")]
[DataRow("namespace")]
public void CSharp_WrapsAnEntryPointOutsideATypeInAStaticClass(string placement)
{
string code = new CSharpGenerator().Generate(Program(placement, withHelper: false));

int holder = code.IndexOf("static class", StringComparison.Ordinal);
int main = code.IndexOf("static int Main", StringComparison.Ordinal);
Assert.IsTrue(holder >= 0 && holder < main, code);
}

/// <summary>
/// Tests that every language other than C# writes an entry point declared in a type at file scope,
/// after the type, rather than inside it or not at all.
/// </summary>
/// <param name="language">The generator's display name.</param>
/// <param name="signature">How the entry point starts, at the start of a line.</param>
[TestMethod]
[DataRow("C", "int main(")]
[DataRow("C++", "int main(")]
[DataRow("Rust", "fn main(")]
[DataRow("Go", "func main(")]
[DataRow("Python", "def main(")]
[DataRow("JavaScript", "function main(")]
public void AnEntryPointInATypeIsWrittenAtFileScope(string language, string signature)
{
string code = GeneratorFor(language).Generate(Program("type", withHelper: false));

int type = code.IndexOf("Program", StringComparison.Ordinal);
int main = code.IndexOf($"\n{signature}", StringComparison.Ordinal);
Assert.IsTrue(type >= 0 && main > type, code);
}

/// <summary>
/// Tests that C++ and Rust write an entry point declared in a namespace at file scope, where it is
/// found, and keep the names declared beside it in reach of its body.
/// </summary>
/// <param name="language">The generator's display name.</param>
/// <param name="signature">How the entry point starts, at the start of a line.</param>
/// <param name="import">What brings the namespace's names into the body.</param>
[TestMethod]
[DataRow("C++", "int main(", "using namespace App;")]
[DataRow("Rust", "fn run(", "use self::App::*;")]
public void AnEntryPointInANamespaceIsWrittenAtFileScope(string language, string signature, string import)
{
string code = GeneratorFor(language).Generate(Program("namespace", withHelper: true));

Assert.IsTrue(code.Contains($"\n{signature}", StringComparison.Ordinal), code);
StringAssert.Contains(code, import, StringComparison.Ordinal, code);

Check warning on line 89 in Coder.Test/Languages/EntryPointPlacementTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYG-DtvmJXuzZVl3NT&open=AaFYG-DtvmJXuzZVl3NT&pullRequest=331
}

/// <summary>
/// Tests that a program whose entry point is declared in a namespace, beside the function it calls,
/// compiles, links and runs in every language with a compiler.
/// </summary>
/// <param name="language">The generator's display name.</param>
[TestMethod]
[DataRow("C")]
[DataRow("C++")]
[DataRow("Rust")]
[DataRow("Go")]
[DataRow("C#")]
public void AnEntryPointInANamespace_CompilesLinksAndRuns(string language) =>
CompileAndRun(language, Program("namespace", withHelper: true));

/// <summary>
/// Tests that a program whose entry point is declared in a type compiles, links and runs in every
/// language with a compiler.
/// </summary>
/// <param name="language">The generator's display name.</param>
[TestMethod]
[DataRow("C")]
[DataRow("C++")]
[DataRow("Rust")]
[DataRow("Go")]
[DataRow("C#")]
public void AnEntryPointInAType_CompilesLinksAndRuns(string language) =>
CompileAndRun(language, Program("type", withHelper: false));

/// <summary>
/// Tests that a type generated on its own, rather than inside a file, still has the entry point it
/// holds written after it at file scope.
/// </summary>
/// <param name="language">The generator's display name.</param>
/// <param name="signature">How the entry point starts, at the start of a line.</param>
[TestMethod]
[DataRow("C", "int main(")]
[DataRow("C++", "int main(")]
[DataRow("Rust", "fn main(")]
[DataRow("Go", "func main(")]
[DataRow("Python", "def main(")]
[DataRow("JavaScript", "function main(")]
public void AnEntryPointInATypeGeneratedOnItsOwnIsWrittenAfterIt(string language, string signature)
{
AstNode type = Program("type", withHelper: false).Members[0];

string code = GeneratorFor(language).Generate(type);

int declared = code.IndexOf("Program", StringComparison.Ordinal);
int main = code.IndexOf($"\n{signature}", StringComparison.Ordinal);
Assert.IsTrue(declared >= 0 && main > declared, code);
Assert.AreEqual(main, code.LastIndexOf($"\n{signature}", StringComparison.Ordinal), $"The entry point was written twice:{Environment.NewLine}{code}");
}

/// <summary>
/// Tests that a namespace generated on its own, rather than inside a file, has its entry point
/// written after it at file scope, with the names declared beside it brought back into the body.
/// </summary>
/// <param name="language">The generator's display name.</param>
/// <param name="signature">How the entry point starts, at the start of a line.</param>
/// <param name="import">What brings the namespace's names into the body.</param>
[TestMethod]
[DataRow("C++", "int main(", "using namespace App;")]
[DataRow("Rust", "fn run(", "use self::App::*;")]
public void AnEntryPointInANamespaceGeneratedOnItsOwnIsWrittenAfterIt(string language, string signature, string import)
{
AstNode container = Program("namespace", withHelper: true).Members[0];

string code = GeneratorFor(language).Generate(container);

int helper = code.IndexOf("helper", StringComparison.Ordinal);
int main = code.IndexOf($"\n{signature}", StringComparison.Ordinal);
Assert.IsTrue(helper >= 0 && main > helper, code);
Assert.IsTrue(code.IndexOf(import, StringComparison.Ordinal) > main, code);

Check warning on line 164 in Coder.Test/Languages/EntryPointPlacementTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYMh8c4PI52TNFFXm3&open=AaFYMh8c4PI52TNFFXm3&pullRequest=331
}

/// <summary>
/// Tests that a type which loses its entry point to file scope keeps its annotations, which the
/// copy left behind carries in its place.
/// </summary>
/// <param name="language">The generator's display name.</param>
[TestMethod]
[DataRow("C")]
[DataRow("C++")]
[DataRow("Rust")]
[DataRow("Go")]
[DataRow("Python")]
[DataRow("JavaScript")]
public void AnAnnotatedTypeKeepsItsAnnotationsWhenItsEntryPointMoves(string language)
{
SourceFile file = Program("type", withHelper: false);
((ClassDeclaration)file.Members[0]).Annotations.Add(new Annotation("Marked"));
LanguageGeneratorBase generator = (LanguageGeneratorBase)GeneratorFor(language);
generator.EnforceConservation = true;

string code = generator.Generate(file);

StringAssert.Contains(code, "Marked", StringComparison.Ordinal, code);

Check warning on line 188 in Coder.Test/Languages/EntryPointPlacementTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYMh8c4PI52TNFFXm4&open=AaFYMh8c4PI52TNFFXm4&pullRequest=331
}

/// <summary>
/// Builds a program that exits with <see cref="Expected"/>.
/// </summary>
/// <param name="placement">Where the entry point is declared: <c>file</c>, <c>namespace</c> or <c>type</c>.</param>
/// <param name="withHelper">Whether the value comes from a function declared beside the entry point.</param>
/// <returns>The file.</returns>
private static SourceFile Program(string placement, bool withHelper)
{
EntryPoint main = new() { ReturnsExitCode = true };
Expression value = withHelper ? new CallExpression("helper") : new LiteralExpression<int>(Expected);
main.Body.Add(new ReturnStatement(value));

List<AstNode> members = [];
if (withHelper)
{
FunctionDeclaration helper = new("helper") { ReturnType = "int", Visibility = Visibility.Public, IsStatic = true };
helper.Body.Add(new ReturnStatement(new LiteralExpression<int>(Expected)));
members.Add(helper);
}

members.Add(main);

SourceFile file = new("program");
switch (placement)
{
case "namespace":
{
NamespaceDeclaration container = new("App");
members.ForEach(container.Members.Add);
file.Members.Add(container);
break;
}

case "type":
{
ClassDeclaration container = new("Program");
members.ForEach(container.Members.Add);
file.Members.Add(container);
break;
}

default:
members.ForEach(file.Members.Add);
break;
}

return file;
}

private static void CompileAndRun(string language, SourceFile file)
{
(string[] commands, string versionArgument, string fileName, string build, string run, string runArguments) = ToolchainFor(language);
string? compiler = ToolchainHarness.FindOnPath(versionArgument, commands);
if (compiler is null)
{
Assert.Inconclusive($"No {language} toolchain on the path, so nothing was compiled.");
return;
}

string code = GeneratorFor(language).Generate(file);
ToolchainHarness.InTemporaryDirectory(directory =>
{
File.WriteAllText(Path.Combine(directory, fileName), code);
if (language == "Go")
{
File.WriteAllText(Path.Combine(directory, "go.mod"), "module program\n\ngo 1.21\n");
}
else if (language == "C#")
{
File.WriteAllText(Path.Combine(directory, "program.csproj"), Project);
}

(int exitCode, string output) = ToolchainHarness.Run(compiler, build, directory);
Assert.AreEqual(0, exitCode, $"{compiler} rejected the generated source:{Environment.NewLine}{code}{Environment.NewLine}{output}");

string program = run.Length == 0 ? Path.Combine(directory, "program") : run;
(int programExitCode, string programOutput) = ToolchainHarness.Run(program, runArguments, directory);
Assert.AreEqual(Expected, programExitCode, $"The entry point did not run:{Environment.NewLine}{code}{Environment.NewLine}{programOutput}");
});
}

private static (string[] Commands, string VersionArgument, string FileName, string Build, string Run, string RunArguments) ToolchainFor(string language) => language switch
{
"C" => (["cc", "gcc", "clang"], "--version", "program.c", "-std=c11 -Wall -Wextra -pedantic -Werror program.c -o program", string.Empty, string.Empty),
"C++" => (["c++", "g++", "clang++"], "--version", "program.cpp", "-std=c++20 -Wall -Wextra -pedantic -Werror program.cpp -o program", string.Empty, string.Empty),
"Rust" => (["rustc"], "--version", "program.rs", "--edition 2021 -o program program.rs", string.Empty, string.Empty),
"Go" => (["go"], "version", "program.go", "build -o program .", string.Empty, string.Empty),
"C#" => (["dotnet"], "--version", "Program.cs", "build -nologo -o out", "dotnet", Path.Combine("out", "program.dll")),
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null),
};

private static ILanguageGenerator GeneratorFor(string language) => language switch
{
"C" => new CGenerator(),
"C++" => new CppGenerator(),
"Rust" => new RustGenerator(),
"Go" => new GoGenerator(),
"Python" => new PythonGenerator(),
"JavaScript" => new JavaScriptGenerator(),
"C#" => new CSharpGenerator(),
_ => throw new ArgumentOutOfRangeException(nameof(language), language, null),
};
}
11 changes: 6 additions & 5 deletions Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@
/// A member is emitted through the same dispatch as any other node, so a nested class, a method
/// and a field each come out through the emitter for their own shape.
/// </remarks>
private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code)

Check warning on line 303 in Coder/Languages/CSharpGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
bool aliasesWereHoisted = memberAliasesHoisted;
if (!aliasesWereHoisted)
Expand Down Expand Up @@ -652,8 +652,8 @@
/// <param name="holderName">What to call the class, unless a type beside it already has the name.</param>
/// <param name="code">The writer to emit into.</param>
/// <remarks>
/// C# has no field, function or variable outside a type: one in a namespace fails with CS0116,
/// and one at file scope with CS0106. Every other target has somewhere to put them, so the AST
/// C# has no field, function, variable or entry point outside a type: one in a namespace fails
/// with CS0116, and one at file scope with CS0106 (#128). Every other target has somewhere to put them, so the AST
/// allows them, and a static class is what C# has in their place — a reader names
/// <c>Geometry.twice</c> where C++ names <c>Geometry::twice</c>. The class is written where the
/// first of them was declared, and types, enums and nested namespaces stay where they are. Aliases
Expand Down Expand Up @@ -765,9 +765,9 @@
/// Determines whether a member of a namespace or file is one C# can declare only inside a type.
/// </summary>
/// <param name="member">The member.</param>
/// <returns>True for a field, a function or a variable.</returns>
/// <returns>True for a field, a function, a variable or an entry point.</returns>
private static bool IsTypelessMember(AstNode member) =>
member is FieldDeclaration or FunctionDeclaration or VariableDeclaration;
member is FieldDeclaration or FunctionDeclaration or VariableDeclaration or EntryPoint;

/// <summary>
/// Picks a name for the class holding a scope's members that no type beside it already has.
Expand Down Expand Up @@ -1631,7 +1631,8 @@
/// <remarks>
/// Always <c>static</c>, and named with the capital C# gives it. The method is emitted on its own
/// rather than wrapped in a class, because a C# entry point is an ordinary member of whatever
/// class the document puts it in — including one this generator emits around it.
/// class the document puts it in — including the static class this generator writes around one
/// declared in a namespace or a file (#128).
/// </remarks>
private void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code)
{
Expand Down
14 changes: 14 additions & 0 deletions Coder/Languages/CppGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,13 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code)
EndStatement(code);
}

/// <inheritdoc/>
/// <remarks>
/// The linker looks for <c>main</c> only at file scope; <c>App::main</c> is an ordinary function
/// nothing calls.
/// </remarks>
protected override bool EntryPointsLeaveNamespaces => true;

/// <inheritdoc/>
/// <remarks>
/// C++'s <c>main</c> returns <c>int</c> whether or not the program means to hand back an exit
Expand Down Expand Up @@ -765,6 +772,13 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co

using IDisposable types = OpenStaticTypeScope(entryPoint);
using Scope body = new(code);
if (NamespacesLeftBy(entryPoint) is [_, ..] namespaces)
{
// Written at file scope, where the linker looks for it, so the names declared beside it in
// its namespace are brought back in (#128).
code.WriteLine($"using namespace {string.Join("::", namespaces)};");
}

if (entryPoint.AcceptsArguments)
{
code.WriteLine("std::vector<std::string> args(argv + 1, argv + argc);");
Expand Down
Loading
Loading