From db6fd924427711d54cc139c6af4c2462587e0213 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:36:19 +0000 Subject: [PATCH 1/3] feat: add a Go language generator [minor] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GoGenerator, a seventh target alongside C#, C++, C, Rust, Python and JavaScript. Go answers most of what the AST says with something it already had. A type is a struct with its methods declared beside it; an interface is an interface that nothing declares it implements; a base type is an embedded field, whose members are promoted. A member that promises not to modify what it is called on takes a value receiver and one that does takes a pointer receiver — the promise C++ writes as a trailing const, made by the shape of the declaration rather than by a modifier on it. What Go left out it left out on purpose, so each gap has an answer rather than a note. An operator is a method named from the AST's own word for it, a constructor is New, a destructor is the Close a caller defers, an enumeration is a named type and a block of iota constants, and a compile-time assertion is a map literal whose duplicate constant key Go refuses. What is left is visibility, which Go says with the first letter of the name and no keyword: a name that disagrees with what it asked for gets a note, because renaming it would not rename the references to it. The output is already what gofmt would write — tab indentation and the columns of a struct's fields and a constant block lined up — which is checked by running gofmt over it. Go has one formatter and everybody runs it, so a generated file it disagrees with is a diff the first time anyone opens it. Shared rather than duplicated: - LanguageGeneratorBase.IndentString becomes an overridable property, since Go's indentation is not this generator's to pick. - StandardLanguageGenerator gains the run-of-members walk, the two-undocumented-members-of-a-kind grouping rule and the operator names built from the AST's vocabulary; the C family and Rust now reach them there instead of each holding a copy. A list's padding becomes overridable, because Go writes Point{X: 1}. - CompiledExemplar gains the declarations the Rust and Go compile tests are both checked against, so the claim stays "one AST, three real compilers". Coder.Editor registers a Go highlighter definition, since the highlighter ships fifteen languages and Go is not one of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D7Exu6cR3QLkHY5F8uSks7 --- CLAUDE.md | 57 +- Coder.Editor/CoderEditorApp.cs | 6 +- Coder.Editor/EditorSyntax.cs | 51 + Coder.Editor/GoSyntax.cs | 78 + Coder.Editor/RustSyntax.cs | 26 +- Coder.Test/Editor/CoderEditorAppTests.cs | 4 +- Coder.Test/Editor/EditorWiringTests.cs | 4 +- .../Editor/GeneratedCodeHighlightingTests.cs | 14 +- .../CGeneratedSourceCompilesTests.cs | 4 +- Coder.Test/Languages/CompiledExemplar.cs | 171 +- .../Languages/GeneratedLineEndingTests.cs | 2 + .../GoGeneratedSourceCompilesTests.cs | 186 ++ Coder.Test/Languages/GoGeneratorTests.cs | 878 ++++++++++ .../RustGeneratedSourceCompilesTests.cs | 124 +- Coder.Test/Languages/ToolchainHarness.cs | 22 +- .../ServiceCollectionExtensionsTests.cs | 3 +- Coder/Languages/CFamilyGenerator.cs | 23 +- Coder/Languages/CGenerator.cs | 45 +- Coder/Languages/GoGenerator.cs | 1515 +++++++++++++++++ Coder/Languages/LanguageGeneratorBase.cs | 19 +- Coder/Languages/RustGenerator.cs | 40 +- Coder/Languages/StandardLanguageGenerator.cs | 147 +- Coder/ServiceCollectionExtensions.cs | 1 + README.md | 17 +- docs/design.md | 17 +- 25 files changed, 3144 insertions(+), 310 deletions(-) create mode 100644 Coder.Editor/EditorSyntax.cs create mode 100644 Coder.Editor/GoSyntax.cs create mode 100644 Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs create mode 100644 Coder.Test/Languages/GoGeneratorTests.cs create mode 100644 Coder/Languages/GoGenerator.cs diff --git a/CLAUDE.md b/CLAUDE.md index 20c9e80..3b1550b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ dotnet run --project Coder.Editor ## Project Structure `ktsu.Coder` represents code as a language-agnostic AST, round-trips it through YAML, and generates -source in six target languages. The solution uses: +source in seven target languages. The solution uses: - **ktsu.Sdk** — custom SDK providing shared build configuration - **MSTest.Sdk** — test project SDK with Microsoft Testing Platform @@ -80,15 +80,19 @@ source in six target languages. The solution uses: `IsConstant` is the intent rather than the keyword: C++ writes `inline constexpr` at namespace scope and `static constexpr` inside a type, C# writes `static readonly`, C writes `static const` at file scope and a note inside a struct, having no static data member at all, Rust picks between - a `const` and a `static` with it and lets it decide whether a local is `let` or `let mut`, and a - language with no spelling for it omits it the way it omits an indirection. + a `const` and a `static` with it and lets it decide whether a local is `let` or `let mut`, Go + honours it only where the language can — a Go `const` holds a number, a string or a boolean and + nothing with a field in it, so a table is a `var` with a note — and a language with no spelling for + it omits it the way it omits an indirection. - `Coder/Ast/ClassDeclaration.cs`'s `SpecialisationArguments` — what makes a declaration be *for* a type rather than *of* one. `template<> struct Describe` is how C++ attaches a fact to a type without touching the type, which is what a generated reflection table needs: the alternative is naming, and a `DescribeRigidBody` every consumer has to spell for itself is the thing a lookup by type exists to avoid. C++ has it, and Rust answers it exactly — `impl Describe for RigidBody` attaches facts to a type without touching the type, which is the whole of what the specialisation - is for; the rest write a comment, the same as `CompileTimeAssertion`. The arguments are + is for; the rest write a comment, the same as `CompileTimeAssertion` — Go included, and for the one + reason worth reading: a method may only be declared in the package that declares its type, so there + is nowhere outside it for facts about it to be attached. The arguments are `TypeReference` rather than text, though, because a specialisation argument is a type and the comma in `Result` belongs to one of them rather than separating two. @@ -98,16 +102,22 @@ source in six target languages. The solution uses: `std::is_trivially_copyable_v` to model. Only C++ and C have one — `static_assert` and `_Static_assert`, the second of which requires a message, so an assertion with none is given its own condition — and Rust, whose `const _: () = assert!(…)` needs no macro crate because a constant - nobody names still has to be evaluated for the program to build; the others write a comment, - because a file that quietly loses a guarantee looks like one that still makes it. + nobody names still has to be evaluated for the program to build, and Go, which has no assertion and + something that works as one: the keys of a map literal must be distinct and a constant key is + checked while compiling, so `map[bool]struct{}{false: {}, cond: {}}` is a compile error exactly when + `cond` is false. The rest write a comment, because a file that quietly loses a guarantee looks like + one that still makes it. - `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares. - `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately - does not derive from it. + does not derive from it. It also owns the three things more than one generator needs and no + language owns: the braced list, the rule that two undocumented members of a kind stay in one block, + and the operator names built from the AST's own vocabulary for the two targets — C and Go — that + cannot overload one and so have to call it something. - `Coder/Languages/CFamilyGenerator.cs` — what C and C++ share beyond what every generator shares, and all of it is about C: the preprocessor (`#pragma once`, `#include`), the braced list with its - designated initialisers, the declarator that puts an array's brackets after the name, and the rule - that two undocumented members of a kind stay in one block. The type mappings deliberately stay + designated initialisers, and the declarator that puts an array's brackets after the name. The type + mappings deliberately stay with each generator, because `str` is a `std::string` in one language and a `const char*` in the other and the whole of what a mapping is is the spelling. - `Coder/Languages/CGenerator.cs` — the target with the least to map onto, and so the one whose @@ -127,12 +137,29 @@ source in six target languages. The solution uses: trait, a conversion is `impl From`, and a specialisation is `impl Trait for Type` — which is the one place a target answers C++'s explicit specialisation exactly. What is left over is inheritance, which Rust does not have, and the operators it supplies from another one and will not let a type - define by itself. `Coder.Editor/RustSyntax.cs` registers the highlighter definition, because the - highlighter ships fifteen languages and Rust is not one of them. -- `Coder.Test/Languages/CompiledExemplar.cs` — one AST, compiled by two real compilers. The C and - Rust generators are each checked by compiling what they write, and they are checked against the - same declarations, which says more than two parallel fixtures could: the claim being made is that - the same AST comes out as valid source in each target. + define by itself. +- `Coder/Languages/GoGenerator.cs` — the target that answers most of the AST with something it + already had. A type is a `struct` with its methods beside it, an interface is an `interface` that + nothing declares it implements, and a base type is an embedded field, whose members are promoted — + as near as Go comes to inheritance and nearer than the other targets without it manage. A member + that promises not to modify what it is called on takes a value receiver and one that does takes a + pointer receiver, which is the same promise C++ writes as a trailing `const` made by the shape of + the declaration. What Go left out it left out on purpose, so an operator is a method named for what + it does, a constructor is `New`, a destructor is the `Close` a caller defers, and a constant + table is a `var`. Visibility is the one thing no generator can write: Go exports a name whose first + letter is a capital, so a name disagreeing with its declared visibility gets a note rather than a + rename the references would not follow. Output is already what `gofmt` would write, tab and aligned + columns included, which is why `IndentString` is overridable at all. +- `Coder.Editor/EditorSyntax.cs` — hands the highlighter the definitions in `RustSyntax.cs` and + `GoSyntax.cs`, because it ships fifteen languages and neither of those is one of them. An id it has + never heard of is not an error to it, so without this the preview pane would draw generated Rust or + Go in one colour and say nothing. +- `Coder.Test/Languages/CompiledExemplar.cs` — one AST, compiled by three real compilers. The C, Rust + and Go generators are each checked by compiling what they write, and they are checked against the + same declarations, which says more than three parallel fixtures could: the claim being made is that + the same AST comes out as valid source in each target. `GoGeneratedSourceCompilesTests` adds the + check no other generator here can have — that the output is what `gofmt` would write — because Go + has one formatter and everybody runs it. - `Coder.Graph/AstSchema.cs` — the uniform view of the AST's parent/child structure, hand-written rather than reflective. Adding a node type means adding it here. - `Coder.Graph/AstFields.cs` — a node's editable properties as named fields of a kind, which is what diff --git a/Coder.Editor/CoderEditorApp.cs b/Coder.Editor/CoderEditorApp.cs index 283e86a..5ae1f00 100644 --- a/Coder.Editor/CoderEditorApp.cs +++ b/Coder.Editor/CoderEditorApp.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Editor; @@ -419,14 +419,14 @@ private void DrawCodePane() private static readonly SyntaxHighlightConfig CodeStyle = new() { ShowLineNumbers = true }; /// - /// Teaches the highlighter the one language it does not already know. + /// Teaches the highlighter the languages it does not already know. /// /// /// A static constructor rather than a call from somewhere: the registry is process-global and the /// preview reaches it from a draw call, so the registration has to have happened before any /// instance of this class draws anything, whichever one draws first. /// - static CoderEditorApp() => RustSyntax.Register(); + static CoderEditorApp() => EditorSyntax.Register(); /// /// Draws the generated source, highlighted for the language it was generated in. diff --git a/Coder.Editor/EditorSyntax.cs b/Coder.Editor/EditorSyntax.cs new file mode 100644 index 0000000..d762395 --- /dev/null +++ b/Coder.Editor/EditorSyntax.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor; + +using ktsu.SyntaxHighlighting; + +/// +/// Teaches the syntax highlighter the languages it does not already know. +/// +/// +/// The highlighter ships definitions for fifteen languages, and two of the generators here target +/// one it does not: an id it has never heard of is not an error to it, so the preview pane would go +/// on rendering generated Rust or Go in one colour and nobody would be told why. Registering is what +/// closes that, and there is one of these rather than one per language because the closing is the +/// same each time and only the definition differs. +/// +/// It lives in the editor rather than in the library for the reason the library has no UI dependency +/// at all: what a language looks like on a screen is the editor's business, and a generator would +/// not otherwise know that a highlighter exists. +/// +/// +internal static class EditorSyntax +{ + /// + /// Registers every definition the editor carries. + /// + public static void Register() + { + Register(RustSyntax.Definition); + Register(GoSyntax.Definition); + } + + /// + /// Registers one definition, if it is not registered already. + /// + /// The definition to register. + /// + /// Idempotent, and deliberately: the registry is process-global, the editor builds more than one + /// application object over a test run, and registering twice would replace a definition with an + /// identical one for no reason. + /// + private static void Register(LanguageDefinition definition) + { + if (LanguageRegistry.TryGet(definition.Name, out _)) + { + return; + } + + LanguageRegistry.Register(definition); + } +} diff --git a/Coder.Editor/GoSyntax.cs b/Coder.Editor/GoSyntax.cs new file mode 100644 index 0000000..9940e56 --- /dev/null +++ b/Coder.Editor/GoSyntax.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Editor; + +using ktsu.SyntaxHighlighting; + +/// +/// What the syntax highlighter needs in order to read Go. +/// +/// +/// The highlighter ships definitions for fifteen languages and Go is not one of them, so the preview +/// pane would draw generated Go as plain text — the one failure mode ImGuiSyntaxHighlighting +/// has, and a silent one. This is the same arrangement is, handed to the +/// registry by for the same reason. +/// +/// The keyword list is the language's, not the generator's. A highlighter reads whatever is in the +/// pane — a file somebody opened, or output from a generator that has learned a new spelling since — +/// so listing only what this generator emits today would make the pane wrong tomorrow. +/// +/// +internal static class GoSyntax +{ + /// + /// The language name the generator reports and the highlighter is asked for. + /// + private const string LanguageName = "go"; + + /// + /// Gets what the tokenizer needs in order to read Go. + /// + /// + /// Go has one comment marker rather than two, because its documentation is an ordinary comment in + /// the right place — which is why there is no DocComment rule here and why a doc comment in + /// the pane is drawn as the comment it is. + /// + public static LanguageDefinition Definition => new() + { + Name = LanguageName, + Aliases = ["golang"], + CaseSensitive = true, + + LineComments = [new LineCommentRule { Prefix = "//" }], + BlockComments = [new BlockCommentRule { Open = "/*", Close = "*/" }], + + // The third of Go's string forms is the raw one, which runs to the next backquote and honours + // no escape — a rule the tokenizer's open-and-close pair already describes exactly. + Strings = + [ + new StringRule { Open = "\"", Close = "\"" }, + new StringRule { Open = "'", Close = "'" }, + new StringRule { Open = "`", Close = "`" }, + ], + + Keywords = + [ + "chan", "const", "defer", "func", "go", "import", "interface", "map", "package", "range", + "struct", "type", "var", + ], + ControlKeywords = + [ + "break", "case", "continue", "default", "else", "fallthrough", "for", "goto", "if", + "return", "select", "switch", + ], + Types = + [ + "any", "bool", "byte", "comparable", "complex64", "complex128", "error", "float32", + "float64", "int", "int8", "int16", "int32", "int64", "rune", "string", "uint", "uint8", + "uint16", "uint32", "uint64", "uintptr", + ], + Constants = ["true", "false", "nil", "iota"], + + HighlightFunctionCalls = true, + IdentifierCharacters = "_", + IdentifierStartCharacters = "_", + OperatorCharacters = "+-*/%=<>!&|^~:", + PunctuationCharacters = "(){}[];,.", + }; +} diff --git a/Coder.Editor/RustSyntax.cs b/Coder.Editor/RustSyntax.cs index 1d328cf..98df6cd 100644 --- a/Coder.Editor/RustSyntax.cs +++ b/Coder.Editor/RustSyntax.cs @@ -1,17 +1,17 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Editor; using ktsu.SyntaxHighlighting; /// -/// Teaches the syntax highlighter to read Rust. +/// What the syntax highlighter needs in order to read Rust. /// /// /// The highlighter ships definitions for fifteen languages and Rust is not one of them, so the /// preview pane would draw generated Rust as plain text — the one failure mode /// ImGuiSyntaxHighlighting has, and a silent one. Definitions are plain data and the registry -/// takes one from an application, which is what this is. +/// takes one from an application; is where this one is handed over. /// /// It lives in the editor rather than in the library for the reason the library has no UI /// dependency at all: what a language looks like on a screen is the editor's business, and @@ -30,28 +30,10 @@ internal static class RustSyntax /// private const string LanguageName = "rust"; - /// - /// Registers the definition, if it is not registered already. - /// - /// - /// Idempotent, and deliberately: the registry is process-global, the editor builds more than one - /// application object over a test run, and registering twice would replace a definition with an - /// identical one for no reason. - /// - public static void Register() - { - if (LanguageRegistry.TryGet(LanguageName, out _)) - { - return; - } - - LanguageRegistry.Register(Definition); - } - /// /// Gets what the tokenizer needs in order to read Rust. /// - private static LanguageDefinition Definition => new() + public static LanguageDefinition Definition => new() { Name = LanguageName, Aliases = ["rs"], diff --git a/Coder.Test/Editor/CoderEditorAppTests.cs b/Coder.Test/Editor/CoderEditorAppTests.cs index 6589546..562083a 100644 --- a/Coder.Test/Editor/CoderEditorAppTests.cs +++ b/Coder.Test/Editor/CoderEditorAppTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Editor; @@ -61,7 +61,7 @@ private static DocumentStore NewStore() => private string PathIn(string name) => Path.Combine(root, name + DocumentStore.Extension); private static CoderEditorApp NewApp(DocumentStore store, EditorSettings? settings = null) => - new(store, [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new RustGenerator(), new JavaScriptGenerator()], + new(store, [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new RustGenerator(), new GoGenerator(), new JavaScriptGenerator()], settings ?? new EditorSettings()); /// diff --git a/Coder.Test/Editor/EditorWiringTests.cs b/Coder.Test/Editor/EditorWiringTests.cs index a9f7598..512ca9b 100644 --- a/Coder.Test/Editor/EditorWiringTests.cs +++ b/Coder.Test/Editor/EditorWiringTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Editor; @@ -24,7 +24,7 @@ public sealed class EditorWiringTests { private const string ConfigHomeVariable = "XDG_CONFIG_HOME"; - private static readonly string[] ExpectedLanguageIds = ["python", "csharp", "javascript", "cpp", "c", "rust"]; + private static readonly string[] ExpectedLanguageIds = ["python", "csharp", "javascript", "cpp", "c", "rust", "go"]; private static readonly string[] ExpectedRecentFiles = ["/work/second.coder.yaml", "/work/first.coder.yaml"]; private string root = string.Empty; diff --git a/Coder.Test/Editor/GeneratedCodeHighlightingTests.cs b/Coder.Test/Editor/GeneratedCodeHighlightingTests.cs index b0b4b24..eed869b 100644 --- a/Coder.Test/Editor/GeneratedCodeHighlightingTests.cs +++ b/Coder.Test/Editor/GeneratedCodeHighlightingTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Editor; @@ -40,17 +40,17 @@ private static FunctionDeclaration SampleFunction() /// Teaches the highlighter what the editor teaches it. /// /// - /// The highlighter ships fifteen languages and Rust is not one of them, so the editor registers a - /// definition for it at startup. This is that same registration: without it the assertion below - /// would be checking a language nothing had told the highlighter about, and with it the - /// assertion checks the definition as well as the id. + /// The highlighter ships fifteen languages and neither Rust nor Go is one of them, so the editor + /// registers a definition for each at startup. This is that same registration: without it the + /// assertion below would be checking languages nothing had told the highlighter about, and with it + /// the assertion checks the definitions as well as the ids. /// /// The test context, which this does not read. [ClassInitialize] - public static void RegisterEditorLanguages(TestContext context) => RustSyntax.Register(); + public static void RegisterEditorLanguages(TestContext context) => EditorSyntax.Register(); private static ILanguageGenerator[] Generators() => - [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new RustGenerator(), new JavaScriptGenerator()]; + [new CSharpGenerator(), new PythonGenerator(), new CppGenerator(), new CGenerator(), new RustGenerator(), new GoGenerator(), new JavaScriptGenerator()]; /// /// Tests that every generator's language id is one the highlighter recognises, by generating real diff --git a/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs index 2ddaaef..1c864bb 100644 --- a/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/CGeneratedSourceCompilesTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Languages; @@ -79,7 +79,7 @@ int main(void) [TestMethod] public void GeneratedHeader_Compiles() { - string? compiler = ToolchainHarness.FindOnPath(Compilers); + string? compiler = ToolchainHarness.FindOnPath("--version", Compilers); if (compiler is null) { Assert.Inconclusive("No C compiler on the path, so nothing was compiled."); diff --git a/Coder.Test/Languages/CompiledExemplar.cs b/Coder.Test/Languages/CompiledExemplar.cs index 2e9b88c..5bece27 100644 --- a/Coder.Test/Languages/CompiledExemplar.cs +++ b/Coder.Test/Languages/CompiledExemplar.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Languages; @@ -8,16 +8,21 @@ namespace ktsu.Coder.Test.Languages; /// The declarations every generator that is checked by compiling its output is checked against. /// /// -/// One AST, two real compilers. Building a parallel one per language would say less than this does: -/// what is being claimed is that the *same* declarations come out as valid source in each target, -/// which is the whole premise of a language-agnostic AST and is not something two similar-looking -/// fixtures can demonstrate. +/// One AST, three real compilers. Building a parallel one per language would say less than this +/// does: what is being claimed is that the *same* declarations come out as valid source in each +/// target, which is the whole premise of a language-agnostic AST and is not something three +/// similar-looking fixtures can demonstrate. /// -/// What each test adds for itself is what only that language has to answer — C's struct of function -/// pointers reached through a driver, Rust's operator traits and its implementation for a type. What -/// is here is what both have to answer, and nothing whose spelling depends on the receiver: a body -/// that reads a member says self->x in one language and self.x in the other, so -/// those belong to whichever test is about that language. +/// There are two groups, and the line between them is the receiver. Everything down to +/// is what all three targets are checked against, and none of it contains a +/// body that reads a member — a body that does says self->x in C and self.x in the +/// other two, which is a difference no shared declaration can hold. Everything after it reads a +/// member, so it is shared by the two that spell that the same way and is not offered to C. +/// +/// +/// What each test still adds for itself is what only that language has to answer — C's struct of +/// function pointers reached through a driver, Rust's trait whose constant an implementation +/// supplies, Go's destructor and the package it compiles as. /// /// internal static class CompiledExemplar @@ -115,6 +120,152 @@ public static FieldDeclaration OriginTable() return origins; } + /// + /// Gets a member that reads the instance without modifying it. + /// + /// What the target's own conventions call it. + /// The declaration. + /// + /// The name is the caller's because it is the one thing about these that is not shared: Rust asks + /// for sum and Go exports Sum, and neither generator recases a name it was given. + /// + public static FunctionDeclaration Sum(string name) + { + FunctionDeclaration sum = new(name) { ReturnType = "int", IsReadOnly = true, IsPure = true }; + sum.Body.Add(new ReturnStatement(new BinaryExpression( + new VariableReference("self.x"), BinaryOperator.Add, new VariableReference("self.y")))); + return sum; + } + + /// + /// Gets a member that modifies the instance, which is what earns it the other receiver. + /// + /// What the target's own conventions call it. + /// The declaration. + /// + /// The one a compiler is needed for. Both targets would compile the read-only receiver here too, + /// and would modify a copy nobody ever looks at again. + /// + public static FunctionDeclaration Shift(string name) + { + FunctionDeclaration shift = new(name); + shift.Parameters.Add(new Parameter("dx", "int")); + shift.Body.Add(new AssignmentStatement( + new VariableReference("self.x"), new VariableReference("dx"), AssignmentOperator.AddAssign)); + return shift; + } + + /// + /// Gets the binary operator, which one target spells as a trait and the other has to name. + /// + /// The declaration. + public static FunctionDeclaration Plus() + { + FunctionDeclaration plus = new("+") + { + Kind = FunctionKind.Operator, + ReturnType = "Point", + IsReadOnly = true, + }; + plus.Parameters.Add(new Parameter("rhs", "Point")); + plus.Body.Add(new ReturnStatement(Combined("rhs"))); + return plus; + } + + /// + /// Gets the unary operator that shares its symbol with the binary one. + /// + /// The declaration. + /// + /// Declared beside on purpose. A target that decides which operator a symbol + /// means without looking at how many operands the declaration takes writes the same name twice + /// here, which is a spelling a test can pin and only a compiler refuses. + /// + public static FunctionDeclaration Negate() + { + FunctionDeclaration negate = new("-") + { + Kind = FunctionKind.Operator, + ReturnType = "Point", + IsReadOnly = true, + }; + negate.Body.Add(new ReturnStatement(Combined(null))); + return negate; + } + + /// + /// Gets the conversion, which neither target declares as one. + /// + /// What it answers with, spelled as the target spells it. + /// The declaration. + /// + /// The expression is the caller's because the two targets bind the value being converted to + /// different names: Rust's From takes a value and Go's method takes the receiver. + /// + public static FunctionDeclaration ToDouble(string answer) + { + FunctionDeclaration conversion = new("ignored") + { + Kind = FunctionKind.ConversionOperator, + ReturnType = "double", + IsReadOnly = true, + }; + conversion.Body.Add(new ReturnStatement(new VariableReference(answer))); + return conversion; + } + + /// + /// Gets a free function taking a borrowed string and a sequence, and declaring two locals. + /// + /// The declaration. + /// + /// Both locals are read, which one target only warns about and the other refuses outright. + /// + public static FunctionDeclaration Measure() + { + FunctionDeclaration measure = new("measure") { ReturnType = "int" }; + measure.Parameters.Add(new Parameter("label") + { + Type = new TypeReference("str") { Indirection = TypeIndirection.Reference, IsReadOnly = true }, + }); + measure.Parameters.Add(new Parameter("sizes") + { + Type = new TypeReference("list") { TypeArguments = { new TypeReference("int") } }, + }); + + measure.Body.Add(new VariableDeclaration("total", "int", new LiteralExpression(0))); + measure.Body.Add(new VariableDeclaration("guessed", null, new LiteralExpression(1)) { IsTypeInferred = true }); + measure.Body.Add(new AssignmentStatement( + new VariableReference("total"), new VariableReference("guessed"), AssignmentOperator.AddAssign)); + measure.Body.Add(new ReturnStatement(new VariableReference("total"))); + + return measure; + } + + /// + /// Builds the value an operator answers with. + /// + /// The operand to combine each member with, or null to negate it. + /// The expression. + private static ConstructionExpression Combined(string? operand) + { + ConstructionExpression built = new(new TypeReference("Point")); + + foreach (string member in new[] { "x", "y" }) + { + Expression value = operand is null + ? new UnaryExpression(UnaryOperator.Negate, new VariableReference($"self.{member}")) + : new BinaryExpression( + new VariableReference($"self.{member}"), + BinaryOperator.Add, + new VariableReference($"{operand}.{member}")); + + built.Arguments.Add(new MemberInitialiser(member) { Value = value }); + } + + return built; + } + /// /// Builds one row of the table, or the value the static answers. /// diff --git a/Coder.Test/Languages/GeneratedLineEndingTests.cs b/Coder.Test/Languages/GeneratedLineEndingTests.cs index f1c5bee..3b9f6ce 100644 --- a/Coder.Test/Languages/GeneratedLineEndingTests.cs +++ b/Coder.Test/Languages/GeneratedLineEndingTests.cs @@ -47,6 +47,7 @@ private static FunctionDeclaration MultiLineFunction() [DataRow("cpp")] [DataRow("c")] [DataRow("rust")] + [DataRow("go")] [DataRow("javascript")] public void Generators_EmitLineFeedsOnly(string languageId) { @@ -57,6 +58,7 @@ public void Generators_EmitLineFeedsOnly(string languageId) "cpp" => new CppGenerator(), "c" => new CGenerator(), "rust" => new RustGenerator(), + "go" => new GoGenerator(), "javascript" => new JavaScriptGenerator(), _ => throw new ArgumentOutOfRangeException(nameof(languageId)) }; diff --git a/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs new file mode 100644 index 0000000..7c186b9 --- /dev/null +++ b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs @@ -0,0 +1,186 @@ +// 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; + +/// +/// Compiles what writes, with a real Go toolchain, and checks that it is +/// already what gofmt would write. +/// +/// +/// Go refuses things the other two only grumble about, and each of them is invisible in the text: an +/// import nothing uses and a local nothing reads are errors rather than warnings, a method declared +/// twice under one name is an error, and a const holding anything the compiler cannot +/// evaluate is an error. A test that pinned the spelling would pass on all four. +/// +/// The formatting check is the one no other generator here can have, and it is worth more than it +/// looks. Go has a single formatter that everybody runs, so "what gofmt would write" is not a +/// style this generator picked but the only spelling the file has — and a generated file that fails +/// it produces a diff the first time anybody opens it. Asserting it also pins the two rules +/// gofmt has that nothing else here does: the tab, and the columns of a struct's fields and a +/// constant block lining up. +/// +/// +/// The driver is a second file in the same package, written the way a person would write one, and it +/// uses every declaration the generated file makes — so a declaration that compiles on its own but +/// cannot be used fails here rather than passing quietly. It is what proves the two mappings that +/// are only claims otherwise: that Circle satisfies Shape without saying so, which is +/// the whole of what a Go interface is, and that an embedded Point answers Sum, which +/// is the whole of what Go has in place of inheritance. +/// +/// +/// The test is inconclusive rather than failing where no toolchain is on the path, which is the +/// honest result: nothing was checked. +/// +/// +[TestClass] +public class GoGeneratedSourceCompilesTests +{ + /// + /// The module the generated file is compiled as. + /// + /// + /// Go compiles a module rather than a file, and a directory without one of these is not a module + /// however much Go source is in it. Nothing is required, so nothing is fetched. + /// + private const string Module = """ + module exemplar + + go 1.21 + + """; + + /// + /// The consumer of the generated package, written the way a person would write one. + /// + private const string Driver = """ + package exemplar + + // Circle satisfies Shape by having the methods, and says so nowhere — which is the one thing + // Go's interfaces do that no other target here does at all. + func (self *Circle) draw(scale float64) { _ = scale } + + func (self Circle) area() float64 { return self.radius } + + func use() int { + built := NewPoint(1, 2) + zeroed := PointZero() + first := ORIGINS[0] + var shape Shape = &Circle{} + var named Origin = built + + built.Shift(1) + shape.draw(2.0) + + circle := Circle{Point: built, radius: 1.0} + circle.Close() + + // circle.Sum is Point's, reached through the embedded field rather than inherited. + return built.Sum() + zeroed.y + first.x + named.x + circle.Sum() + + built.Add(zeroed).x + built.Negate().x + + int(built.ToFloat64()) + int(shape.area()) + int(ColourGreen) + measure("a", []int{1}) + } + + """; + + /// + /// Tests that a package holding every kind of declaration the generator writes compiles, that a + /// second file in the package can use all of it, and that the generated file is already + /// formatted. + /// + [TestMethod] + public void GeneratedSource_CompilesAndIsFormatted() + { + if (ToolchainHarness.FindOnPath("version", "go") is null) + { + Assert.Inconclusive("No Go toolchain on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "go.mod"), Module); + File.WriteAllText(Path.Combine(directory, "driver.go"), Driver); + File.WriteAllText( + Path.Combine(directory, "exemplar.go"), + new GoGenerator().Generate(Exemplar())); + + (int exitCode, string output) = ToolchainHarness.Run("go", "build ./...", directory); + Assert.AreEqual(0, exitCode, $"go rejected the generated source:{Environment.NewLine}{output}"); + + // gofmt lists the files it would change, so the evidence of agreement is that it named + // none. Only the generated file is offered: how the driver is written is nobody's claim. + (int formatted, string differs) = ToolchainHarness.Run("gofmt", "-l exemplar.go", directory); + Assert.AreEqual(0, formatted, $"gofmt did not run:{Environment.NewLine}{differs}"); + Assert.AreEqual( + string.Empty, + differs.Trim(), + "gofmt would rewrite the generated source, so it is not what a Go file looks like"); + }); + } + + /// + /// Builds a file holding one of everything the generator has a spelling for. + /// + /// The file to generate. + private static SourceFile Exemplar() + { + SourceFile file = new("exemplar"); + file.HeaderComment.Add("Generated by Coder. Do not edit."); + file.Imports.Add("unsafe"); + + ClassDeclaration point = CompiledExemplar.Point(); + point.Members.Add(CompiledExemplar.Sum("Sum")); + point.Members.Add(CompiledExemplar.Shift("Shift")); + point.Members.Add(CompiledExemplar.Plus()); + point.Members.Add(CompiledExemplar.Negate()); + point.Members.Add(CompiledExemplar.ToDouble("float64(self.x)")); + + ClassDeclaration circle = new("Circle") { BaseType = "Point" }; + circle.Documentation.Add("A shape with one radius."); + circle.Members.Add(new VariableDeclaration("radius", "double")); + circle.Members.Add(Released()); + + file.Members.Add(CompiledExemplar.Colour()); + file.Members.Add(point); + file.Members.Add(CompiledExemplar.Shape()); + file.Members.Add(circle); + file.Members.Add(DescribesPoint()); + file.Members.Add(CompiledExemplar.OriginAlias()); + file.Members.Add(CompiledExemplar.OriginTable()); + file.Members.Add(CompiledExemplar.Measure()); + file.Members.Add(new CompileTimeAssertion + { + Condition = "unsafe.Sizeof(Point{}) == 2*unsafe.Sizeof(0)", + Message = "Point must stay two ints", + }); + + return file; + } + + /// + /// Builds the destructor, which becomes the Close the caller has to call. + /// + /// The declaration. + private static FunctionDeclaration Released() + { + FunctionDeclaration close = new("Circle") { Kind = FunctionKind.Destructor }; + close.Body.Add(new VariableDeclaration("going", "bool", new LiteralExpression(true)) + { + IsTypeInferred = true, + }); + close.Body.Add(new AssignmentStatement( + new VariableReference("_"), new VariableReference("going"), AssignmentOperator.Assign)); + return close; + } + + /// + /// Builds the declaration that is for a type rather than of one, which Go cannot write. + /// + /// The declaration. + private static ClassDeclaration DescribesPoint() => + new("Describe") { SpecialisationArguments = { new TypeReference("Point") } }; +} diff --git a/Coder.Test/Languages/GoGeneratorTests.cs b/Coder.Test/Languages/GoGeneratorTests.cs new file mode 100644 index 0000000..6dc6e4b --- /dev/null +++ b/Coder.Test/Languages/GoGeneratorTests.cs @@ -0,0 +1,878 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for . +/// +/// +/// Go answers most of what the AST says with something it already had — a struct, an interface, an +/// embedded field, a receiver — so most of what is pinned here is which of those each part of a +/// declaration became. The rest is the handful of things Go left out on purpose, where what is +/// written is a name it had to invent or a note saying there is nothing to write. +/// +[TestClass] +public class GoGeneratorTests +{ + private GoGenerator Generator { get; } = new(); + + private static string NewLine => CodeBlocker.DefaultNewLineString; + + /// + /// Tests that the generator reports the identity the registry and file writer rely on. + /// + [TestMethod] + public void Identity_IsGo() + { + Assert.AreEqual("go", Generator.LanguageId); + Assert.AreEqual("Go", Generator.DisplayName); + Assert.AreEqual("go", Generator.FileExtension); + } + + /// + /// Tests that a function's statements carry no terminator, since Go's lexer supplies the one its + /// grammar wants and gofmt deletes any that were written. + /// + [TestMethod] + public void Statements_CarryNoSemicolon() + { + FunctionDeclaration function = new("answer") { ReturnType = "int" }; + function.Body.Add(new ReturnStatement(Literal.Number(4))); + + Assert.AreEqual( + $"func answer() int {{{NewLine}\treturn 4{NewLine}}}{NewLine}", + Generator.Generate(function)); + } + + /// + /// Tests that a body is indented with a tab, which is the one thing about a Go file's shape that + /// nobody gets a say in. + /// + [TestMethod] + public void Indentation_IsATab() + { + FunctionDeclaration function = new("answer"); + function.Body.Add(new ReturnStatement()); + + StringAssert.Contains(Generator.Generate(function), $"{NewLine}\treturn{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that a function answering nothing writes no result at all, rather than naming an empty + /// one. + /// + [TestMethod] + public void EmptyFunction_WritesNoResult() + { + FunctionDeclaration function = new("doNothing"); + + Assert.AreEqual($"func doNothing() {{{NewLine}}}{NewLine}", Generator.Generate(function)); + } + + /// + /// Tests that the AST's language-neutral type names are mapped to Go spellings. + /// + [TestMethod] + public void Types_AreMappedToGoSpellings() + { + FunctionDeclaration function = new("greet") { ReturnType = "str" }; + function.Parameters.Add(new Parameter("times", "long")); + function.Parameters.Add(new Parameter("ratio", "double")); + + StringAssert.Contains( + Generator.Generate(function), + "func greet(times int64, ratio float64) string", + StringComparison.Ordinal); + } + + /// + /// Tests that an unrecognized type name is passed through, so a caller can name a real Go type. + /// + [TestMethod] + public void UnknownType_IsPassedThrough() + { + FunctionDeclaration function = new("make") { ReturnType = "Widget" }; + + StringAssert.Contains(Generator.Generate(function), ") Widget", StringComparison.Ordinal); + } + + /// + /// Tests that the containers the AST names are spelled out of Go's grammar rather than from a + /// package, and that one named without arguments holds the most general thing there is. + /// + [TestMethod] + public void Containers_AreSpelledFromTheGrammar() + { + FunctionDeclaration function = new("lookup") + { + ReturnType = new TypeReference("dict") + { + TypeArguments = { new TypeReference("str"), new TypeReference("int") }, + }, + }; + function.Parameters.Add(new Parameter("keys") { Type = new TypeReference("list") }); + + StringAssert.Contains( + Generator.Generate(function), + "func lookup(keys []any) map[string]int", + StringComparison.Ordinal); + } + + /// + /// Tests that a type with arguments is written with square brackets, which is where Go put its + /// generics. + /// + [TestMethod] + public void GenericType_IsWrittenWithSquareBrackets() + { + FunctionDeclaration function = new("read") + { + ReturnType = new TypeReference("Result") + { + TypeArguments = { new TypeReference("Handle"), new TypeReference("Error") }, + }, + }; + + StringAssert.Contains(Generator.Generate(function), ") Result[Handle, Error]", StringComparison.Ordinal); + } + + /// + /// Tests that both of the AST's indirections become the one Go has. + /// + [TestMethod] + public void Indirection_IsAlwaysThePointer() + { + FunctionDeclaration function = new("touch"); + function.Parameters.Add(new Parameter("held") { Type = new TypeReference("Widget") { Indirection = TypeIndirection.Reference } }); + function.Parameters.Add(new Parameter("raw") { Type = new TypeReference("Widget") { Indirection = TypeIndirection.Pointer } }); + + StringAssert.Contains(Generator.Generate(function), "(held *Widget, raw *Widget)", StringComparison.Ordinal); + } + + /// + /// Tests that a reference to something already holding a pointer is left as it is, since a + /// pointer to a slice or a string is a pointer to a pointer and nobody means that. + /// + [TestMethod] + public void ReferenceToAView_IsTheViewItself() + { + FunctionDeclaration function = new("measure"); + function.Parameters.Add(new Parameter("label") { Type = new TypeReference("str") { Indirection = TypeIndirection.Reference } }); + function.Parameters.Add(new Parameter("sizes") + { + Type = new TypeReference("int") { IsArray = true, Indirection = TypeIndirection.Reference }, + }); + + StringAssert.Contains(Generator.Generate(function), "(label string, sizes []int)", StringComparison.Ordinal); + } + + /// + /// Tests that a type declaration becomes a struct with the data in it and the behaviour beside + /// it, which is where Go keeps a method. + /// + [TestMethod] + public void Class_BecomesAStructAndMethodsBesideIt() + { + ClassDeclaration point = new("Point") { Kind = TypeDeclarationKind.Struct }; + point.Members.Add(new VariableDeclaration("X", "int")); + point.Members.Add(new FunctionDeclaration("Reset")); + + string generated = Generator.Generate(point); + + StringAssert.Contains(generated, $"type Point struct {{{NewLine}\tX int{NewLine}}}", StringComparison.Ordinal); + StringAssert.Contains(generated, "func (self *Point) Reset()", StringComparison.Ordinal); + } + + /// + /// Tests that a struct with no fields is written on one line, which is what gofmt does with one. + /// + [TestMethod] + public void EmptyStruct_IsWrittenOnOneLine() + { + ClassDeclaration marker = new("Marker"); + + Assert.AreEqual($"type Marker struct{{}}{NewLine}", Generator.Generate(marker)); + } + + /// + /// Tests that a member promising not to modify what it is called on takes the value and one that + /// does takes a pointer to it, which is how Go makes that promise. + /// + [TestMethod] + public void Receiver_IsAValueOnlyWhereTheMemberPromisesNotToModify() + { + ClassDeclaration point = new("Point"); + point.Members.Add(new FunctionDeclaration("Sum") { ReturnType = "int", IsReadOnly = true }); + point.Members.Add(new FunctionDeclaration("Shift")); + + string generated = Generator.Generate(point); + + StringAssert.Contains(generated, "func (self Point) Sum() int", StringComparison.Ordinal); + StringAssert.Contains(generated, "func (self *Point) Shift()", StringComparison.Ordinal); + } + + /// + /// Tests that a static member becomes a package-level function carrying the type's name, since + /// there is nothing else to scope it. + /// + [TestMethod] + public void StaticMember_TakesTheTypeIntoItsName() + { + ClassDeclaration point = new("Point"); + point.Members.Add(new FunctionDeclaration("zero") { ReturnType = "Point", IsStatic = true }); + + StringAssert.Contains(Generator.Generate(point), "func PointZero() Point", StringComparison.Ordinal); + } + + /// + /// Tests that a constructor becomes the function every Go package writes instead, building the + /// value from the initialiser list. + /// + [TestMethod] + public void Constructor_BecomesANewFunction() + { + ClassDeclaration point = new("Point"); + FunctionDeclaration create = new("Point") { Kind = FunctionKind.Constructor }; + create.Parameters.Add(new Parameter("x", "int")); + create.Initialisers.Add(new MemberInitialiser("x") { Value = new VariableReference("x") }); + point.Members.Add(create); + + StringAssert.Contains( + Generator.Generate(point), + $"func NewPoint(x int) Point {{{NewLine}\treturn Point{{x: x}}{NewLine}}}", + StringComparison.Ordinal); + } + + /// + /// Tests that a constructor with nothing to build from answers the zero value, which Go gives + /// every type and which is exactly what the declaration was asking for. + /// + [TestMethod] + public void ConstructorWithNoInitialisers_AnswersTheZeroValue() + { + ClassDeclaration point = new("Point"); + point.Members.Add(new FunctionDeclaration("Point") { Kind = FunctionKind.Constructor }); + + StringAssert.Contains(Generator.Generate(point), "\treturn Point{}", StringComparison.Ordinal); + } + + /// + /// Tests that a destructor becomes the convention Go has instead, which is a method the caller + /// has to call. + /// + [TestMethod] + public void Destructor_BecomesClose() + { + ClassDeclaration handle = new("Handle"); + handle.Members.Add(new FunctionDeclaration("Handle") { Kind = FunctionKind.Destructor }); + + StringAssert.Contains(Generator.Generate(handle), "func (self *Handle) Close()", StringComparison.Ordinal); + } + + /// + /// Tests that an operator becomes a method named for what it does, since Go has no overloading. + /// + [TestMethod] + public void Operator_BecomesAMethodNamedForWhatItDoes() + { + ClassDeclaration point = new("Point"); + FunctionDeclaration plus = new("+") { Kind = FunctionKind.Operator, ReturnType = "Point", IsReadOnly = true }; + plus.Parameters.Add(new Parameter("rhs", "Point")); + point.Members.Add(plus); + + StringAssert.Contains( + Generator.Generate(point), + "func (self Point) Add(rhs Point) Point", + StringComparison.Ordinal); + } + + /// + /// Tests that a unary operator is named from the unary vocabulary rather than after the binary + /// operator sharing its symbol, which is what keeps a type declaring both from declaring one name + /// twice. + /// + [TestMethod] + public void UnaryOperator_IsNamedForTheUnaryReadingOfItsSymbol() + { + ClassDeclaration point = new("Point"); + FunctionDeclaration minus = new("-") { Kind = FunctionKind.Operator, ReturnType = "Point", IsReadOnly = true }; + minus.Parameters.Add(new Parameter("rhs", "Point")); + point.Members.Add(minus); + point.Members.Add(new FunctionDeclaration("-") { Kind = FunctionKind.Operator, ReturnType = "Point", IsReadOnly = true }); + + string generated = Generator.Generate(point); + + StringAssert.Contains(generated, "func (self Point) Subtract(rhs Point) Point", StringComparison.Ordinal); + StringAssert.Contains(generated, "func (self Point) Negate() Point", StringComparison.Ordinal); + } + + /// + /// Tests that a conversion to a string becomes the method the standard library asks for, so that + /// everything printing a value finds it. + /// + [TestMethod] + public void ConversionToAString_BecomesStringer() + { + ClassDeclaration point = new("Point"); + point.Members.Add(new FunctionDeclaration("ignored") + { + Kind = FunctionKind.ConversionOperator, + ReturnType = "str", + IsReadOnly = true, + }); + + StringAssert.Contains(Generator.Generate(point), "func (self Point) String() string", StringComparison.Ordinal); + } + + /// + /// Tests that every other conversion becomes a method named after what it answers. + /// + [TestMethod] + public void Conversion_BecomesAMethodNamedForItsTarget() + { + ClassDeclaration point = new("Point"); + point.Members.Add(new FunctionDeclaration("ignored") + { + Kind = FunctionKind.ConversionOperator, + ReturnType = "double", + IsReadOnly = true, + }); + + StringAssert.Contains(Generator.Generate(point), "func (self Point) ToFloat64() float64", StringComparison.Ordinal); + } + + /// + /// Tests that an interface becomes one, holding signatures with no receiver and no body. + /// + [TestMethod] + public void Interface_BecomesAnInterfaceOfSignatures() + { + ClassDeclaration shape = new("Shape") { Kind = TypeDeclarationKind.Interface }; + FunctionDeclaration draw = new("Draw") { IsAbstract = true }; + draw.Parameters.Add(new Parameter("scale", "double")); + shape.Members.Add(draw); + shape.Members.Add(new FunctionDeclaration("Area") { ReturnType = "double", IsReadOnly = true, IsAbstract = true }); + + Assert.AreEqual( + $"type Shape interface {{{NewLine}\tDraw(scale float64){NewLine}\tArea() float64{NewLine}}}{NewLine}", + Generator.Generate(shape)); + } + + /// + /// Tests that a base type on an interface is embedded, which is how Go says an implementation + /// must do everything the other one requires. + /// + [TestMethod] + public void InterfaceBaseType_IsEmbedded() + { + ClassDeclaration named = new("Named") { Kind = TypeDeclarationKind.Interface, BaseType = "Stringer" }; + named.Members.Add(new FunctionDeclaration("Name") { ReturnType = "str", IsAbstract = true }); + + Assert.AreEqual( + $"type Named interface {{{NewLine}\tStringer{NewLine}\tName() string{NewLine}}}{NewLine}", + Generator.Generate(named)); + } + + /// + /// Tests that a base type on a struct is an embedded field, and is said to be one — Go promotes + /// what it holds rather than deriving from it, which is close enough to inheritance to be worth + /// telling apart from it. + /// + [TestMethod] + public void StructBaseType_IsAnEmbeddedFieldWithANote() + { + ClassDeclaration circle = new("Circle") { BaseType = "Point" }; + circle.Members.Add(new VariableDeclaration("Radius", "double")); + + string generated = Generator.Generate(circle); + + StringAssert.Contains(generated, "// the base, embedded:", StringComparison.Ordinal); + StringAssert.Contains(generated, $"{NewLine}\tPoint{NewLine}\tRadius float64{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that an enumeration becomes a named type and a block of constants, each prefixed with + /// the type's name because Go's constants share the package's scope. + /// + [TestMethod] + public void Enum_BecomesANamedTypeAndPrefixedConstants() + { + EnumDeclaration colour = new("Colour") { UnderlyingType = "long" }; + colour.Members.Add(new EnumMember("Red")); + colour.Members.Add(new EnumMember("Green")); + + Assert.AreEqual( + $"type Colour int64{NewLine}{NewLine}const ({NewLine}\tColourRed Colour = iota{NewLine}\tColourGreen{NewLine}){NewLine}", + Generator.Generate(colour)); + } + + /// + /// Tests that an enumeration whose members say nothing takes the default underlying type. + /// + [TestMethod] + public void EnumWithNoUnderlyingType_IsAnInt() + { + EnumDeclaration weekday = new("Weekday"); + weekday.Members.Add(new EnumMember("Monday")); + + StringAssert.Contains(Generator.Generate(weekday), $"type Weekday int{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that a member following one that named a value is written as the one before it plus one, + /// which is what C's rule means and the only way to keep meaning it once iota has stopped + /// counting. + /// + [TestMethod] + public void EnumMemberAfterAValue_NamesTheOneBeforeIt() + { + EnumDeclaration colour = new("Colour"); + colour.Members.Add(new EnumMember("Red") { Value = "1" }); + colour.Members.Add(new EnumMember("Green")); + + Assert.AreEqual( + $"type Colour int{NewLine}{NewLine}const ({NewLine}\tColourRed Colour = 1{NewLine}\tColourGreen Colour = ColourRed + 1{NewLine}){NewLine}", + Generator.Generate(colour)); + } + + /// + /// Tests that a member already carrying the type's name is not given it twice. + /// + [TestMethod] + public void EnumMemberAlreadyPrefixed_IsNotPrefixedAgain() + { + EnumDeclaration colour = new("Colour"); + colour.Members.Add(new EnumMember("ColourRed")); + + StringAssert.Contains(Generator.Generate(colour), "\tColourRed Colour = iota", StringComparison.Ordinal); + } + + /// + /// Tests that an alias is one, rather than a second type with the same shape. + /// + [TestMethod] + public void UsingAlias_BecomesATypeAlias() + { + UsingAlias alias = new("Origin", "Point"); + + Assert.AreEqual($"type Origin = Point{NewLine}", Generator.Generate(alias)); + } + + /// + /// Tests that a compile-time assertion is one, spelled as the duplicate map key Go refuses. + /// + [TestMethod] + public void CompileTimeAssertion_IsADuplicateMapKey() + { + CompileTimeAssertion assertion = new() + { + Condition = "unsafe.Sizeof(Point{}) == 8", + Message = "Point must stay eight bytes", + }; + + string generated = Generator.Generate(assertion); + + StringAssert.Contains(generated, "// Point must stay eight bytes", StringComparison.Ordinal); + StringAssert.Contains( + generated, + "var _ = map[bool]struct{}{false: {}, unsafe.Sizeof(Point{}) == 8: {}}", + StringComparison.Ordinal); + } + + /// + /// Tests that a namespace becomes the file's package, named for the last part of its path. + /// + [TestMethod] + public void Namespace_BecomesThePackageClause() + { + SourceFile file = new("shapes"); + NamespaceDeclaration geometry = new("geo.shapes"); + geometry.Members.Add(new UsingAlias("Origin", "Point")); + file.Members.Add(geometry); + + string generated = Generator.Generate(file); + + StringAssert.StartsWith(generated, $"package shapes{NewLine}", StringComparison.Ordinal); + StringAssert.Contains(generated, "// geo/shapes: a Go package is named for one directory", StringComparison.Ordinal); + } + + /// + /// Tests that a file with an entry point is in the package Go runs one in, whatever its namespace + /// says. + /// + [TestMethod] + public void FileWithAnEntryPoint_IsPackageMain() + { + SourceFile file = new("tool"); + NamespaceDeclaration geometry = new("geo"); + geometry.Members.Add(new EntryPoint()); + file.Members.Add(geometry); + + StringAssert.StartsWith(Generator.Generate(file), $"package main{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that one import is written on the line, as a Go file with one import is. + /// + [TestMethod] + public void OneImport_IsWrittenOnTheLine() + { + SourceFile file = new("tool"); + file.Imports.Add("fmt"); + + StringAssert.Contains(Generator.Generate(file), $"import \"fmt\"{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that several imports become a block, sorted within each group and with the groups kept + /// apart — which is what gofmt does to one. + /// + [TestMethod] + public void SeveralImports_AreABlockSortedWithinItsGroups() + { + SourceFile file = new("tool"); + file.Imports.Add("strings"); + file.Imports.Add("fmt"); + file.Imports.Add(string.Empty); + file.Imports.Add("example.com/thing"); + + StringAssert.Contains( + Generator.Generate(file), + $"import ({NewLine}\t\"fmt\"{NewLine}\t\"strings\"{NewLine}{NewLine}\t\"example.com/thing\"{NewLine}){NewLine}", + StringComparison.Ordinal); + } + + /// + /// Tests that an entry point reaching the command line or answering an exit code is given the + /// import it needs, since Go has no way to name a package without importing it. + /// + [TestMethod] + public void EntryPointNeedingTheRuntime_ImportsIt() + { + SourceFile file = new("tool"); + file.Members.Add(new EntryPoint { AcceptsArguments = true, ReturnsExitCode = true }); + + string generated = Generator.Generate(file); + + StringAssert.Contains(generated, $"import \"os\"{NewLine}", StringComparison.Ordinal); + StringAssert.Contains(generated, "func run(args []string) int", StringComparison.Ordinal); + StringAssert.Contains(generated, "\tos.Exit(run(os.Args))", StringComparison.Ordinal); + } + + /// + /// Tests that an entry point that neither reads its arguments nor answers an exit code is a plain + /// main with nothing imported for it. + /// + [TestMethod] + public void PlainEntryPoint_IsJustMain() + { + SourceFile file = new("tool"); + file.Members.Add(new EntryPoint()); + + string generated = Generator.Generate(file); + + Assert.IsFalse(generated.Contains("import", StringComparison.Ordinal), generated); + StringAssert.Contains(generated, $"func main() {{{NewLine}}}{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that a declaration whose type is to be inferred uses the short form, and one that names + /// its type uses the long one. + /// + [TestMethod] + public void LocalDeclaration_IsShortOnlyWhereItsTypeIsInferred() + { + FunctionDeclaration function = new("count"); + function.Body.Add(new VariableDeclaration("total", "int", Literal.Number(0))); + function.Body.Add(new VariableDeclaration("guessed", null, Literal.Number(1)) { IsTypeInferred = true }); + function.Body.Add(new VariableDeclaration("later", "int")); + + string generated = Generator.Generate(function); + + StringAssert.Contains(generated, "\tvar total int = 0", StringComparison.Ordinal); + StringAssert.Contains(generated, "\tguessed := 1", StringComparison.Ordinal); + StringAssert.Contains(generated, "\tvar later int", StringComparison.Ordinal); + } + + /// + /// Tests that a constant declaration is one where Go can hold it, and a var with a note where it + /// cannot — a Go constant is a value the compiler worked out, never a value with fields in it. + /// + [TestMethod] + public void Constant_IsAConstOnlyWhereGoCanHoldOne() + { + FunctionDeclaration function = new("limits"); + function.Body.Add(new VariableDeclaration("limit", "int", Literal.Number(7)) { IsConstant = true }); + function.Body.Add(new VariableDeclaration("origin", "Point", new ConstructionExpression(new TypeReference("Point"))) + { + IsConstant = true, + }); + + string generated = Generator.Generate(function); + + StringAssert.Contains(generated, "\tconst limit int = 7", StringComparison.Ordinal); + StringAssert.Contains(generated, "// origin is constant: a Go const is a number, a string or a bool", StringComparison.Ordinal); + StringAssert.Contains(generated, "\tvar origin Point = Point{}", StringComparison.Ordinal); + } + + /// + /// Tests that a list with no type of its own is given the declaration's, which is the only way Go + /// takes one — and is the opposite of what C asks for in the same position. + /// + [TestMethod] + public void BareListInitialiser_TakesTheDeclarationsType() + { + ConstructionExpression table = new(type: null); + table.Arguments.Add(Literal.Number(1)); + table.Arguments.Add(Literal.Number(2)); + + FieldDeclaration sizes = new() + { + Name = "Sizes", + Type = new TypeReference("int") { IsArray = true }, + InitialValue = table, + }; + + Assert.AreEqual($"var Sizes []int = []int{{1, 2}}{NewLine}", Generator.Generate(sizes)); + } + + /// + /// Tests that a list whose elements are themselves lists is written one per line, so that adding + /// a row to a generated table touches one line. + /// + [TestMethod] + public void TableOfRows_IsWrittenOnePerLine() + { + ConstructionExpression row = new(new TypeReference("Point")); + row.Arguments.Add(new MemberInitialiser("X") { Value = Literal.Number(0) }); + + ConstructionExpression table = new(type: null); + table.Arguments.Add(row); + + FieldDeclaration origins = new() + { + Name = "Origins", + Type = new TypeReference("Point") { IsArray = true }, + InitialValue = table, + }; + + Assert.AreEqual( + $"var Origins []Point = []Point{{{NewLine}\tPoint{{X: 0}},{NewLine}}}{NewLine}", + Generator.Generate(origins)); + } + + /// + /// Tests that a static field becomes a package-level declaration carrying the type's name, since + /// Go has no static data member and a note in place of the table would lose it. + /// + [TestMethod] + public void StaticField_BecomesAPackageLevelDeclaration() + { + ClassDeclaration holder = new("Holder"); + holder.Members.Add(new FieldDeclaration + { + Name = "Limit", + Type = "int", + IsStatic = true, + IsConstant = true, + InitialValue = Literal.Number(7), + }); + + StringAssert.Contains(Generator.Generate(holder), $"const HolderLimit int = 7{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that a declaration whose name disagrees with the visibility it asked for is told so, + /// since Go says visibility with the name and renaming it here would not rename what refers to + /// it. + /// + [TestMethod] + public void VisibilityTheNameContradicts_IsANote() + { + ClassDeclaration hidden = new("Marker") { Visibility = Visibility.Private }; + ClassDeclaration shown = new("marker") { Visibility = Visibility.Public }; + + StringAssert.Contains( + Generator.Generate(hidden), + "// Marker is private: in Go that is the case of the first letter, so the name says exported instead", + StringComparison.Ordinal); + StringAssert.Contains( + Generator.Generate(shown), + "// marker is public: in Go that is the case of the first letter, so the name says unexported instead", + StringComparison.Ordinal); + } + + /// + /// Tests that a declaration whose name already says what it asked for is left to say it, and that + /// one asking for nothing is left alone too. + /// + [TestMethod] + public void VisibilityTheNameAgreesWith_IsNotMentioned() + { + ClassDeclaration shown = new("Marker") { Visibility = Visibility.Public }; + ClassDeclaration hidden = new("marker") { Visibility = Visibility.Internal }; + ClassDeclaration unsaid = new("marker"); + + foreach (ClassDeclaration declaration in new[] { shown, hidden, unsaid }) + { + string generated = Generator.Generate(declaration); + Assert.IsFalse(generated.Contains("//", StringComparison.Ordinal), generated); + } + } + + /// + /// Tests that a declaration the language cannot refuse a call to is written as a note rather than + /// as a declaration that would allow one. + /// + [TestMethod] + public void DeletedDeclaration_IsANote() + { + ClassDeclaration holder = new("Holder"); + holder.Members.Add(new FunctionDeclaration("Copy") { Definition = FunctionDefinition.Deleted }); + + StringAssert.Contains(Generator.Generate(holder), "// Copy is deleted: Go cannot refuse a call", StringComparison.Ordinal); + } + + /// + /// Tests that a declaration the language supplies is written as a note naming what it supplies + /// instead. + /// + [TestMethod] + public void DefaultedDeclaration_IsANote() + { + ClassDeclaration holder = new("Holder"); + holder.Members.Add(new FunctionDeclaration("Holder") + { + Kind = FunctionKind.Constructor, + Definition = FunctionDefinition.Defaulted, + }); + + StringAssert.Contains( + Generator.Generate(holder), + "// NewHolder is defaulted: Go gives every type a zero value instead", + StringComparison.Ordinal); + } + + /// + /// Tests that a member with no body on a struct is a note, since only an interface may require + /// one. + /// + [TestMethod] + public void AbstractMemberOfAStruct_IsANote() + { + ClassDeclaration holder = new("Holder"); + holder.Members.Add(new FunctionDeclaration("Draw") { IsAbstract = true }); + + StringAssert.Contains( + Generator.Generate(holder), + "// Draw has no body: only an interface may require one", + StringComparison.Ordinal); + } + + /// + /// Tests that a specialisation is a note, because Go declares a method only in the package + /// declaring its type and so has nothing that attaches facts to one from outside. + /// + [TestMethod] + public void Specialisation_IsANote() + { + ClassDeclaration describe = new("Describe") + { + SpecialisationArguments = { new TypeReference("Point") }, + }; + + StringAssert.Contains( + Generator.Generate(describe), + "// Describe for Point: Go attaches a method only in the package declaring its type", + StringComparison.Ordinal); + } + + /// + /// Tests that a constant on an interface is a note, since a Go interface holds methods. + /// + [TestMethod] + public void ConstantOnAnInterface_IsANote() + { + ClassDeclaration describe = new("Describe") { Kind = TypeDeclarationKind.Interface }; + describe.Members.Add(new FieldDeclaration { Name = "Name", Type = "str" }); + + StringAssert.Contains( + Generator.Generate(describe), + "// Name: a Go interface holds methods, so a constant belongs to what implements it", + StringComparison.Ordinal); + } + + /// + /// Tests that a type declared inside another is written beside it, since Go nests nothing but a + /// function. + /// + [TestMethod] + public void NestedType_IsWrittenBesideTheOneDeclaringIt() + { + ClassDeclaration outer = new("Outer"); + outer.Members.Add(new ClassDeclaration("Inner")); + + StringAssert.StartsWith(Generator.Generate(outer), $"type Inner struct{{}}{NewLine}", StringComparison.Ordinal); + } + + /// + /// Tests that a parameter's default value is written beside it, since Go has none and the caller + /// has to pass one. + /// + [TestMethod] + public void OptionalParameter_KeepsItsDefaultAsAComment() + { + FunctionDeclaration function = new("greet"); + function.Parameters.Add(new Parameter("times", "int") { IsOptional = true, DefaultValue = "1" }); + + StringAssert.Contains(Generator.Generate(function), "(times int /* = 1 */)", StringComparison.Ordinal); + } + + /// + /// Tests that bitwise complement is spelled the way Go spells it, which is the one operator in + /// the AST's vocabulary it does not share with the C family. + /// + [TestMethod] + public void BitwiseNot_IsSpelledWithACaret() + { + FunctionDeclaration function = new("mask") { ReturnType = "int" }; + function.Body.Add(new ReturnStatement( + new UnaryExpression(UnaryOperator.BitwiseNot, new VariableReference("bits")))); + + StringAssert.Contains(Generator.Generate(function), "\treturn (^bits)", StringComparison.Ordinal); + } + + /// + /// Tests that documentation is written as the ordinary comment Go reads as documentation, with no + /// second marker to make it one. + /// + [TestMethod] + public void Documentation_IsAnOrdinaryComment() + { + ClassDeclaration point = new("Point"); + point.Documentation.Add("Somewhere on a surface."); + + StringAssert.StartsWith(Generator.Generate(point), $"// Somewhere on a surface.{NewLine}type Point", StringComparison.Ordinal); + } + + /// + /// Tests that a struct's fields have their types lined up, which is what gofmt does and therefore + /// what the file has to look like already. + /// + [TestMethod] + public void StructFields_AreLinedUp() + { + ClassDeclaration circle = new("Circle"); + circle.Members.Add(new VariableDeclaration("X", "int")); + circle.Members.Add(new VariableDeclaration("Radius", "double")); + + Assert.AreEqual( + $"type Circle struct {{{NewLine}\tX int{NewLine}\tRadius float64{NewLine}}}{NewLine}", + Generator.Generate(circle)); + } +} diff --git a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs index b5a673a..4841540 100644 --- a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Languages; @@ -36,7 +36,7 @@ public class RustGeneratedSourceCompilesTests [TestMethod] public void GeneratedSource_Compiles() { - if (ToolchainHarness.FindOnPath("rustc") is null) + if (ToolchainHarness.FindOnPath("--version", "rustc") is null) { Assert.Inconclusive("No Rust compiler on the path, so nothing was compiled."); return; @@ -73,11 +73,11 @@ private static SourceFile Exemplar() file.HeaderComment.Add("Generated by Coder. Do not edit."); ClassDeclaration point = CompiledExemplar.Point(); - point.Members.Add(Sum()); - point.Members.Add(Shift()); - point.Members.Add(Plus()); - point.Members.Add(Negate()); - point.Members.Add(ToDouble()); + point.Members.Add(CompiledExemplar.Sum("sum")); + point.Members.Add(CompiledExemplar.Shift("shift")); + point.Members.Add(CompiledExemplar.Plus()); + point.Members.Add(CompiledExemplar.Negate()); + point.Members.Add(CompiledExemplar.ToDouble("value.x as f64")); ClassDeclaration circle = new("Circle") { BaseType = "Point" }; circle.Documentation.Add("A shape with one radius."); @@ -96,7 +96,7 @@ private static SourceFile Exemplar() geometry.Members.Add(DescribesPoint()); geometry.Members.Add(CompiledExemplar.OriginAlias()); geometry.Members.Add(CompiledExemplar.OriginTable()); - geometry.Members.Add(Measure()); + geometry.Members.Add(CompiledExemplar.Measure()); geometry.Members.Add(new CompileTimeAssertion { Condition = "std::mem::size_of::() == 4", @@ -107,94 +107,6 @@ private static SourceFile Exemplar() return file; } - /// - /// Builds a member that reads the instance without modifying it. - /// - /// The declaration. - private static FunctionDeclaration Sum() - { - FunctionDeclaration sum = new("sum") { ReturnType = "int", IsReadOnly = true, IsPure = true }; - sum.Body.Add(new ReturnStatement(new BinaryExpression( - new VariableReference("self.x"), BinaryOperator.Add, new VariableReference("self.y")))); - return sum; - } - - /// - /// Builds a member that modifies the instance, which is what earns it a mutable receiver. - /// - /// The declaration. - private static FunctionDeclaration Shift() - { - FunctionDeclaration shift = new("shift"); - shift.Parameters.Add(new Parameter("dx", "int")); - shift.Body.Add(new AssignmentStatement( - new VariableReference("self.x"), new VariableReference("dx"), AssignmentOperator.AddAssign)); - return shift; - } - - /// - /// Builds the binary operator, which has to become a trait implementation naming its output. - /// - /// The declaration. - private static FunctionDeclaration Plus() - { - FunctionDeclaration plus = new("+") { Kind = FunctionKind.Operator, ReturnType = "Point" }; - plus.Parameters.Add(new Parameter("rhs", "Point")); - plus.Body.Add(new ReturnStatement(Combined("rhs"))); - return plus; - } - - /// - /// Builds the unary operator that shares its symbol with the binary one. - /// - /// The declaration. - private static FunctionDeclaration Negate() - { - FunctionDeclaration negate = new("-") { Kind = FunctionKind.Operator, ReturnType = "Point" }; - negate.Body.Add(new ReturnStatement(Combined(null))); - return negate; - } - - /// - /// Builds the value an operator answers with. - /// - /// The operand to combine each member with, or null to negate it. - /// The expression. - private static ConstructionExpression Combined(string? operand) - { - ConstructionExpression built = new(new TypeReference("Point")); - - foreach (string member in new[] { "x", "y" }) - { - Expression value = operand is null - ? new UnaryExpression(UnaryOperator.Negate, new VariableReference($"self.{member}")) - : new BinaryExpression( - new VariableReference($"self.{member}"), - BinaryOperator.Add, - new VariableReference($"{operand}.{member}")); - - built.Arguments.Add(new MemberInitialiser(member) { Value = value }); - } - - return built; - } - - /// - /// Builds the conversion, which has to become an implementation of From. - /// - /// The declaration. - private static FunctionDeclaration ToDouble() - { - FunctionDeclaration conversion = new("ignored") - { - Kind = FunctionKind.ConversionOperator, - ReturnType = "double", - IsReadOnly = true, - }; - conversion.Body.Add(new ReturnStatement(new VariableReference("value.x as f64"))); - return conversion; - } - /// /// Builds a trait whose constant every implementation has to supply. /// @@ -228,26 +140,6 @@ private static ClassDeclaration DescribesPoint() return specialisation; } - /// - /// Builds a free function taking a borrow and a sequence, and declaring two locals. - /// - /// The declaration. - private static FunctionDeclaration Measure() - { - FunctionDeclaration measure = new("measure") { ReturnType = "int" }; - measure.Parameters.Add(new Parameter("label") { Type = BorrowedString() }); - measure.Parameters.Add(new Parameter("sizes") - { - Type = new TypeReference("list") { TypeArguments = { new TypeReference("int") } }, - }); - - measure.Body.Add(new VariableDeclaration("total", "int", new LiteralExpression(0))); - measure.Body.Add(new VariableDeclaration("guessed", null, new LiteralExpression(1)) { IsTypeInferred = true }); - measure.Body.Add(new ReturnStatement(new VariableReference("total"))); - - return measure; - } - /// /// Gets a string the holder does not own, which is the one Rust asks for wherever it is only read. /// diff --git a/Coder.Test/Languages/ToolchainHarness.cs b/Coder.Test/Languages/ToolchainHarness.cs index 1d38153..a9af2d2 100644 --- a/Coder.Test/Languages/ToolchainHarness.cs +++ b/Coder.Test/Languages/ToolchainHarness.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Languages; @@ -8,25 +8,31 @@ namespace ktsu.Coder.Test.Languages; /// Runs a real compiler over generated source. /// /// -/// Two of the generators here are checked by compiling what they write, because the rules they have -/// to obey are not visible in the text: C's about linkage and constant expressions, Rust's about -/// receivers, associated items and what a trait implementation owes its trait. Everything those two -/// tests share about *running* a compiler is here, so that what is left in each of them is the -/// language. +/// Three of the generators here are checked by compiling what they write, because the rules they +/// have to obey are not visible in the text: C's about linkage and constant expressions, Rust's +/// about receivers, associated items and what a trait implementation owes its trait, and Go's about +/// unused names, which it refuses rather than warns about. Everything those tests share about +/// *running* a compiler is here, so that what is left in each of them is the language. /// internal static class ToolchainHarness { /// /// Finds the first of several commands that is on the path. /// + /// The argument that makes the command say what it is and stop. /// The commands to try, in the order a project would. /// The first one that runs, or null when none does. /// /// Asked by running it rather than by looking for a file, because what matters is whether it /// starts — a name on the path that cannot be executed is not a compiler. + /// + /// The argument is the caller's because it is not the same everywhere: a compiler takes + /// --version and the Go toolchain takes a subcommand, and go --version exits + /// non-zero, which would read as "not installed". + /// /// - public static string? FindOnPath(params string[] commands) => - commands.FirstOrDefault(command => Run(command, "--version", Path.GetTempPath()).ExitCode == 0); + public static string? FindOnPath(string askVersion, params string[] commands) => + commands.FirstOrDefault(command => Run(command, askVersion, Path.GetTempPath()).ExitCode == 0); /// /// Runs a command in a directory of its own, and deletes the directory afterwards. diff --git a/Coder.Test/ServiceCollectionExtensionsTests.cs b/Coder.Test/ServiceCollectionExtensionsTests.cs index 635ced3..54cede5 100644 --- a/Coder.Test/ServiceCollectionExtensionsTests.cs +++ b/Coder.Test/ServiceCollectionExtensionsTests.cs @@ -60,8 +60,9 @@ public void AddLanguageGenerators_ShouldRegisterEveryImplementedLanguage() Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "cpp")); Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "c")); Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "rust")); + Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "go")); - Assert.AreEqual(6, generators.Count, "A new generator needs a registration and an entry here"); + Assert.AreEqual(7, generators.Count, "A new generator needs a registration and an entry here"); } /// diff --git a/Coder/Languages/CFamilyGenerator.cs b/Coder/Languages/CFamilyGenerator.cs index 9c6d37d..55fd356 100644 --- a/Coder/Languages/CFamilyGenerator.cs +++ b/Coder/Languages/CFamilyGenerator.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Languages; @@ -105,26 +105,9 @@ protected override bool WriteFileDirectives(SourceFile file, CodeBlocker code) /// /// Two of a kind that say nothing about themselves stay together, which is what keeps a run of /// aliases, of defaulted declarations, or of assertions about one type reading as one block - /// rather than as four paragraphs. A documented member needs air above it or its first comment - /// line butts against the member before it and reads as belonging to that one. + /// rather than as four paragraphs. /// - protected override bool NeedsSeparation(AstNode previous, AstNode member) - { - Ensure.NotNull(previous); - Ensure.NotNull(member); - - return previous.GetType() != member.GetType() - || IsDocumented(previous) - || IsDocumented(member); - } - - /// - /// Reports whether a member carries documentation. - /// - /// The member to test. - /// True when it does. - protected static bool IsDocumented(AstNode member) => - member is IHasDocumentation documented && documented.Documentation.Count > 0; + protected override bool NeedsSeparation(AstNode previous, AstNode member) => !GroupsWith(previous, member); /// /// Spells a declaration of with that type. diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 814ceb7..d32b29b 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Languages; @@ -106,48 +106,7 @@ public class CGenerator : CFamilyGenerator /// means. /// /// - private static readonly Dictionary OperatorNames = BuildOperatorNames(); - - /// - /// Builds the operator names from the AST's own operator vocabulary. - /// - /// The name for each symbol the AST can spell. - private static Dictionary BuildOperatorNames() - { - Dictionary names = new(StringComparer.Ordinal); - - foreach (BinaryOperator op in Enum.GetValues().Where(HasSymbol)) - { - names[OperatorSymbols.GetSymbol(op)] = SnakeCase(op.ToString()); - } - - // Added second, and without replacing: a symbol both kinds of operator share is named for - // the binary one. - foreach (UnaryOperator op in Enum.GetValues().Where(HasSymbol)) - { - names.TryAdd(OperatorSymbols.GetSymbol(op), SnakeCase(op.ToString())); - } - - return names; - } - - /// - /// Reports whether the AST can spell an operator at all. - /// - /// The operator to test. - /// True when it has a symbol. - /// - /// An operator with no spelling is left out rather than named, which is what keeps - /// safe to call on what survives this — - /// and what stops one added to the AST without a symbol from throwing before anything has run. - /// - private static bool HasSymbol(BinaryOperator op) => - OperatorSymbols.TryGetSymbol(op, out string? symbol) && symbol is not null; - - /// - /// The operator to test. - private static bool HasSymbol(UnaryOperator op) => - OperatorSymbols.TryGetSymbol(op, out string? symbol) && symbol is not null; + private static readonly Dictionary OperatorNames = BuildOperatorNames(SnakeCase); /// /// Writes a name the way C names things. diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs new file mode 100644 index 0000000..d0316fb --- /dev/null +++ b/Coder/Languages/GoGenerator.cs @@ -0,0 +1,1515 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Languages; + +using System; +using System.Collections.Generic; +using System.Linq; +using ktsu.Coder.Ast; +using ktsu.CodeBlocker; + +/// +/// Generates Go code from AST nodes. +/// +/// +/// Go is the target that answers most of the AST with something it already had rather than with +/// something built for the occasion. A type is a struct and its behaviour is methods declared +/// beside it; an interface is an interface, satisfied by whatever has the methods rather than +/// by saying so; a base type is an embedded field, whose members are promoted, which is as +/// near as Go comes to inheritance and nearer than anything the other targets without it manage. +/// A member that promises not to modify what it is called on takes a value receiver and one that +/// does takes a pointer receiver — the same promise C++ writes as a trailing const, made by +/// the shape of the declaration rather than by a modifier on it. +/// +/// What Go does not have is more interesting than what it does, because in each case it left the +/// feature out rather than not reaching it. There is no operator overloading, so an operator is a +/// method named for what it does — Add, LessThan — taken from the AST's own word for +/// it. There are no constructors, so one is the NewType function every Go package +/// writes instead, and it needs no fallback when there is nothing to build from, because every Go +/// type has a zero value and that is what the declaration described. There are no destructors: the +/// convention is a Close the caller defers, which unlike a destructor has to be called. +/// And there is no const beyond numbers, strings and booleans, so a constant table is a +/// var — which is what the language means by constant, rather than a gap in it. +/// +/// +/// Visibility is the one thing Go says in a way no generator can write: a name is exported when its +/// first letter is a capital, and there is no keyword. Renaming a declaration to match what it asked +/// for would not rename the references to it, which is the rule every generator here keeps, so a +/// name that disagrees with its declared visibility gets a note saying so. Unexported is Go's only +/// other answer and it means package-private, so is exactly right +/// and and are as near as there +/// is. +/// +/// +/// The output is what gofmt would write, which is why this generator indents with a tab and +/// lines up the columns of a struct's fields and a constant block. That is not a style anybody here +/// chose: Go has one formatter, everybody runs it, and a generated file it disagrees with is a diff +/// the first time anyone opens it. +/// +/// +public class GoGenerator : StandardLanguageGenerator +{ + /// + /// What a declaration that never said what type it is gets. + /// + /// + /// A type is optional on every node that carries one, because a half-built AST is a thing the + /// editor has to be able to hold. Go's most general type is any, which keeps the output + /// compiling while making it obvious which declaration was never finished. + /// + private const string UnknownTypeName = "object"; + + /// + /// The name a method's receiver is bound to. + /// + /// + /// Go style asks for a one- or two-letter abbreviation of the type, and this is not one, for the + /// reason C names its receiver the same thing: a body is text the generator cannot rewrite, so + /// whoever wrote the statements had to know what the instance would be called. A name that is + /// the same whatever the type is, is the only kind they could have known. + /// + private const string ReceiverName = "self"; + + /// + /// The package a file with an entry point is in. + /// + /// + /// Not a default: Go runs main in a package called main and nowhere else, so a file + /// holding an entry point is in that package whatever its namespace says. + /// + private const string MainPackage = "main"; + + /// + /// The name a destructor is written under. + /// + /// + /// Go has no destructor. Close is the convention for releasing what a value holds — it is + /// what io.Closer asks for and what defer exists to pair with — and the one thing it + /// does not share with a destructor is that somebody has to call it. + /// + private const string CloseName = "Close"; + + /// + /// What a type declaration with no name is written under. + /// + private const string UnnamedType = "UnnamedType"; + + /// + /// What a member with no name is written under. + /// + private const string UnnamedMember = "unnamed"; + + /// + /// The package an entry point reaches for its arguments and its exit code. + /// + private const string RuntimePackage = "\"os\""; + + private static readonly Dictionary TypeMappings = new(StringComparer.OrdinalIgnoreCase) + { + { "str", "string" }, + { "string", "string" }, + { "int", "int" }, + { "long", "int64" }, + { "float", "float32" }, + { "double", "float64" }, + { "bool", "bool" }, + { "void", "" }, + { "object", "any" }, + }; + + /// + /// The name of each operator, for a language that cannot overload one and so must call it + /// something. + /// + /// + /// The AST's own word for the operator, unchanged: + is Add and < is + /// LessThan, which is both what the enumeration calls them and how Go names a method. C + /// reaches the same vocabulary through the same builder and lowercases it, which is the whole of + /// the difference between naming a thing in the two languages. + /// + private static readonly Dictionary OperatorNames = BuildOperatorNames(word => word); + + /// + /// The name of each unary operator, which is not always the name of the binary one sharing its + /// symbol. + /// + /// + /// Kept apart because - is in both, and a type declaring subtraction and negation would + /// otherwise declare Subtract twice — which Go refuses, as it should. Which one a + /// declaration means is decided by whether it takes an operand beside the instance, the same + /// question Rust asks of the same symbol. + /// + private static readonly Dictionary UnaryOperatorNames = BuildUnaryOperatorNames(word => word); + + /// + /// Whether what is being written is a member of an interface. + /// + /// + /// An interface holds signatures, so a member there is written without func, without a + /// receiver and without a body — and a member with no body is a requirement rather than something + /// missing. + /// + private bool insideInterface; + + /// + /// Gets the unique identifier for this language generator. + /// + public override string LanguageId => "go"; + + /// + /// Gets the display name for this language generator. + /// + public override string DisplayName => "Go"; + + /// + /// Gets the file extension (without the dot) used for this language. + /// + public override string FileExtension => "go"; + + /// + /// + /// A tab, because gofmt writes a tab. This is the one target where the indentation is not + /// the generator's to pick. + /// + protected override string IndentString => "\t"; + + /// + /// + /// Go's documentation is an ordinary comment in the right place: a run of // lines + /// directly above a declaration, with no blank line between, is that declaration's doc comment + /// and is what go doc prints. There is no second marker, and writing one would make the + /// comment stop being documentation. + /// + protected override string DocumentationPrefix => "//"; + + /// + /// + /// Nothing between a literal's braces and its elements: Point{X: 1}, which is what + /// gofmt writes. + /// + protected override string ListPadding => string.Empty; + + /// + /// + /// The line break alone. Go's grammar wants a semicolon and its lexer inserts one at the end of + /// the line, so a generator writing one would be writing the character gofmt deletes. + /// + protected override void EndStatement(CodeBlocker code) + { + Ensure.NotNull(code); + code.WriteLine(); + } + + /// + /// + /// ^. Go spells bitwise complement with the same character as exclusive-or and has no + /// ~ outside a type constraint, which is the one operator in the AST's vocabulary it does + /// not share with the C family. + /// + protected override string GetUnaryOperatorSpelling(UnaryOperator op) => + op == UnaryOperator.BitwiseNot ? "^" : GetUnaryOperator(op); + + /// + /// + /// Nothing: a Go file's imports are written with its package clause, by + /// , because the two are one header and have to appear in that + /// order before anything else. + /// + protected override string? SpellImport(string import) => null; + + /// + /// + /// The package clause and the import block, which is the whole of a Go file's header and is the + /// one part of it whose order the language fixes. + /// + /// Writing the imports here rather than one at a time is what lets the block hold one the file + /// did not ask for. An entry point that reads its arguments or answers an exit code reaches + /// os, and Go has no way to name a package without importing it — nor to leave an import + /// unused, which is an error rather than a warning, so it is added exactly when it is about to be + /// used. Nothing similar can be done for an import a body needs: a statement is text, + /// and what it depends on is the file's to declare. + /// + /// + /// A group is sorted and the groups are kept apart, which is what gofmt does to an import + /// block: it sorts within each run of lines and leaves a blank line where it finds one. The AST + /// already spells a group boundary as an empty import, so the two agree without being made to. + /// + /// + protected override bool WriteFileDirectives(SourceFile file, CodeBlocker code) + { + Ensure.NotNull(file); + Ensure.NotNull(code); + + code.WriteLine($"package {PackageOf(file)}"); + + List> groups = ImportGroups(file); + if (groups.Count == 0) + { + return true; + } + + code.NewLine(); + + // One import is written on the line, which is what a Go file with one import looks like. + if (groups is [[string only]]) + { + code.WriteLine($"import {only}"); + return true; + } + + code.Write("import "); + + using ParenScope block = new(code); + + bool first = true; + foreach (List group in groups) + { + if (!first) + { + code.NewLine(); + } + + first = false; + + foreach (string import in group) + { + code.WriteLine(import); + } + } + + return true; + } + + /// + /// Gives the package a file declares. + /// + /// The file being emitted. + /// The package name. + /// + /// A file holding an entry point is main, whatever else it says: that is where Go runs + /// one. Otherwise the namespace names it, and failing that the file does. + /// + private static string PackageOf(SourceFile file) + { + if (EntryPoints(file.Members).Any()) + { + return MainPackage; + } + + foreach (NamespaceDeclaration declared in file.Members.OfType()) + { + if (NamespaceDeclaration.Split(declared.Name) is [.., string leaf]) + { + return PackageName(leaf); + } + } + + return PackageName(file.Name ?? string.Empty); + } + + /// + /// Folds a name into something Go will accept as a package name. + /// + /// The name to fold. + /// The package name. + /// + /// Lower case and letters only, which is what Go asks a package to be named — and the one place + /// here a name is recased, because a package name is not referred to by the declarations in it. + /// + private static string PackageName(string name) + { + string folded = string.Concat(name.Where(char.IsLetterOrDigit)).ToLowerInvariant(); + return folded.Length == 0 || char.IsDigit(folded[0]) ? MainPackage : folded; + } + + /// + /// Gives the import block's groups, in the order they are written. + /// + /// The file being emitted. + /// Each group's import lines, sorted by path, with the empty groups dropped. + private static List> ImportGroups(SourceFile file) + { + List> groups = [[]]; + + foreach (string import in file.Imports) + { + // An empty import is a group separator rather than an import of nothing. + if (import.Length == 0) + { + groups.Add([]); + continue; + } + + groups[^1].Add(Quoted(import)); + } + + if (EntryPoints(file.Members).Any(entry => entry.AcceptsArguments || entry.ReturnsExitCode) + && !groups.Any(group => group.Contains(RuntimePackage))) + { + groups[0].Add(RuntimePackage); + } + + foreach (List group in groups) + { + group.Sort((left, right) => string.CompareOrdinal(ImportPath(left), ImportPath(right))); + } + + return [.. groups.Where(group => group.Count > 0)]; + } + + /// + /// Quotes an import path, unless whoever wrote it already spelled the whole item. + /// + /// The import as the file carries it. + /// The import line, without the keyword. + /// + /// A path is quoted in Go, so text that already carries a quote is the whole item — a renaming + /// import, f "fmt", or a blank one — and is written as it stands. + /// + private static string Quoted(string import) => + import.Contains('"', StringComparison.Ordinal) ? import : $"\"{import}\""; + + /// + /// Gives the path an import line imports, which is what it sorts by. + /// + /// The import line. + /// The path, without its quotes. + private static string ImportPath(string import) + { + int open = import.IndexOf('"', StringComparison.Ordinal); + int close = import.LastIndexOf('"'); + return open >= 0 && close > open ? import[(open + 1)..close] : import; + } + + /// + /// Finds every entry point a file declares, wherever its namespaces put them. + /// + /// The declarations to search. + /// The entry points. + private static IEnumerable EntryPoints(IEnumerable members) + { + foreach (AstNode member in members) + { + if (member is EntryPoint entryPoint) + { + yield return entryPoint; + } + else if (member is NamespaceDeclaration declared) + { + foreach (EntryPoint nested in EntryPoints(declared.Members)) + { + yield return nested; + } + } + } + } + + /// + /// + /// Two of a kind that say nothing about themselves stay together, which keeps a run of type + /// aliases or of assertions reading as one block rather than as a paragraph each — but only where + /// each of them is one line. A type or a function is a paragraph in Go whatever precedes it, and + /// a declaration with a body butted against a one-line one reads as part of it. + /// + protected override bool NeedsSeparation(AstNode previous, AstNode member) => + !GroupsWith(previous, member) || member is ClassDeclaration or EnumDeclaration or FunctionDeclaration or EntryPoint; + + /// + /// + /// A namespace is a package, and the package clause is part of the file's header — so what is + /// left here is the members, written where they stand. + /// + /// A dotted name is not nested packages. A Go package is named for the one directory holding its + /// files, so geo.shapes is a file in geo/shapes called shapes, and the rest + /// of the path is where the file goes rather than anything written in it. That is worth a note, + /// because it is the one part of the name a generated file cannot carry. + /// + /// + protected override void GenerateNamespaceDeclaration(NamespaceDeclaration namespaceDecl, CodeBlocker code) + { + Ensure.NotNull(namespaceDecl); + Ensure.NotNull(code); + + GenerateDocumentation(namespaceDecl, code); + + IReadOnlyList path = NamespaceDeclaration.Split(namespaceDecl.Name); + if (path.Count > 1) + { + WriteInexpressible( + code, + $"{string.Join("/", path)}: a Go package is named for one directory, so the rest of the path is where this file goes"); + + // A comment against a declaration is that declaration's documentation in Go, so this one + // needs air under it or it becomes what the first member says about itself. + code.NewLine(); + } + + WriteMembers(namespaceDecl.Members, code); + } + + /// + /// + /// A struct for the data, and the behaviour beside it rather than inside it — which is the split + /// Rust insists on and Go simply does, since a method is declared at package scope with a + /// receiver and nothing gathers them. + /// + /// A static member has no receiver and so nothing to scope its name, which is the one place a + /// declaration is renamed: the type's name becomes part of it, the way C writes + /// Point_zero, spelled as Go spells a name. That applies to a static field too, which + /// becomes a package-level var, because Go has no static data member and a note in place + /// of the table would lose it. + /// + /// + protected override void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code) + { + Ensure.NotNull(classDecl); + Ensure.NotNull(code); + + string name = classDecl.Name ?? UnnamedType; + + // A type declared inside another is written beside it: Go nests nothing but a function. + foreach (AstNode nested in classDecl.Members.Where(IsTypeDeclaration)) + { + GenerateInternal(nested, code); + code.NewLine(); + } + + if (classDecl.IsSpecialisation) + { + GenerateDocumentation(classDecl, code); + WriteInexpressible( + code, + $"{name} for {string.Join(", ", classDecl.SpecialisationArguments.Select(SpellType))}: " + + "Go attaches a method only in the package declaring its type, so there is nowhere to put this"); + return; + } + + if (classDecl.Kind == TypeDeclarationKind.Interface) + { + GenerateInterface(classDecl, name, code); + return; + } + + GenerateStruct(classDecl, name, code); + + foreach (FieldDeclaration field in classDecl.Members.OfType().Where(field => field.IsStatic)) + { + code.NewLine(); + WriteStorage(Join(name, field.Name ?? UnnamedMember), field, code); + } + + foreach (FunctionDeclaration function in classDecl.Members.OfType()) + { + code.NewLine(); + GenerateFunction(function, code, name); + } + } + + /// + /// Reports whether a member declares a type rather than data or behaviour. + /// + /// The member to test. + /// True when it declares a type. + private static bool IsTypeDeclaration(AstNode member) => + member is ClassDeclaration or EnumDeclaration or UsingAlias; + + /// + /// Writes the data half of a type declaration. + /// + /// The declaration to emit. + /// The name it is written under. + /// The writer to emit into. + /// + /// A base type is an embedded field: a field with a type and no name of its own, whose members + /// are reached through the outer value as though they were its. That is the nearest thing to + /// inheritance in the language, and it is near enough to be worth saying which it is — what it + /// does not give is a derived value standing in for a base one, which interfaces do instead. + /// + /// A field's initial value is not written, and that is not a gap: a Go value that names none of + /// its fields starts every one of them at that type's zero, so what a field starts at is the + /// language's answer rather than the declaration's. + /// + /// + private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) + { + GenerateDocumentation(classDecl, code); + WriteExportNote(name, classDecl.Visibility, code); + + List fields = [.. StructFields(classDecl)]; + + if (fields.Count == 0) + { + code.WriteLine($"type {name} struct{{}}"); + return; + } + + code.Write($"type {name} struct "); + + using Scope body = new(code); + WriteAligned(fields, code); + } + + /// + /// Gives the fields a struct declares, in the order they are written. + /// + /// The declaration being emitted. + /// One line per field. + private IEnumerable StructFields(ClassDeclaration classDecl) + { + if (classDecl.BaseType is TypeReference baseType) + { + yield return new AlignedLine( + [$"{CommentPrefix} the base, embedded: Go promotes an embedded type's members rather than deriving from it"], + SpellType(baseType), + string.Empty); + } + + foreach (AstNode member in classDecl.Members) + { + switch (member) + { + case VariableDeclaration field: + yield return Field(field.Name, field.Type, field.Visibility, []); + break; + + case FieldDeclaration field when !field.IsStatic: + yield return Field(field.Name, field.Type, field.Visibility, field.Documentation); + break; + + default: + break; + } + } + } + + /// + /// Builds the line one field of a struct is written on. + /// + /// The field's name. + /// The field's type. + /// What the declaration said about who may see it. + /// What the declaration says about itself. + /// The line. + private AlignedLine Field(string? name, TypeReference? type, Visibility visibility, IEnumerable documentation) + { + List notes = [.. documentation.Select(DocumentationLine)]; + + if (ExportNote(name, visibility) is string note) + { + notes.Add($"{CommentPrefix} {note}"); + } + + return new AlignedLine(notes, name ?? UnnamedMember, SpellType(type ?? new TypeReference(UnknownTypeName))); + } + + /// + /// Writes an interface as the interface it is. + /// + /// The declaration to emit. + /// The name it is written under. + /// The writer to emit into. + /// + /// The one mapping here that needs no explaining, and the one thing Go does that no other target + /// does at all: an interface is satisfied by any type with the methods, which never has to say so + /// and need not have been written when the interface was. A base type is an embedded interface, + /// which requires everything it requires — the same thing Rust spells as a supertrait. + /// + private void GenerateInterface(ClassDeclaration classDecl, string name, CodeBlocker code) + { + GenerateDocumentation(classDecl, code); + WriteExportNote(name, classDecl.Visibility, code); + + List members = [.. classDecl.Members.Where(member => member is FunctionDeclaration or FieldDeclaration)]; + + if (classDecl.BaseType is null && members.Count == 0) + { + code.WriteLine($"type {name} interface{{}}"); + return; + } + + code.Write($"type {name} interface "); + + using Scope body = new(code); + + if (classDecl.BaseType is TypeReference baseType) + { + code.WriteLine(SpellType(baseType)); + } + + insideInterface = true; + + foreach (AstNode member in members) + { + if (member is FunctionDeclaration function) + { + GenerateFunction(function, code, name); + continue; + } + + GenerateInternal(member, code); + } + + insideInterface = false; + } + + /// + protected override void GenerateFunctionDeclaration(FunctionDeclaration funcDecl, CodeBlocker code) => + GenerateFunction(funcDecl, code, null); + + /// + /// Emits a function, which may have been declared as a member of a type. + /// + /// The declaration to emit. + /// The writer to emit into. + /// The name of the type it belongs to, when it belongs to one. + /// + /// A member takes a receiver, and which one it takes is what + /// decides: a member that promises not to modify + /// what it is called on takes the value and one that does takes a pointer to it. Go makes that + /// promise by the shape of the declaration rather than with a keyword, and it is a real one — + /// a value receiver is a copy, so a method that took one cannot change the caller's value even + /// by mistake. + /// + /// Nothing is written for , + /// , + /// , + /// , + /// , or + /// . Go has no attribute for a call worth looking at, + /// no virtual dispatch outside an interface, no compile-time evaluation, no exceptions, no + /// converting constructors and no friends — which is a list of things left out rather than of + /// things missed. + /// + /// + private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, string? enclosingType) + { + Ensure.NotNull(funcDecl); + Ensure.NotNull(code); + + GenerateDocumentation(funcDecl, code); + + string name = SpellFunctionName(funcDecl, enclosingType); + + // A deleted declaration exists to make a call illegal, and Go has no way to say that of one + // member. Writing the signature would do the opposite of what it asks for. + if (funcDecl.Definition == FunctionDefinition.Deleted) + { + WriteInexpressible(code, $"{name} is deleted: Go cannot refuse a call"); + return; + } + + // A defaulted declaration is one the language supplies. Go supplies it to every type at once, + // as the zero value, rather than to a type that asks. + if (funcDecl.Definition == FunctionDefinition.Defaulted) + { + WriteInexpressible(code, $"{name} is defaulted: Go gives every type a zero value instead"); + return; + } + + if (insideInterface) + { + WriteSignature(funcDecl, name, code, enclosingType); + code.WriteLine(); + return; + } + + // A declaration with no definition is a requirement, which only an interface can hold: there + // is nowhere on a struct for one to be implemented. + if (funcDecl.IsAbstract) + { + WriteInexpressible(code, $"{name} has no body: only an interface may require one"); + return; + } + + WriteExportNote(name, funcDecl.Visibility, code); + + code.Write("func "); + WriteReceiver(funcDecl, enclosingType, code); + WriteSignature(funcDecl, name, code, enclosingType); + + // The line is left open, so the scope's brace lands on it: Go braces hang, and gofmt will not + // have them anywhere else. + code.Write(" "); + + using Scope body = new(code); + + if (funcDecl.Kind == FunctionKind.Constructor) + { + WriteConstructorBody(funcDecl, enclosingType, code); + return; + } + + WriteBody(funcDecl.Body, code); + } + + /// + /// Writes a function's name, its parameters and what it answers with. + /// + /// The declaration being emitted. + /// The name it is written under. + /// The writer to emit into. + /// The name of the type it belongs to, when it belongs to one. + private void WriteSignature(FunctionDeclaration funcDecl, string name, CodeBlocker code, string? enclosingType) + { + code.Write($"{name}("); + GenerateParameterList(funcDecl.Parameters, code); + code.Write(")"); + + if (SpellResult(funcDecl, enclosingType) is string result && result.Length > 0) + { + code.Write($" {result}"); + } + } + + /// + /// Spells what a function answers with, or nothing when it answers nothing. + /// + /// The declaration being emitted. + /// The name of the type it belongs to, when it belongs to one. + /// The result type, or an empty string. + /// + /// A constructor answers the type it builds, which the declaration does not carry — it is named + /// after the type rather than typed by it, the same as everywhere else in the AST. + /// + private static string SpellResult(FunctionDeclaration funcDecl, string? enclosingType) => + funcDecl.Kind == FunctionKind.Constructor && enclosingType is not null + ? enclosingType + : SpellType(funcDecl.ReturnType ?? new TypeReference("void")); + + /// + /// Writes the instance a method is called on, when it is called on one. + /// + /// The declaration being emitted. + /// The name of the type it belongs to, when it belongs to one. + /// The writer to emit into. + private static void WriteReceiver(FunctionDeclaration funcDecl, string? enclosingType, CodeBlocker code) + { + if (!TakesReceiver(funcDecl, enclosingType)) + { + return; + } + + code.Write($"({ReceiverName} {(funcDecl.IsReadOnly ? string.Empty : "*")}{enclosingType}) "); + } + + /// + /// Reports whether a declaration is called on an instance. + /// + /// The declaration to test. + /// The name of the type it belongs to, when it belongs to one. + /// True when it takes a receiver. + private static bool TakesReceiver(FunctionDeclaration funcDecl, string? enclosingType) => + enclosingType is not null && !funcDecl.IsStatic && funcDecl.Kind != FunctionKind.Constructor; + + /// + /// Spells the name a declaration is written under. + /// + /// The declaration being emitted. + /// The name of the type it belongs to, when it belongs to one. + /// The name as Go writes it. + /// + /// A constructor is NewType, which is a convention rather than a keyword — Go has + /// no constructors, and a function answering the type is what every package writes instead. + /// + /// A member with no receiver has nothing to scope its name, so the type's name becomes part of + /// it. That is a rename, which this generator otherwise refuses to do, and it is forced: there is + /// no Point.zero in Go for a reference to have named in the first place. + /// + /// + private static string SpellFunctionName(FunctionDeclaration funcDecl, string? enclosingType) + { + string bare = funcDecl.Kind switch + { + FunctionKind.Constructor => $"New{enclosingType ?? UnnamedType}", + FunctionKind.Destructor => CloseName, + FunctionKind.Operator => OperatorName(funcDecl), + FunctionKind.ConversionOperator => ConversionName(funcDecl.ReturnType), + _ => funcDecl.Name ?? UnnamedMember, + }; + + return funcDecl.Kind != FunctionKind.Constructor + && enclosingType is not null + && !TakesReceiver(funcDecl, enclosingType) + ? Join(enclosingType, bare) + : bare; + } + + /// + /// Names an operator, which Go cannot overload and so must call something. + /// + /// The declaration being emitted. + /// The method name. + /// + /// A declaration with no operand beside the instance is the unary reading of its symbol, which is + /// the only thing that tells -a from a - b. + /// + private static string OperatorName(FunctionDeclaration funcDecl) + { + string symbol = funcDecl.Name ?? string.Empty; + + if (funcDecl.Parameters.Count == 0 && UnaryOperatorNames.TryGetValue(symbol, out string? unary)) + { + return unary; + } + + return OperatorNames.TryGetValue(symbol, out string? word) ? word : $"Operator{Identifier(symbol)}"; + } + + /// + /// Names a conversion, which Go cannot declare and so writes as a method. + /// + /// The type being converted to. + /// The method name. + /// + /// A conversion to a string is String, which is not a naming convention but the method + /// fmt.Stringer asks for: a type that has it is printed with it by everything in the + /// standard library that prints anything. Every other conversion is ToType, which + /// is only a name. + /// + private static string ConversionName(TypeReference? target) + { + string spelled = SpellType(target ?? new TypeReference(UnknownTypeName)); + return string.Equals(spelled, "string", StringComparison.Ordinal) ? "String" : $"To{Identifier(spelled)}"; + } + + /// + /// Joins a type's name to a member's, as Go joins the words of a name. + /// + /// The type's name. + /// The member's name. + /// The joined name. + private static string Join(string type, string name) => + name.Length == 0 ? type : $"{type}{char.ToUpperInvariant(name[0])}{name[1..]}"; + + /// + /// Keeps what Go will accept in an identifier, and capitalises what is left. + /// + /// The text to fold. + /// The text as a word of a name. + private static string Identifier(string text) + { + string kept = string.Concat(text.Where(character => char.IsLetterOrDigit(character) || character == '_')); + return kept.Length == 0 ? string.Empty : $"{char.ToUpperInvariant(kept[0])}{kept[1..]}"; + } + + /// + /// Writes what a constructor builds. + /// + /// The declaration being emitted. + /// The type being built. + /// The writer to emit into. + /// + /// A Go value is built by a composite literal naming the fields it does not want the zero of, so + /// the initialiser list is the whole of the constructor rather than a preamble to it. An + /// initialiser list with nothing in it needs no fallback: the literal with no fields is the zero + /// value, which every Go type has and which is exactly what a constructor with nothing to build + /// from was asking for. + /// + private void WriteConstructorBody(FunctionDeclaration funcDecl, string? enclosingType, CodeBlocker code) + { + WriteBody(funcDecl.Body, code); + + ConstructionExpression value = new(new TypeReference(enclosingType ?? UnnamedType)); + foreach (MemberInitialiser initialiser in funcDecl.Initialisers) + { + value.Arguments.Add(initialiser); + } + + code.Write("return "); + GenerateConstructionExpression(value, code); + EndStatement(code); + } + + /// + /// Writes a function's statements. + /// + /// The statements to write. + /// The writer to emit into. + private void WriteBody(IEnumerable statements, CodeBlocker code) + { + foreach (AstNode statement in statements) + { + GenerateInternal(statement, code); + } + } + + /// + /// + /// A parameter's default value is written beside it as a comment. Go has no default arguments, + /// so the caller has to pass one — and the value the declaration chose is exactly what they need + /// in order to pass the same thing. + /// + protected override void GenerateParameter(Parameter parameter, CodeBlocker code, int position) + { + Ensure.NotNull(parameter); + Ensure.NotNull(code); + + code.Write($"{parameter.Name ?? $"param{position}"} "); + code.Write(SpellType(parameter.Type ?? new TypeReference(UnknownTypeName))); + + if (parameter.IsOptional && !string.IsNullOrEmpty(parameter.DefaultValue)) + { + code.Write($" /* = {parameter.DefaultValue} */"); + } + } + + /// + /// + /// := where the declaration says its type is to be inferred and gives a value to infer it + /// from, and var otherwise. A var with no value is not an omission: Go starts it at + /// its type's zero, which is the whole of what the declaration said. + /// + /// is a const only where Go can hold one. + /// A Go constant is a number, a string or a boolean the compiler worked out — never a struct, a + /// slice or anything built while running — so a declaration that starts at one of those is a + /// var with a note, rather than a const the compiler refuses. + /// + /// + protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, CodeBlocker code) + { + Ensure.NotNull(varDecl); + Ensure.NotNull(code); + + bool inferred = varDecl.IsTypeInferred || varDecl.Type is null; + + // 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)) + { + code.Write($"{varDecl.Name} := "); + GenerateInternal(varDecl.InitialValue, code); + EndStatement(code); + return; + } + + WriteStorageKeyword(varDecl.Name, varDecl.IsConstant, varDecl.InitialValue, code); + code.Write(varDecl.Name); + + TypeReference type = varDecl.Type ?? new TypeReference(UnknownTypeName); + + if (!inferred || varDecl.InitialValue is null) + { + code.Write($" {SpellType(type)}"); + } + + if (varDecl.InitialValue is not null) + { + code.Write(" = "); + WriteInitialValue(type, varDecl.InitialValue, code); + } + + EndStatement(code); + } + + /// + /// + /// A package-level var or const. A field of a struct never reaches here: the struct + /// writes its own, so that their columns line up. + /// + protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code) + { + Ensure.NotNull(field); + Ensure.NotNull(code); + + if (insideInterface) + { + GenerateDocumentation(field, code); + WriteInexpressible(code, $"{field.Name}: a Go interface holds methods, so a constant belongs to what implements it"); + return; + } + + WriteStorage(field.Name ?? UnnamedMember, field, code); + } + + /// + /// Writes a field as the package-level declaration Go holds one in. + /// + /// The name it is written under. + /// The declaration to emit. + /// The writer to emit into. + private void WriteStorage(string name, FieldDeclaration field, CodeBlocker code) + { + GenerateDocumentation(field, code); + WriteExportNote(name, field.Visibility, code); + + TypeReference type = field.Type ?? new TypeReference(UnknownTypeName); + + WriteStorageKeyword(name, field.IsConstant, field.InitialValue, code); + code.Write($"{name} {SpellType(type)}"); + + if (field.InitialValue is not null) + { + code.Write(" = "); + WriteInitialValue(type, field.InitialValue, code); + } + + EndStatement(code); + } + + /// + /// Writes const or var, saying so first where the declaration asked for the one Go + /// cannot give. + /// + /// The name being declared. + /// Whether the declaration says its value never changes. + /// What it starts at. + /// The writer to emit into. + private void WriteStorageKeyword(string? name, bool wanted, AstNode? value, CodeBlocker code) + { + bool constant = IsConstant(wanted, value); + + if (wanted && !constant) + { + WriteInexpressible(code, $"{name} is constant: a Go const is a number, a string or a bool, so this is a var"); + } + + code.Write(constant ? "const " : "var "); + } + + /// + /// Reports whether a declaration is written as a const. + /// + /// Whether the declaration says its value never changes. + /// What it starts at. + /// 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 AstLeafNode + or AstLeafNode + or AstLeafNode; + + /// + /// Writes what a declaration starts at, giving a bare list the declaration's own type. + /// + /// The declared type. + /// The value to write. + /// The writer to emit into. + /// + /// This is the one place Go asks for the opposite of what C does. A composite literal carries its + /// type, and the only place an untyped one is allowed is inside another — so a list standing as + /// an initialiser is given the type the declaration already said, where C had to take one away. + /// + private void WriteInitialValue(TypeReference declared, AstNode value, CodeBlocker code) + { + if (value is ConstructionExpression { Type: null }) + { + code.Write(SpellType(declared)); + } + + GenerateInternal(value, code); + } + + /// + /// + /// A named type and a block of constants, which is what Go has in place of an enumeration. The + /// constants are at package scope, so each is prefixed with the type's name the way C's are and + /// for the same reason: two enumerations with a None each would otherwise be one + /// redeclaration. + /// + /// A member with no value of its own is iota while nothing has interrupted the count, and + /// the one before it plus one once something has. That is not a flourish: Go continues a + /// const block by repeating the previous line's expression, which is right while the + /// expression mentions iota and gives every later member the same value once one has said + /// a number. Naming the previous constant says what C says — that an unvalued member is the one + /// before it plus one — in the one way that stays true. + /// + /// + protected override void GenerateEnumDeclaration(EnumDeclaration enumDecl, CodeBlocker code) + { + Ensure.NotNull(enumDecl); + Ensure.NotNull(code); + + string name = enumDecl.Name ?? UnnamedType; + + GenerateDocumentation(enumDecl, code); + WriteExportNote(name, enumDecl.Visibility, code); + code.WriteLine($"type {name} {SpellType(enumDecl.UnderlyingType ?? new TypeReference("int"))}"); + + if (enumDecl.Members.Count == 0) + { + return; + } + + code.NewLine(); + code.Write("const "); + + using ParenScope block = new(code); + WriteAligned([.. EnumMembers(enumDecl, name)], code); + } + + /// + /// Gives the constants an enumeration declares, in the order they are written. + /// + /// The declaration being emitted. + /// The type's name. + /// One line per member. + private static IEnumerable EnumMembers(EnumDeclaration enumDecl, string name) + { + string? previous = null; + bool counting = true; + + foreach (EnumMember member in enumDecl.Members) + { + string constant = Prefixed(name, member.Name ?? UnnamedMember); + string value; + + if (member.Value is not null) + { + value = $"{name} = {member.Value}"; + counting = false; + } + else if (previous is null) + { + value = $"{name} = iota"; + } + else + { + // While iota is still counting, saying nothing is what continues it. + value = counting ? string.Empty : $"{name} = {previous} + 1"; + } + + yield return new AlignedLine([], constant, value); + previous = constant; + } + } + + /// + /// Gives a constant the enumeration's name, unless it already carries it. + /// + /// The enumeration's name. + /// The member's name. + /// The constant's name. + /// + /// The one thing already prefixed is a member somebody prefixed by hand, and prefixing it again + /// would give them a ColourColourRed for having anticipated this. + /// + private static string Prefixed(string name, string member) => + member.StartsWith(name, StringComparison.Ordinal) ? member : Join(name, member); + + /// + /// + /// A type alias, which is what = makes it: type Origin = Point is a second name for + /// one type, where type Origin Point would be a second type with the same shape. + /// + protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code) + { + Ensure.NotNull(usingAlias); + Ensure.NotNull(code); + + GenerateDocumentation(usingAlias, code); + WriteExportNote(usingAlias.Name, usingAlias.Visibility, code); + code.Write($"type {usingAlias.Name} = {SpellType(usingAlias.AliasedType ?? new TypeReference(UnknownTypeName))}"); + EndStatement(code); + } + + /// + /// + /// Go has no static_assert, and it has something that works as one. The keys of a map + /// literal must be distinct, and a key that is a constant is checked while compiling — so a + /// literal holding false and the condition holds two keys when the condition is true and + /// the same key twice when it is false, which is a compile error naming the duplicate. + /// + /// It needs no import, no build tag and no generics, and it fails at the line that made the + /// promise. What it does need is a condition Go can evaluate while compiling, which is what a + /// compile-time assertion means everywhere. + /// + /// + protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assertion, CodeBlocker code) + { + Ensure.NotNull(assertion); + Ensure.NotNull(code); + + if (assertion.Message is string message) + { + code.WriteLine($"{CommentPrefix} {message}"); + } + + code.WriteLine($"{CommentPrefix} A false condition repeats the false key, which Go refuses to compile."); + code.Write($"var _ = map[bool]struct{{}}{{false: {{}}, {assertion.Condition ?? "false"}: {{}}}}"); + EndStatement(code); + } + + /// + /// + /// Three shapes, and which one is written depends on what the expression is rather than on where + /// it stands: a construction naming its members is a composite literal, one with no type at all + /// is a composite literal whose type the declaration around it supplies, and one that names + /// neither is a call — which in Go is a conversion when it takes one argument, since that is + /// what int64(n) is. + /// + protected override void GenerateConstructionExpression(ConstructionExpression construction, CodeBlocker code) + { + Ensure.NotNull(construction); + Ensure.NotNull(code); + + if (construction.Type is null) + { + WriteElementList(construction, code, "{", "}", "{}"); + return; + } + + string type = SpellType(construction.Type); + + if (construction.Arguments.Count == 0 || construction.Arguments.Any(argument => argument is MemberInitialiser)) + { + code.Write(type); + WriteElementList(construction, code, "{", "}", "{}"); + return; + } + + code.Write($"{type}("); + + for (int index = 0; index < construction.Arguments.Count; index++) + { + if (index > 0) + { + code.Write(", "); + } + + GenerateInternal(construction.Arguments[index], code); + } + + code.Write(")"); + } + + /// + /// + /// Go's main takes no arguments and returns nothing, so a program that wants either + /// reaches for os: the arguments are os.Args, and an exit code is handed to + /// os.Exit. + /// + /// A program that returns an exit code is written as a run answering one and a + /// main exiting with what it answered — the same shape Python's __main__ guard + /// takes here, and for the same reason: it is what keeps the body's own return meaning + /// what it says. It is also what Go's own documentation recommends, because os.Exit runs + /// no deferred call and a run is the place to put them. + /// + /// + protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker code) + { + Ensure.NotNull(entryPoint); + Ensure.NotNull(code); + + string parameters = entryPoint.AcceptsArguments ? "args []string" : string.Empty; + string arguments = entryPoint.AcceptsArguments ? "os.Args" : string.Empty; + + if (entryPoint.ReturnsExitCode) + { + code.Write($"func run({parameters}) int "); + + using (Scope run = new(code)) + { + WriteBody(entryPoint.Body, code); + } + + code.NewLine(); + code.Write("func main() "); + + using Scope main = new(code); + code.WriteLine($"os.Exit(run({arguments}))"); + return; + } + + code.Write("func main() "); + + using Scope body = new(code); + + if (entryPoint.AcceptsArguments) + { + code.WriteLine("args := os.Args"); + } + + WriteBody(entryPoint.Body, code); + } + + /// + /// Writes the note a declaration earns when its name disagrees with the visibility it asked for. + /// + /// The name being declared. + /// What the declaration said about who may see it. + /// The writer to emit into. + private void WriteExportNote(string? name, Visibility visibility, CodeBlocker code) + { + if (ExportNote(name, visibility) is string note) + { + WriteInexpressible(code, note); + } + } + + /// + /// Says what a declaration asked for, where Go's only way of saying it is the name it was given. + /// + /// The name being declared. + /// What the declaration said about who may see it. + /// The note, or null when the name already says it. + /// + /// Go exports a name whose first letter is a capital and nothing else, so a declaration's + /// visibility is not something a generator can write beside it — only something it can rename it + /// into. Renaming would leave every reference to the old name behind, which is why this says so + /// instead, and why it says nothing at all where the name and the declaration already agree. + /// + private static string? ExportNote(string? name, Visibility visibility) + { + if (visibility == Visibility.Unspecified || string.IsNullOrEmpty(name) || !char.IsLetter(name[0])) + { + return null; + } + + bool exported = char.IsUpper(name[0]); + if (exported == (visibility == Visibility.Public)) + { + return null; + } + + string said = exported ? "exported" : "unexported"; + return $"{name} is {visibility.ToString().ToLowerInvariant()}: in Go that is the case of the first letter, so the name says {said} instead"; + } + + /// + /// Writes one line of documentation as Go writes one. + /// + /// The line to write. + /// The comment line. + private string DocumentationLine(string line) => + line.Length == 0 ? DocumentationPrefix : $"{DocumentationPrefix} {line}"; + + /// + /// One line of a block whose columns line up, and the comments belonging above it. + /// + /// What is written above the line. + /// The first column. + /// The second column, or nothing where the line has only one. + private sealed record AlignedLine(IReadOnlyList Notes, string Name, string Rest); + + /// + /// Writes a block of lines with their second columns lined up. + /// + /// The lines to write. + /// The writer to emit into. + /// + /// What gofmt does to a struct's fields and to a constant block, reproduced rather than + /// left for it to do: a generated file nobody has run the formatter over should already be what + /// the formatter would write. + /// + /// The rule is gofmt's own, including its two edges. A line with one column — an embedded + /// field — takes no part in the width, and a comment between two lines does not break the block, + /// which is why the notes belong to the line rather than being written before the block. + /// + /// + private static void WriteAligned(IReadOnlyList lines, CodeBlocker code) + { + int width = lines + .Where(line => line.Rest.Length > 0) + .Select(line => line.Name.Length) + .DefaultIfEmpty(0) + .Max(); + + foreach (AlignedLine line in lines) + { + foreach (string note in line.Notes) + { + code.WriteLine(note); + } + + code.WriteLine(line.Rest.Length == 0 ? line.Name : $"{line.Name.PadRight(width)} {line.Rest}"); + } + } + + /// + /// Spells a type in Go. + /// + /// The type to spell. + /// The Go source for it. + /// + /// Go has one indirection and it is the pointer: there are no references, and nothing a type can + /// be that answers . What is left of + /// is "reached rather than copied", which is what a + /// pointer is — except where the type is already a view of something else. A string, a slice and + /// a map each hold a pointer already, so a pointer to one is a pointer to a pointer and nobody + /// means that. + /// + private static string SpellType(TypeReference type) + { + string core = SpellCoreType(type); + + return type.Indirection switch + { + TypeIndirection.Pointer => $"*{core}", + TypeIndirection.Reference => IsView(core) ? core : $"*{core}", + _ => core, + }; + } + + /// + /// Reports whether a type is already a view of what it holds. + /// + /// The type as Go spells it. + /// True when a pointer to it would be a pointer to a pointer. + private static bool IsView(string spelled) => + string.Equals(spelled, "string", StringComparison.Ordinal) + || spelled.StartsWith("[]", StringComparison.Ordinal) + || spelled.StartsWith("map[", StringComparison.Ordinal); + + /// + /// Spells a type without saying how it is reached. + /// + /// The type to spell. + /// The value form. + private static string SpellCoreType(TypeReference type) + { + string core = SpellTypeName(type); + return type.IsArray ? $"[]{core}" : core; + } + + /// + /// Spells a type's name and its arguments. + /// + /// The type whose name to spell. + /// The name as Go writes it. + /// + /// list and dict are the two names the AST has that Go spells out of its own + /// grammar rather than from a package: a sequence is a slice and a mapping is a map, and neither + /// is a type anybody imported. A container named without arguments is a container of the most + /// general thing there is, which is what the caller left unsaid. + /// + /// A type with arguments is written with square brackets, which is where Go put its generics and + /// the one place its spelling of a familiar thing surprises a reader of the others. + /// + /// + private static string SpellTypeName(TypeReference type) + { + string unknown = TypeMappings[UnknownTypeName]; + + if (string.Equals(type.Name, "list", StringComparison.OrdinalIgnoreCase)) + { + return $"[]{(type.TypeArguments.Count == 1 ? SpellType(type.TypeArguments[0]) : unknown)}"; + } + + if (string.Equals(type.Name, "dict", StringComparison.OrdinalIgnoreCase)) + { + return type.TypeArguments.Count == 2 + ? $"map[{SpellType(type.TypeArguments[0])}]{SpellType(type.TypeArguments[1])}" + : $"map[string]{unknown}"; + } + + string name = TypeMappings.TryGetValue(type.Name, out string? mapped) ? mapped : type.Name; + + return type.TypeArguments.Count == 0 + ? name + : $"{name}[{string.Join(", ", type.TypeArguments.Select(SpellType))}]"; + } +} diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 6f39a5c..5abfd18 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -20,13 +20,18 @@ namespace ktsu.Coder.Languages; public abstract class LanguageGeneratorBase : ILanguageGenerator { /// - /// The indentation one level of nesting adds. + /// Gets the indentation one level of nesting adds. /// /// /// Four spaces rather than 's tab: Python's /// indentation is syntax, and four spaces is what PEP 8 asks for. + /// + /// Overridable because one target does not get a say. Go is formatted by gofmt rather + /// than by whoever wrote the file, and gofmt indents with a tab — so a generated Go file + /// indented any other way is a diff against itself the first time anybody saves it. + /// /// - protected const string IndentString = " "; + protected virtual string IndentString => " "; /// /// Gets the unique identifier for this language generator. @@ -232,10 +237,16 @@ protected void GenerateSourceFile(SourceFile file, CodeBlocker code) bool wroteImport = false; foreach (string import in file.Imports) { - // An empty import is a group separator rather than an import of nothing. + // An empty import is a group separator rather than an import of nothing, and separates + // nothing until a group has been written — which is also what keeps a language whose + // imports are written elsewhere from getting a blank line for each of them here. if (import.Length == 0) { - code.NewLine(); + if (wroteImport) + { + code.NewLine(); + } + continue; } diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 68cd0ab..c815f12 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Languages; @@ -257,48 +257,12 @@ private void WriteModule(IReadOnlyList path, IReadOnlyCollection - /// Writes a run of declarations, separated as this language separates them. - /// - /// The declarations to write. - /// The writer to emit into. - private void WriteMembers(IReadOnlyCollection members, CodeBlocker code) - { - AstNode? previous = null; - foreach (AstNode member in members) - { - if (previous is not null && NeedsSeparation(previous, member)) - { - code.NewLine(); - } - - previous = member; - GenerateInternal(member, code); - } - } - /// /// /// Two of a kind that say nothing about themselves stay together, which keeps a run of type /// aliases or of constants reading as one block rather than as a paragraph each. /// - protected override bool NeedsSeparation(AstNode previous, AstNode member) - { - Ensure.NotNull(previous); - Ensure.NotNull(member); - - return previous.GetType() != member.GetType() - || IsDocumented(previous) - || IsDocumented(member); - } - - /// - /// Reports whether a declaration carries documentation. - /// - /// The declaration to test. - /// True when it does. - private static bool IsDocumented(AstNode member) => - member is IHasDocumentation documented && documented.Documentation.Count > 0; + protected override bool NeedsSeparation(AstNode previous, AstNode member) => !GroupsWith(previous, member); /// /// diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index 87c297f..063b374 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -151,20 +151,71 @@ protected virtual void GenerateNamespaceDeclaration(NamespaceDeclaration namespa Ensure.NotNull(code); GenerateDocumentation(namespaceDecl, code); + WriteMembers(namespaceDecl.Members, code); + } - bool first = true; - foreach (AstNode member in namespaceDecl.Members) + /// + /// Writes a run of declarations, separated as this language separates them. + /// + /// The declarations to write. + /// The writer to emit into. + /// + /// The same walk does over a file's + /// members, for the emitters that have a run of declarations to write and no file around them. + /// + protected void WriteMembers(IEnumerable members, CodeBlocker code) + { + Ensure.NotNull(members); + Ensure.NotNull(code); + + AstNode? previous = null; + foreach (AstNode member in members) { - if (!first) + if (previous is not null && NeedsSeparation(previous, member)) { code.NewLine(); } - first = false; + previous = member; GenerateInternal(member, code); } } + /// + /// Reports whether two adjacent declarations belong in one block. + /// + /// The declaration already written. + /// The declaration about to be written. + /// True when no blank line belongs between them. + /// + /// Two of a kind that say nothing about themselves stay together, which is what keeps a run of + /// aliases, of constants or of assertions about one type reading as one block rather than as a + /// paragraph each. A documented member needs air above it or its first comment line butts against + /// the member before it and reads as belonging to that one. + /// + /// The rule rather than the override, because is + /// the question and this is one answer to it: the C family, Rust and Go all give this one, while Python and + /// JavaScript keep the default of a blank line between everything. + /// + /// + protected static bool GroupsWith(AstNode previous, AstNode member) + { + Ensure.NotNull(previous); + Ensure.NotNull(member); + + return previous.GetType() == member.GetType() + && !IsDocumented(previous) + && !IsDocumented(member); + } + + /// + /// Reports whether a declaration carries documentation. + /// + /// The declaration to test. + /// True when it does. + protected static bool IsDocumented(AstNode member) => + member is IHasDocumentation documented && documented.Documentation.Count > 0; + /// /// Emits something that must be true when the program is built. /// @@ -324,7 +375,7 @@ protected void WriteElementList( return; } - code.Write($"{open} "); + code.Write($"{open}{ListPadding}"); for (int index = 0; index < construction.Arguments.Count; index++) { if (index > 0) @@ -335,9 +386,19 @@ protected void WriteElementList( WriteListElement(construction.Arguments[index], code); } - code.Write($" {close}"); + code.Write($"{ListPadding}{close}"); } + /// + /// Gets what stands between a list's delimiters and its elements when it is written on one line. + /// + /// + /// A space everywhere but Go, which writes Point{X: 1}. That is not a preference there: + /// gofmt writes it that way and nobody gets a say, which makes the padding part of the + /// language's spelling of a list rather than of anyone's taste in them. + /// + protected virtual string ListPadding => " "; + /// /// Writes a list one element per line. /// @@ -420,6 +481,80 @@ private static bool SpansLines(ConstructionExpression construction) => construction.Arguments.Any(argument => argument is ConstructionExpression or MemberInitialiser { Value: ConstructionExpression }); + /// + /// Names every operator the AST can spell, for a language that cannot overload one and so has to + /// call it something. + /// + /// How the language writes a name made of words. + /// The name for each symbol the AST can spell. + /// + /// The names come from the AST's own operator vocabulary rather than from a table written beside + /// it, so an operator added to is named without anyone remembering + /// to name it — and one added without a spelling is left out rather than throwing before anything + /// has run. + /// + /// A symbol both kinds of operator share is named for the binary one, which is what a reader of + /// a - b means; the unary declaration of it is told apart by taking no operand beside the + /// instance, which is the caller's to notice. + /// + /// + protected static Dictionary BuildOperatorNames(Func spell) + { + Ensure.NotNull(spell); + + Dictionary names = new(StringComparer.Ordinal); + + foreach (BinaryOperator op in Enum.GetValues().Where(HasSymbol)) + { + names[OperatorSymbols.GetSymbol(op)] = spell(op.ToString()); + } + + foreach (UnaryOperator op in Enum.GetValues().Where(HasSymbol)) + { + names.TryAdd(OperatorSymbols.GetSymbol(op), spell(op.ToString())); + } + + return names; + } + + /// + /// Names the unary operators alone. + /// + /// How the language writes a name made of words. + /// The name for each symbol a unary operator can be spelled with. + /// + /// For a language that tells a unary declaration apart by its arity and so must not name it after + /// the binary operator sharing its symbol. - is the whole of the problem: a type declaring + /// both would otherwise declare the same name twice, which is the one outcome worse than an odd + /// name. + /// + protected static Dictionary BuildUnaryOperatorNames(Func spell) + { + Ensure.NotNull(spell); + + Dictionary names = new(StringComparer.Ordinal); + + foreach (UnaryOperator op in Enum.GetValues().Where(HasSymbol)) + { + names[OperatorSymbols.GetSymbol(op)] = spell(op.ToString()); + } + + return names; + } + + /// + /// Reports whether the AST can spell an operator at all. + /// + /// The operator to test. + /// True when it has a symbol. + private static bool HasSymbol(BinaryOperator op) => + OperatorSymbols.TryGetSymbol(op, out string? symbol) && symbol is not null; + + /// + /// The operator to test. + private static bool HasSymbol(UnaryOperator op) => + OperatorSymbols.TryGetSymbol(op, out string? symbol) && symbol is not null; + /// /// Spells a binary operator in the target language. /// diff --git a/Coder/ServiceCollectionExtensions.cs b/Coder/ServiceCollectionExtensions.cs index 1775b61..ead643d 100644 --- a/Coder/ServiceCollectionExtensions.cs +++ b/Coder/ServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static IServiceCollection AddLanguageGenerators(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/README.md b/README.md index 49286a4..1bbaa68 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ This makes it ideal for code generation tools, transpilers, and any application - **LiteralExpression**: Typed literals (string, int, bool, double) - **AstLeafNode**: Generic leaf nodes for literals (strings, numbers, booleans) -Every operator in `UnaryOperator` and `BinaryOperator` exists in all six target languages, so no +Every operator in `UnaryOperator` and `BinaryOperator` exists in all seven target languages, so no AST built from them is untranslatable. Only the spelling varies — Python's `not`/`and`/`or`, -JavaScript's strict `===`/`!==` — and each generator overrides just the operators it spells -differently. +JavaScript's strict `===`/`!==`, Go's `^` for bitwise complement — and each generator overrides just +the operators it spells differently. Increment and decrement are deliberately absent from `UnaryOperator`: Python has no spelling for them, and an `AssignmentStatement` with `AssignmentOperator.AddAssign` expresses the same effect in @@ -67,6 +67,7 @@ rather than the modifier's text because no two languages spell visibility the sa | C++ | An access label (`public:`, `protected:`, `private:`) the members are grouped under; `Internal` becomes `public:` | | C | Nothing inside a struct, which has no access control; a `Private` declaration at file scope is `static`, which is the internal linkage C has instead | | Rust | `pub`, or `pub(crate)` for `Internal` and `Protected`; `Private` writes nothing, which is already Rust's default. `Unspecified` is `pub`, since a generated type nothing outside the module can read is not what saying nothing asked for | +| Go | Nothing. Go exports a name whose first letter is a capital and has no keyword at all, so a declaration whose name disagrees with what it asked for gets a note — renaming it would not rename the references to it. `Internal` is exactly Go's unexported, and `Private` and `Protected` are as near as there is | | JavaScript | A private class member takes the `#` prefix, which is JavaScript's own private syntax; nothing for the rest | | Python | Nothing — Python has no access modifiers, and its leading-underscore convention renames the declaration rather than modifying it | @@ -84,13 +85,20 @@ a declaration that is *not* constant is written `let mut` — the warning an unn better outcome than the error a missing one causes. At module scope the flag picks between a `const`, which is substituted wherever it is named, and a `static`, which is one object with an address. +Go is the one target that cannot always honour it. A Go `const` holds a number, a string or a boolean +the compiler worked out and nothing else — never a struct, never a slice — so a declaration starting +at one of those is written `var` with a note saying why. That is the language's meaning of constant +rather than a gap in it, and it is why a generated table there is a `var`. + An `EntryPoint` holds the statements a program runs. Each generator writes the spelling its language looks for: C#'s `static Main`, C++'s free `int main`, C's `int main(void)` — an empty parameter list in C declares a function whose parameters are unspecified rather than one that takes none — Python's `main` with the `__main__` guard that calls it (and the `import sys` its arguments and exit code need), JavaScript's `main` with the call that runs it, and Rust's `fn main`, which takes no arguments and returns nothing, so a program wanting either reaches for `std::env::args` and -`std::process::exit`. +`std::process::exit`. Go's `main` is the same shape as Rust's and reaches `os.Args` and `os.Exit` — +and because Go has no way to name a package without importing it, and no way to leave an import +unused, the generator adds `"os"` to the file's import block exactly when it is about to be used. ### Visual graph editor @@ -184,6 +192,7 @@ directly. | `cpp` | `CppGenerator` | `cpp` | Mapped type spellings (`str` → `std::string`); `auto` for inferred declarations; access labels, `static constexpr` members and a terminating `;` on a class | | `c` | `CGenerator` | `c` | `typedef struct` for every kind of type; a member function is a free `Type_name(Type* self, …)`; an interface is a struct of function pointers; a base type is the first member; enumeration members are qualified by their enumeration; `_Static_assert`, `main(void)`, and `static const` for a constant | | `rust` | `RustGenerator` | `rs` | A `struct` for the data and an `impl` block for the behaviour; an interface is a `trait` and a base type on one is a supertrait; a destructor is `impl Drop`, an operator is its `std::ops` trait, a conversion is `impl From`, and a specialisation is `impl Trait for Type`; `#[repr]`, `#[must_use]`, `const fn`, and `const _: () = assert!(…)` | +| `go` | `GoGenerator` | `go` | A `struct` and its methods beside it; a base type is an embedded field and an interface is an `interface` nothing declares it implements; a value receiver where a member promises not to modify and a pointer receiver where it does; a constructor is `NewType`, a destructor is `Close`, an operator is a method named for what it does; `iota` constants for an enumeration, and a duplicate map key for a compile-time assertion. Output is already what `gofmt` would write | ## Installation diff --git a/docs/design.md b/docs/design.md index d4802fb..f894106 100644 --- a/docs/design.md +++ b/docs/design.md @@ -60,9 +60,12 @@ functionDeclaration: * Abstract interface (`ILanguageGenerator`) for converting AST nodes into specific languages. * Base implementation (`LanguageGeneratorBase`) provides common functionality. - * Four generators, each with proper indentation and language-appropriate type handling: - Python (type hints), C#, JavaScript (untyped, strict equality), and C++ (mapped type spellings). - * Dependency injection configuration (`ServiceCollectionExtensions`) registers all six. + * Seven generators, each with proper indentation and language-appropriate type handling: + Python (type hints), C#, JavaScript (untyped, strict equality), C++ (mapped type spellings), C + (`typedef struct` and free functions taking the instance), Rust (a `struct`, an `impl` block and + a trait per operator) and Go (a `struct`, methods beside it, and output `gofmt` already agrees + with). + * Dependency injection configuration (`ServiceCollectionExtensions`) registers all seven. 4. **Applications** ✅ **IMPLEMENTED**: @@ -184,8 +187,8 @@ public static class ServiceCollectionExtensions ``` A generator is reached by resolving `IEnumerable` and selecting on `LanguageId` -(`python`, `csharp`, `javascript`, `cpp`), so adding a language is one registration and no caller -changes. +(`python`, `csharp`, `javascript`, `cpp`, `c`, `rust`, `go`), so adding a language is one +registration and no caller changes. ## Current Workflow Example ✅ **WORKING** @@ -228,7 +231,7 @@ string pythonCode = pythonGenerator.Generate(astLoaded); 3. **Expression system** — function calls (binary operators are implemented) ### Medium Priority -4. **Further language generators** beyond the six that exist +4. **Further language generators** beyond the seven that exist 5. **Error handling and validation** improvements 6. **Performance optimization** and benchmarking @@ -239,6 +242,6 @@ string pythonCode = pythonGenerator.Generate(astLoaded); ## Conclusion -This .NET-based design has successfully implemented a robust, flexible foundation for AST-based code generation. The core infrastructure adheres to SOLID principles and provides working serialization and code generation for six target languages. The CLI and TUI applications demonstrate practical usage. +This .NET-based design has successfully implemented a robust, flexible foundation for AST-based code generation. The core infrastructure adheres to SOLID principles and provides working serialization and code generation for seven target languages. The CLI and TUI applications demonstrate practical usage. The next development phase should focus on completing the dependency injection infrastructure and expanding the AST node types to support more complex code structures. From d668e02b91fcec7a721aae7f1a737ceca5a0b2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 09:40:24 +0000 Subject: [PATCH 2/3] refactor: map the declarations rather than the loop variable [patch] CodeQL's "missed opportunity to use Select": the loop bound a declaration only to generate from it, so it now iterates what it was after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D7Exu6cR3QLkHY5F8uSks7 --- Coder.Test/Languages/GoGeneratorTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Coder.Test/Languages/GoGeneratorTests.cs b/Coder.Test/Languages/GoGeneratorTests.cs index 6dc6e4b..bda4405 100644 --- a/Coder.Test/Languages/GoGeneratorTests.cs +++ b/Coder.Test/Languages/GoGeneratorTests.cs @@ -718,9 +718,8 @@ public void VisibilityTheNameAgreesWith_IsNotMentioned() ClassDeclaration hidden = new("marker") { Visibility = Visibility.Internal }; ClassDeclaration unsaid = new("marker"); - foreach (ClassDeclaration declaration in new[] { shown, hidden, unsaid }) + foreach (string generated in new[] { shown, hidden, unsaid }.Select(Generator.Generate)) { - string generated = Generator.Generate(declaration); Assert.IsFalse(generated.Contains("//", StringComparison.Ordinal), generated); } } From 27f9fa7dc1ff6d303991d91b0de54ffc7ae351d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 10:05:53 +0000 Subject: [PATCH 3/3] refactor: address the SonarCloud findings this PR introduced [patch] The quality gate passed, but three of the sixty-three are worth not leaving behind. S3776, reported as critical, is mine: GenerateSourceFile took the guard that stops a language whose imports are written elsewhere from getting a blank line per import, and that pushed its cognitive complexity to 18. What a file depends on is a thing of its own, so it is now a method of its own and the four steps of writing a file read as four steps. S1192 asked for a constant where `string` repeats, which it does six times in GoGenerator because the generator asks three different questions of it: which Go type a name maps to, whether a type is already a view of what it holds, and whether a conversion is the one the standard library prints a value with. The map key stays a literal - that one is the AST's name for a type rather than Go's spelling of it, and they only happen to agree. MSTEST0037 is the assertion that says what it means on a count, which this suite adopted while this branch was open. The sixty MSTEST0046 reports are left as they are, which is the same call the suite already made: it calls StringAssert.Contains in every one of its string assertions and has no bare Assert.Contains anywhere, so writing these the other way would read as a mistake rather than as an improvement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D7Exu6cR3QLkHY5F8uSks7 --- .../ServiceCollectionExtensionsTests.cs | 2 +- Coder/Languages/GoGenerator.cs | 24 ++++++++--- Coder/Languages/LanguageGeneratorBase.cs | 43 ++++++++++++------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/Coder.Test/ServiceCollectionExtensionsTests.cs b/Coder.Test/ServiceCollectionExtensionsTests.cs index 54cede5..0f90ce0 100644 --- a/Coder.Test/ServiceCollectionExtensionsTests.cs +++ b/Coder.Test/ServiceCollectionExtensionsTests.cs @@ -62,7 +62,7 @@ public void AddLanguageGenerators_ShouldRegisterEveryImplementedLanguage() Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "rust")); Assert.IsInstanceOfType(generators.FirstOrDefault(g => g.LanguageId == "go")); - Assert.AreEqual(7, generators.Count, "A new generator needs a registration and an entry here"); + Assert.HasCount(7, generators, "A new generator needs a registration and an entry here"); } /// diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index d795496..b4a0280 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -104,10 +104,20 @@ public class GoGenerator : StandardLanguageGenerator /// private const string RuntimePackage = "\"os\""; + /// + /// How Go spells a string. + /// + /// + /// Named because the generator asks three different questions of it: which Go type a name maps + /// to, whether a type is already a view of what it holds, and whether a conversion is the one the + /// standard library prints a value with. + /// + private const string StringTypeName = "string"; + private static readonly Dictionary TypeMappings = new(StringComparer.OrdinalIgnoreCase) { - { "str", "string" }, - { "string", "string" }, + { "str", StringTypeName }, + { "string", StringTypeName }, { "int", "int" }, { "long", "int64" }, { "float", "float32" }, @@ -869,7 +879,9 @@ private static string OperatorName(FunctionDeclaration funcDecl) private static string ConversionName(TypeReference? target) { string spelled = SpellType(target ?? new TypeReference(UnknownTypeName)); - return string.Equals(spelled, "string", StringComparison.Ordinal) ? "String" : $"To{Identifier(spelled)}"; + return string.Equals(spelled, StringTypeName, StringComparison.Ordinal) + ? "String" + : $"To{Identifier(spelled)}"; } /// @@ -1444,7 +1456,7 @@ private static string BranchType(ConditionalExpression conditional) => /// The type as Go writes it, or null where the value does not say. private static string? TypeOfValue(AstNode value) => value switch { - LiteralExpression or AstLeafNode => "string", + LiteralExpression or AstLeafNode => StringTypeName, LiteralExpression or AstLeafNode => "int", LiteralExpression or AstLeafNode => "bool", LiteralExpression => "float64", @@ -1669,7 +1681,7 @@ private static string SpellType(TypeReference type) /// The type as Go spells it. /// True when a pointer to it would be a pointer to a pointer. private static bool IsView(string spelled) => - string.Equals(spelled, "string", StringComparison.Ordinal) + string.Equals(spelled, StringTypeName, StringComparison.Ordinal) || spelled.StartsWith("[]", StringComparison.Ordinal) || spelled.StartsWith("map[", StringComparison.Ordinal); @@ -1712,7 +1724,7 @@ private static string SpellTypeName(TypeReference type) { return type.TypeArguments.Count == 2 ? $"map[{SpellType(type.TypeArguments[0])}]{SpellType(type.TypeArguments[1])}" - : $"map[string]{unknown}"; + : $"map[{StringTypeName}]{unknown}"; } string name = TypeMappings.TryGetValue(type.Name, out string? mapped) ? mapped : type.Name; diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index fc5969b..fbfeb1f 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -235,12 +235,37 @@ protected void GenerateSourceFile(SourceFile file, CodeBlocker code) code.NewLine(); } + WriteImports(file, code); + + AstNode? previous = null; + foreach (AstNode member in file.Members) + { + if (previous is not null && NeedsSeparation(previous, member)) + { + code.NewLine(); + } + + previous = member; + GenerateInternal(member, code); + } + } + + /// + /// Emits what a file depends on, and the blank line after it. + /// + /// The file being emitted. + /// The writer to emit into. + /// + /// A language with no import statement writes nothing at all here, including no blank lines: an + /// empty import is a group separator, and a separator between groups separates nothing until a + /// group has been written. + /// + private void WriteImports(SourceFile file, CodeBlocker code) + { bool wroteImport = false; + foreach (string import in file.Imports) { - // An empty import is a group separator rather than an import of nothing, and separates - // nothing until a group has been written — which is also what keeps a language whose - // imports are written elsewhere from getting a blank line for each of them here. if (import.Length == 0) { if (wroteImport) @@ -262,18 +287,6 @@ protected void GenerateSourceFile(SourceFile file, CodeBlocker code) { code.NewLine(); } - - AstNode? previous = null; - foreach (AstNode member in file.Members) - { - if (previous is not null && NeedsSeparation(previous, member)) - { - code.NewLine(); - } - - previous = member; - GenerateInternal(member, code); - } } ///