diff --git a/Coder.Test/Languages/EntryPointPlacementTests.cs b/Coder.Test/Languages/EntryPointPlacementTests.cs new file mode 100644 index 0000000..4bbb7d4 --- /dev/null +++ b/Coder.Test/Languages/EntryPointPlacementTests.cs @@ -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; + +/// +/// 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). +/// +[TestClass] +public class EntryPointPlacementTests +{ + /// + /// The exit code the programs below return, which is what proves the body ran. + /// + private const int Expected = 3; + + /// + /// The project the C# program is built as. + /// + private const string Project = """ + + + Exe + net10.0 + disable + disable + + + """; + + /// + /// Tests that C# puts an entry point outside a type into a static class, since C# declares a method + /// nowhere else. + /// + /// Where the document declared the entry point. + [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); + } + + /// + /// 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. + /// + /// The generator's display name. + /// How the entry point starts, at the start of a line. + [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); + } + + /// + /// 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. + /// + /// The generator's display name. + /// How the entry point starts, at the start of a line. + /// What brings the namespace's names into the body. + [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); + } + + /// + /// 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. + /// + /// The generator's display name. + [TestMethod] + [DataRow("C")] + [DataRow("C++")] + [DataRow("Rust")] + [DataRow("Go")] + [DataRow("C#")] + public void AnEntryPointInANamespace_CompilesLinksAndRuns(string language) => + CompileAndRun(language, Program("namespace", withHelper: true)); + + /// + /// Tests that a program whose entry point is declared in a type compiles, links and runs in every + /// language with a compiler. + /// + /// The generator's display name. + [TestMethod] + [DataRow("C")] + [DataRow("C++")] + [DataRow("Rust")] + [DataRow("Go")] + [DataRow("C#")] + public void AnEntryPointInAType_CompilesLinksAndRuns(string language) => + CompileAndRun(language, Program("type", withHelper: false)); + + /// + /// 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. + /// + /// The generator's display name. + /// How the entry point starts, at the start of a line. + [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}"); + } + + /// + /// 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. + /// + /// The generator's display name. + /// How the entry point starts, at the start of a line. + /// What brings the namespace's names into the body. + [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); + } + + /// + /// Tests that a type which loses its entry point to file scope keeps its annotations, which the + /// copy left behind carries in its place. + /// + /// The generator's display name. + [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); + } + + /// + /// Builds a program that exits with . + /// + /// Where the entry point is declared: file, namespace or type. + /// Whether the value comes from a function declared beside the entry point. + /// The file. + private static SourceFile Program(string placement, bool withHelper) + { + EntryPoint main = new() { ReturnsExitCode = true }; + Expression value = withHelper ? new CallExpression("helper") : new LiteralExpression(Expected); + main.Body.Add(new ReturnStatement(value)); + + List members = []; + if (withHelper) + { + FunctionDeclaration helper = new("helper") { ReturnType = "int", Visibility = Visibility.Public, IsStatic = true }; + helper.Body.Add(new ReturnStatement(new LiteralExpression(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), + }; +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index a40841b..1b18d84 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -652,8 +652,8 @@ protected override void GenerateFileMembers(SourceFile file, CodeBlocker code) /// What to call the class, unless a type beside it already has the name. /// The writer to emit into. /// - /// 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 /// Geometry.twice where C++ names Geometry::twice. The class is written where the /// first of them was declared, and types, enums and nested namespaces stay where they are. Aliases @@ -765,9 +765,9 @@ private void GenerateMemberHolder(string name, IEnumerable members, Cod /// Determines whether a member of a namespace or file is one C# can declare only inside a type. /// /// The member. - /// True for a field, a function or a variable. + /// True for a field, a function, a variable or an entry point. private static bool IsTypelessMember(AstNode member) => - member is FieldDeclaration or FunctionDeclaration or VariableDeclaration; + member is FieldDeclaration or FunctionDeclaration or VariableDeclaration or EntryPoint; /// /// Picks a name for the class holding a scope's members that no type beside it already has. @@ -1631,7 +1631,8 @@ private void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocke /// /// Always static, 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). /// private void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code) { diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index e03ee4f..642e730 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -737,6 +737,13 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code) EndStatement(code); } + /// + /// + /// The linker looks for main only at file scope; App::main is an ordinary function + /// nothing calls. + /// + protected override bool EntryPointsLeaveNamespaces => true; + /// /// /// C++'s main returns int whether or not the program means to hand back an exit @@ -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 args(argv + 1, argv + argc);"); diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 73f5e9f..463706e 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -1948,6 +1948,7 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co using (Scope helper = new(code)) { + WriteModuleImport(entryPoint, code); WriteBody(entryPoint.Body, code); } @@ -1963,6 +1964,7 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co using Scope body = new(code); + WriteModuleImport(entryPoint, code); if (entryPoint.AcceptsArguments) { code.WriteLine($"let args: Vec = {collect};"); @@ -1971,6 +1973,32 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co WriteBody(entryPoint.Body, code); } + /// + /// + /// The compiler looks for main only at the crate root; one in a module is an ordinary + /// private function and the crate fails with E0601. + /// + protected override bool EntryPointsLeaveNamespaces => true; + + /// + /// Brings the names of the module an entry point was declared in into its body. + /// + /// The entry point being written. + /// The writer to emit into. + /// + /// The entry point is written at the crate root, where the compiler looks for it, and the body + /// still names what was declared beside it (#128). A use inside the function reaches the + /// module without adding anything to the crate root's own names. + /// + private void WriteModuleImport(EntryPoint entryPoint, CodeBlocker code) + { + if (NamespacesLeftBy(entryPoint) is [_, ..] modules) + { + code.WriteLine("#[allow(unused_imports)]"); + code.WriteLine($"use self::{string.Join("::", modules)}::*;"); + } + } + /// /// Spells what a declaration said about who may see it, with a trailing space. /// diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index b3a22e2..97fb24b 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -4,6 +4,7 @@ namespace ktsu.Coder.Languages; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using ktsu.Coder.Ast; @@ -594,6 +595,12 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) Ensure.NotNull(node); Ensure.NotNull(code); + if (IsRootHoldingEntryPoint(node)) + { + GenerateWithEntryPointsAfter(node, code); + return; + } + switch (node) { case ClassDeclaration classDecl: @@ -605,7 +612,7 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) break; case SourceFile file: - GenerateSourceFile(file, code); + GenerateSourceFile(WithEntryPointsAtFileScope(file), code); break; case NamespaceDeclaration namespaceDecl: @@ -1050,6 +1057,205 @@ private IEnumerable Separate(PropertyDeclaration property, HashSet + /// Gets a value indicating whether an entry point declared in a namespace is written at file scope. + /// + /// + /// True for a language whose toolchain looks for its entry point only at file scope and whose + /// namespaces are real scopes: C++ does not link a main declared in a namespace, and Rust + /// does not find one declared in a module (#128). The languages that write a namespace's members + /// straight into the file have nothing to move. + /// + protected virtual bool EntryPointsLeaveNamespaces => false; + + /// + /// Whether the root being generated is the copy with its entry points taken out. + /// + private bool generatingView; + + /// + /// The namespaces each entry point written at file scope was declared in, outermost first. + /// + private Dictionary> relocatedEntryPoints = new(ReferenceEqualityComparer.Instance); + + /// + /// Gets the namespaces an entry point was declared in before it was moved to file scope. + /// + /// The entry point being written. + /// The namespace names, outermost first, or nothing when it was declared outside one. + /// + /// The body was written expecting the names declared beside it to be in scope, so a generator + /// that moved it out of its namespace brings that namespace's names back into the body. + /// + protected IReadOnlyList NamespacesLeftBy(EntryPoint entryPoint) => + relocatedEntryPoints.TryGetValue(entryPoint, out IReadOnlyList? path) ? path : []; + + /// + /// Determines whether a node is a type or namespace generated on its own with an entry point in it. + /// + /// The node being dispatched. + /// True when its entry points have to be written after it, at file scope. + private bool IsRootHoldingEntryPoint(AstNode node) => + !generatingView && ReferenceEquals(node, GenerationRoot) && HoldsEntryPoint(node); + + /// + /// Determines whether a type or namespace has an entry point anywhere inside it. + /// + /// The node to search. + /// True when one does. + private static bool HoldsEntryPoint(AstNode node) => + node is ClassDeclaration or NamespaceDeclaration && SelfAndDescendants(node).OfType().Any(); + + /// + /// Returns a file whose entry points all stand where the toolchain looks for them. + /// + /// The file as the document declared it. + /// The file itself when nothing moves, otherwise a copy with the entry points last. + /// + /// No language here other than C# finds an entry point inside a type: C++ does not link a member + /// main, C cannot put a function in a struct, and Rust and Go route a type's members by + /// what they are and dropped an entry point without a word. So an entry point in a type, and in a + /// language that asks for it one in a namespace too, is written at file scope after everything + /// else (#128). The containers are copies holding the document's own members, so the members + /// still meet every check by identity. + /// + private SourceFile WithEntryPointsAtFileScope(SourceFile file) + { + relocatedEntryPoints = new(ReferenceEqualityComparer.Instance); + if (!file.Members.Any(HoldsEntryPoint)) + { + return file; + } + + Dictionary> relocated = new(ReferenceEqualityComparer.Instance); + List moved = []; + List kept = WithoutEntryPoints(file.Members, [], moveHere: false, relocated, moved); + relocatedEntryPoints = relocated; + + SourceFile copy = (SourceFile)file.Clone(); + copy.Members.Clear(); + foreach (AstNode member in kept.Concat(moved)) + { + copy.Members.Add(member); + } + + return copy; + } + + /// + /// Emits a type or namespace generated on its own, then the entry points it holds at file scope. + /// + /// The type or namespace being generated. + /// The writer to emit into. + private void GenerateWithEntryPointsAfter(AstNode root, CodeBlocker code) + { + Dictionary> relocated = new(ReferenceEqualityComparer.Instance); + List moved = []; + AstNode view = WithoutEntryPoints([root], [], moveHere: false, relocated, moved)[0]; + relocatedEntryPoints = relocated; + + generatingView = true; + try + { + GenerateInternal(view, code); + } + finally + { + generatingView = false; + } + + foreach (EntryPoint entryPoint in moved) + { + code.NewLine(); + GenerateInternal(entryPoint, code); + } + } + + /// + /// Copies a run of members, leaving out the entry points that belong at file scope. + /// + /// The members as declared. + /// The namespaces enclosing them, outermost first. + /// Whether an entry point standing directly among them is moved. + /// Where each moved entry point is recorded with its namespaces. + /// The moved entry points, in the order declared. + /// The members that stay, with any container that lost an entry point copied. + private List WithoutEntryPoints( + IEnumerable members, + IReadOnlyList namespaces, + bool moveHere, + Dictionary> relocated, + List moved) + { + List kept = []; + foreach (AstNode member in members) + { + int before = moved.Count; + switch (member) + { + case EntryPoint entryPoint when moveHere: + relocated[entryPoint] = namespaces; + moved.Add(entryPoint); + continue; + + case ClassDeclaration classDecl when HoldsEntryPoint(classDecl): + { + List inner = WithoutEntryPoints(classDecl.Members, namespaces, moveHere: true, relocated, moved); + if (moved.Count == before) + { + break; + } + + ClassDeclaration copy = (ClassDeclaration)classDecl.Clone(); + for (int index = 0; index < classDecl.Annotations.Count; index++) + { + RememberAnnotationClone(copy.Annotations[index], classDecl.Annotations[index]); + } + + Refill(copy.Members, inner); + kept.Add(copy); + continue; + } + + case NamespaceDeclaration namespaceDecl when HoldsEntryPoint(namespaceDecl): + { + IReadOnlyList inside = [.. namespaces, .. NamespaceDeclaration.Split(namespaceDecl.Name)]; + List inner = WithoutEntryPoints(namespaceDecl.Members, inside, EntryPointsLeaveNamespaces, relocated, moved); + if (moved.Count == before) + { + break; + } + + NamespaceDeclaration copy = (NamespaceDeclaration)namespaceDecl.Clone(); + Refill(copy.Members, inner); + kept.Add(copy); + continue; + } + + default: + break; + } + + kept.Add(member); + } + + return kept; + } + + /// + /// Replaces what a collection holds. + /// + /// The collection to refill. + /// What it should hold. + private static void Refill(Collection target, IEnumerable members) + { + target.Clear(); + foreach (AstNode member in members) + { + target.Add(member); + } + } + /// /// Emits the program's entry point, and whatever else the language needs in order to run it. ///