From 05ec3978cb66ff738d92270ae7f88e5cc838c5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 12:32:16 +0000 Subject: [PATCH 1/7] Make a node type that misses AstSchema or AstFields fail a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #49. Both files are hand-written switches ending in a silent default, which is the right shape for them — a node's structure is part of the library's contract and a reflective walk would start exposing whatever property was added next — but it means a node type left out of either produces no error, no warning and nothing failing. It produces a node that draws an empty inspector. So the production code stays hand-written and the test is the reflective one. `AstNodeCoverageTests` walks every concrete `AstNode` subclass in the assembly, closing the two generic ones over the four storage types the inspector matches on, and asks three things of each: that `AstSchema.SlotsOf` gives it slots or that it is on a `Childless` list saying it has none on purpose, that `AstFields.Of` offers a field when the type has a public settable property holding a value rather than a child, and that every field it offers can be written with a value it does not already hold and read back unchanged. Thirty node types, ninety cases, and a failure names the type. It found the three gaps that were already there: - `MemberInitialiser` had no `Name` field, so the node that says which member an argument is for could not be told which one. - `ConstructionExpression` had no `Type` field, so the one expression that names a type rather than a name could not be given it. With no type it is a braced list, which is a real state, so the field is allowed to be empty. - Neither was writable either, both switches having missed them together. `Childless` holds types rather than names, so renaming one is a compile error here rather than a stale entry. The one property excluded from "something to edit" is `Expression.ExpectedType`: every expression carries it and the inspector offers it on none of them, so counting it would demand a new field on every expression in the AST rather than catch the gap this is for. Excluding the property rather than the two nodes that have nothing else is deliberate — the reason is one property shared by every expression, not two nodes that happen to be bare. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 7 + Coder.Graph/AstFields.cs | 20 ++ Coder.Test/Graph/AstNodeCoverageTests.cs | 282 +++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 Coder.Test/Graph/AstNodeCoverageTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 5e905a2..d969a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,13 @@ source in seven target languages. The solution uses: 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 the editor's inspector draws and what makes those edits testable without a GPU. +- `Coder.Test/Graph/AstNodeCoverageTests.cs` — the reflective walk the two files above deliberately + are not. Both end in a silent default, so a node type left out of either draws an empty inspector + rather than failing anything; this reflects over every concrete `AstNode` subclass and asserts + that the schema has a decision about its children, that the inspector offers a field for + everything about it that is not a child, and that each field reads back what it is written. A + node with no children says so in the test's `Childless` list, so the exemption is a line somebody + wrote rather than an omission nobody noticed. - `Coder.Graph/AstGraph.cs` — the AST is the document, the graph is a view: every edit is applied to the AST and the engine graph rebuilt from it, preserving positions by node identity. diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index beb00fe..f20ed6d 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -108,6 +108,9 @@ public static class AstFields /// The name of the field a call exposes its callee through. private const string CalleeField = "Callee"; + /// The name of the field a node holding one type exposes. + private const string TypeField = "Type"; + private static readonly IReadOnlyList FunctionKinds = [ .. Enum.GetValues().Select(kind => new AstFieldChoice(kind.ToString(), kind.ToString())), @@ -168,6 +171,11 @@ public static IReadOnlyList Of(AstNode node) new(ValueField, AstFieldKind.Text, enumMember.Value ?? string.Empty), ], + MemberInitialiser initialiser => + [ + new("Name", AstFieldKind.Text, initialiser.Name ?? string.Empty), + ], + CompileTimeAssertion assertion => [ new("Condition", AstFieldKind.Text, assertion.Condition ?? string.Empty), @@ -280,6 +288,14 @@ private static IReadOnlyList OfExpression(AstNode node) new(CalleeField, AstFieldKind.Text, callExpr.Callee), ], + // The one expression that names a type rather than a name, which is why it could not + // exist before TypeReference did. With no type it is a braced list, so the field is + // allowed to be empty. + ConstructionExpression construction => + [ + new(TypeField, AstFieldKind.Text, construction.Type?.ToString() ?? string.Empty), + ], + BinaryExpression binary => [ new("Operator", AstFieldKind.Choice, binary.Operator.ToString(), OperatorChoices()), @@ -377,6 +393,8 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)), (EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)), + (MemberInitialiser initialiser, "Name") => Assign(() => initialiser.Name = OrNull(value)), + (CompileTimeAssertion assertion, "Condition") => Assign(() => assertion.Condition = OrNull(value)), (CompileTimeAssertion assertion, "Message") => Assign(() => assertion.Message = OrNull(value)), @@ -473,6 +491,8 @@ private static bool TryWriteExpression(AstNode node, string fieldName, string va (CallExpression callExpr, CalleeField) => value.Length > 0 && Assign(() => callExpr.Callee = value), + (ConstructionExpression construction, TypeField) => Assign(() => construction.Type = OrNull(value)), + (BinaryExpression binary, "Operator") => Enum.TryParse(value, out BinaryOperator binaryOp) && Assign(() => binary.Operator = binaryOp), (UnaryExpression unary, "Operator") => diff --git a/Coder.Test/Graph/AstNodeCoverageTests.cs b/Coder.Test/Graph/AstNodeCoverageTests.cs new file mode 100644 index 0000000..7d399ff --- /dev/null +++ b/Coder.Test/Graph/AstNodeCoverageTests.cs @@ -0,0 +1,282 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Graph; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using ktsu.Coder.Ast; +using ktsu.Coder.Graph; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Every concrete AST node type reaches the editor. +/// +/// +/// and are hand-written switches, deliberately: +/// a node's shape is part of the library's contract, and a reflective walk would start exposing +/// whatever property somebody added next, including the ones that are not children at all. The +/// cost of that choice is that both switches end in a silent default, so a node type omitted from +/// either produces no error, no warning and no failing test. It produces a node that draws an +/// empty inspector, which is how and +/// came to be missing from for a +/// while: they were added to the AST, the schema, the serializer and all six generators in one +/// run, and only the inspector was missed. +/// +/// So the production code stays hand-written and the test is the reflective one. It walks +/// every concrete node type in the AST assembly and asks three things of each: that the schema has +/// a decision about its children rather than a default, that the inspector offers a field for +/// everything about it that is not a child, and that each of those fields reads back what it is +/// written. A node type that satisfies none of them fails here by name. +/// +/// +/// "A decision rather than a default" is the one thing reflection cannot see for itself — an empty +/// slot list is what a leaf should have and also what a forgotten node gets — so +/// is where a node type says it has no children on purpose. It holds types +/// rather than names, so renaming one is a compile error here rather than a silently stale entry. +/// +/// +[TestClass] +public sealed class AstNodeCoverageTests +{ + /// + /// The storage types the two generic node types are closed over. + /// + /// + /// The same four matches on, which is what makes them the four that + /// exist as far as the editor is concerned. A fifth would have to be added in both places, and + /// adding it only here fails the field-coverage test. + /// + private static readonly Type[] StorageTypes = [typeof(string), typeof(int), typeof(double), typeof(bool)]; + + /// + /// The node types that hold no children, said rather than defaulted. + /// + /// + /// Each of these is a leaf in the AST's own terms: it names something, or it holds one value, + /// and what it says about the program is said entirely by its own fields. A node that turns out + /// to need a child later comes off this list at the same time as it gains its slot. + /// + private static readonly Type[] Childless = + [ + typeof(CompileTimeAssertion), + typeof(EnumMember), + typeof(Parameter), + typeof(UsingAlias), + typeof(VariableReference), + typeof(AstLeafNode<>), + typeof(LiteralExpression<>), + ]; + + /// + /// Gets every concrete node type, as MSTest data rows. + /// + /// One row per type, so a failure names the type rather than the loop. + public static IEnumerable NodeTypes() => Concrete().Select(type => new object[] { type }); + + /// + /// The schema has a decision about every node type's children. + /// + /// The node type under test. + [TestMethod] + [DynamicData(nameof(NodeTypes))] + public void EveryNodeTypeHasSlotsOrSaysItHasNone(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + bool declaredChildless = Childless.Contains(Definition(type)); + bool hasSlots = AstSchema.SlotsOf(New(type)).Count > 0; + + Assert.AreNotEqual( + declaredChildless, + hasSlots, + declaredChildless + ? $"{Spell(type)} is listed as childless here but AstSchema.SlotsOf gives it slots. " + + "Take it off the Childless list." + : $"{Spell(type)} gets no slots from AstSchema.SlotsOf, so the editor cannot connect " + + "anything to it. Add it to the switch, or list it in Childless here if it really " + + "holds nothing."); + } + + /// + /// The inspector offers a field for every node type that has something to edit. + /// + /// The node type under test. + /// + /// "Something to edit" is a public settable property holding a value rather than a child — + /// text, a flag, a number, an enumeration or a . A node with one of + /// those and no fields is one whose inspector is blank, which is the failure this exists to + /// catch. It does not check that every such property is offered: some are deliberately + /// not, and a count would be a restatement of the switch rather than a check on it. + /// + [TestMethod] + [DynamicData(nameof(NodeTypes))] + public void EveryNodeTypeWithSomethingToEditOffersAField(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (!Editable(type).Any()) + { + return; + } + + Assert.IsNotEmpty( + AstFields.Of(New(type)), + $"{Spell(type)} has editable properties ({string.Join(", ", Editable(type).Select(property => property.Name))}) " + + "but AstFields.Of returns nothing for it, so its inspector draws empty. Add it to the switch."); + } + + /// + /// Every field the inspector offers reads back what it is written. + /// + /// The node type under test. + /// + /// and are two switches over the + /// same set of fields, so a field added to one and not the other reads but does not write — it + /// looks editable and silently is not. Writing a value the field does not already hold is the + /// only way to tell: answers false for an edit that changes + /// nothing, deliberately, so that one does not reach the undo stack. + /// + [TestMethod] + [DynamicData(nameof(NodeTypes))] + public void EveryFieldTheInspectorOffersCanBeWritten(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + AstNode node = New(type); + + foreach (AstField field in AstFields.Of(node)) + { + string changed = Change(field); + + Assert.IsTrue( + AstFields.TryWrite(node, field.Name, changed), + $"{Spell(type)}.{field.Name} is offered by AstFields.Of but AstFields.TryWrite refused " + + $"'{changed}'. The two switches disagree about it."); + + Assert.AreEqual( + changed, + AstFields.Read(node, field.Name), + $"{Spell(type)}.{field.Name} did not read back what it was written."); + } + } + + /// + /// A value the field does not already hold, of a kind the field can take. + /// + /// The field to change. + /// The new value, as text. + /// + /// A choice picks any option other than the current one, so the test says nothing about which + /// option is which. The rest are the smallest change that is still well formed for the kind. + /// + private static string Change(AstField field) => field.Kind switch + { + // Lower case, because that is how the inspector spells a flag and the assertion is that + // the field reads back exactly what it was written rather than something that parses the + // same way. + AstFieldKind.Flag => bool.TryParse(field.Value, out bool flag) + ? (!flag).ToString().ToLowerInvariant() + : throw new InvalidOperationException($"'{field.Value}' is not a flag."), + + AstFieldKind.Number => (Parse(field.Value) + 1).ToString(CultureInfo.InvariantCulture), + + AstFieldKind.Fraction => (Parse(field.Value) + 0.5).ToString(CultureInfo.InvariantCulture), + + AstFieldKind.Choice => field.Choices + .Select(choice => choice.Value) + .FirstOrDefault(choice => !string.Equals(choice, field.Value, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"{field.Name} offers no option other than '{field.Value}'."), + + _ => field.Value + "Changed", + }; + + private static double Parse(string value) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : throw new InvalidOperationException($"'{value}' is not a number."); + + /// + /// Every concrete node type in the AST assembly, with the generic ones closed. + /// + /// The types, in a stable order. + /// + /// Read off 's own assembly rather than from a list, which is the whole + /// point: a node type that exists is in here whether or not anybody remembered it. + /// + private static IEnumerable Concrete() => + typeof(AstNode).Assembly + .GetTypes() + .Where(type => type.IsClass && !type.IsAbstract && type.IsPublic && typeof(AstNode).IsAssignableFrom(type)) + .SelectMany(Closed) + .OrderBy(Spell, StringComparer.Ordinal); + + /// + /// One node type, or its closures when it is generic. + /// + /// The type to close. + /// The constructible types it stands for. + private static IEnumerable Closed(Type type) => + type.IsGenericTypeDefinition + ? StorageTypes.Select(storage => type.MakeGenericType(storage)) + : [type]; + + private static Type Definition(Type type) => type.IsGenericType ? type.GetGenericTypeDefinition() : type; + + /// + /// Builds one, through the parameterless constructor every node type has. + /// + /// The type to build. + /// A new node. + /// + /// That every node type has one is itself part of the contract: the YAML deserializer builds a + /// node before it has read any of its properties, so a node type without one could not be read + /// back at all. A type that loses it fails here rather than at the first document that uses it. + /// + private static AstNode New(Type type) => + Activator.CreateInstance(type) as AstNode + ?? throw new InvalidOperationException($"{Spell(type)} has no parameterless constructor."); + + /// + /// The public settable properties that hold a value rather than a child. + /// + /// The type to look at. + /// The properties, which may be none. + /// + /// is excluded, and it is the only exclusion. It is a + /// hint for type checking that every expression carries and the inspector offers on none of + /// them; counting it would make this test demand a field on every expression in the AST, which + /// is a change to the editor's surface rather than the gap this exists to catch. Excluding the + /// property rather than the two node types that have nothing else is what keeps that on record: + /// the reason is one property shared by every expression, not two nodes that happen to be bare. + /// + private static IEnumerable Editable(Type type) => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.SetMethod is { IsPublic: true }) + .Where(property => property.DeclaringType != typeof(Expression)) + .Where(property => IsValue(property.PropertyType)); + + private static bool IsValue(Type type) + { + Type bare = Nullable.GetUnderlyingType(type) ?? type; + + return bare.IsEnum + || bare == typeof(string) + || bare == typeof(bool) + || bare == typeof(int) + || bare == typeof(double) + || bare == typeof(TypeReference); + } + + /// + /// A node type's name, with its type argument where it has one. + /// + /// The type to name. + /// A name a failure message can be read from, such as LiteralExpression<int>. + private static string Spell(Type type) => + type.IsGenericType + ? $"{type.Name[..type.Name.IndexOf('`', StringComparison.Ordinal)]}<{string.Join(", ", type.GetGenericArguments().Select(argument => argument.Name))}>" + : type.Name; +} From 748d0b393a4c758dff40cf1276f5ce9edd67f79f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 12:49:04 +0000 Subject: [PATCH 2/7] Teach a type declaration what it implements and what it promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [minor] `ClassDeclaration` carried a name, a kind and one base type. Four things a generated type routinely says about itself had nowhere to go: which interfaces it implements, whether the language should supply its value semantics, whether the rest of it may be declared elsewhere, and whether any member of it modifies it. `public readonly partial record struct Mass : IVector0, T>` needs all four, and none of them was reachable. `Interfaces` is separate from `BaseType` rather than one list, because what each target does with the two is different often enough that a generator handed one list would be guessing which entry was the class: - C# writes them in one list, base first, which is the order the language requires and the order a generator could not recover. - C++ writes `public` before each and does not distinguish them at all, an interface being a class whose members are pure virtual. - C embeds each as a member. The first position is what makes a pointer to the whole a pointer to the member, C has exactly one of those to give, so the base takes it and the interfaces after it are reached by address. The declaration says which one is first and why. - Rust makes both supertraits of a trait, which is the one place a target answers the question exactly. A struct's interfaces are written down instead: an impl block needs the bodies the declaration does not have. - Python takes them all as bases, having no separate notion of an interface. - JavaScript extends one thing, so the rest are written down. - Go embeds them in an interface, and for a struct writes `var _ Contract = (*Type)(nil)` — the language's own way to say it, and a check rather than a comment: the file stops compiling when the type stops implementing the interface. `GoGeneratedSourceCompilesTests` now declares that `Circle` implements `Shape` and compiles the result, so the claim is one that test can break. The three modifiers split along a line worth stating once. `IsRecord` and `IsReadOnly` are claims about the type — it compares by value, no member of it modifies it — so a target with no word for one writes it down, the same as a `CompileTimeAssertion`. Rust has a word for the first and uses it: `#[derive(Clone, Debug, PartialEq)]` is exactly what a record asks for. Python's `@dataclass` is the obvious answer and is deliberately not taken, because the decorator needs an import and a class is generated on its own as readily as inside a file whose imports the AST carries; emitting one would change how that generator writes a file rather than how it writes a class. `IsPartial` is the one dropped in silence, and the reason is what it says. It claims nothing about the type: it is permission to declare the rest of it in another file, and a generator that has written the whole declaration has not used that permission for anything a reader of this file could be missing. Twenty new tests over the seven targets, plus the YAML round trip and the clone. The interfaces are serialized as a sequence rather than one joined string, for the reason the specialisation arguments already are: an interface can have type arguments, so a comma inside one is part of it as often as it separates two — which is also why both lists now read through one `DeserializeTypeList`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 19 + Coder.Graph/AstFields.cs | 9 + .../GoGeneratedSourceCompilesTests.cs | 10 +- .../Languages/TypeDeclarationShapeTests.cs | 359 ++++++++++++++++++ Coder/Ast/ClassDeclaration.cs | 72 +++- Coder/Languages/CGenerator.cs | 47 ++- Coder/Languages/CSharpGenerator.cs | 31 +- Coder/Languages/CppGenerator.cs | 16 +- Coder/Languages/GoGenerator.cs | 52 ++- Coder/Languages/JavaScriptGenerator.cs | 11 + Coder/Languages/LanguageGeneratorBase.cs | 36 ++ Coder/Languages/PythonGenerator.cs | 20 +- Coder/Languages/RustGenerator.cs | 38 +- Coder/Serialization/YamlDeserializer.cs | 35 +- Coder/Serialization/YamlSerializer.cs | 24 ++ 15 files changed, 746 insertions(+), 33 deletions(-) create mode 100644 Coder.Test/Languages/TypeDeclarationShapeTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d969a1d..6f9afe9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,25 @@ source in seven target languages. The solution uses: 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 `Interfaces`, `IsRecord`, `IsPartial` and `IsReadOnly` — what a + type declaration says about itself beyond its name. `Interfaces` is separate from `BaseType` + rather than folded into one list, because what a target does with the two differs: C# writes them + in one list but takes at most one class in it and puts it first, C++ writes `public` before each + and does not distinguish them at all, Rust makes both supertraits of a trait and has no answer for + a struct's at all, C can give the first-member position — the one that makes a pointer to the + whole a pointer to the member — to exactly one of them, and Go satisfies an interface structurally + and so writes `var _ Contract = (*Type)(nil)`, an assertion the compiler checks rather than a + declaration. A generator handed one list would be guessing which entry was the class. + The three modifiers split along a line worth stating once: `IsRecord` and `IsReadOnly` are claims + about the type — it compares by value, no member of it modifies it — so a target with no word for + one writes it down, the same as `CompileTimeAssertion`, while Rust's `#[derive(Clone, Debug, + PartialEq)]` is a word for the first and is used. `IsPartial` claims nothing about the type; it is + permission to declare the rest of it elsewhere, and a generator that has written the whole + declaration has not used the permission for anything a reader could miss, so it is dropped in + silence. Python's `@dataclass` is the obvious answer for a record and is *not* taken: the + decorator needs an import, and a class is generated on its own as readily as inside a file whose + imports the AST carries, so emitting one would change how that generator writes a file rather than + how it writes a class. - `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 diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index f20ed6d..0d75e2a 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -204,6 +204,9 @@ public static IReadOnlyList Of(AstNode node) new("Kind", AstFieldKind.Choice, classDecl.Kind.ToString(), TypeKinds), new("BaseType", AstFieldKind.Text, classDecl.BaseType?.ToString() ?? string.Empty), new(VisibilityField, AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities), + new("Record", AstFieldKind.Flag, Spell(classDecl.IsRecord)), + new("Partial", AstFieldKind.Flag, Spell(classDecl.IsPartial)), + new("ReadOnly", AstFieldKind.Flag, Spell(classDecl.IsReadOnly)), ], _ => OfCallable(node), @@ -418,6 +421,12 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (ClassDeclaration classDecl, "BaseType") => Assign(() => classDecl.BaseType = OrNull(value)), (ClassDeclaration classDecl, VisibilityField) => TryParseVisibility(value, out Visibility classVisibility) && Assign(() => classDecl.Visibility = classVisibility), + (ClassDeclaration classDecl, "Record") => + TryParseBool(value, out bool isRecord) && Assign(() => classDecl.IsRecord = isRecord), + (ClassDeclaration classDecl, "Partial") => + TryParseBool(value, out bool isPartial) && Assign(() => classDecl.IsPartial = isPartial), + (ClassDeclaration classDecl, "ReadOnly") => + TryParseBool(value, out bool isClassReadOnly) && Assign(() => classDecl.IsReadOnly = isClassReadOnly), (FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)), (FunctionDeclaration function, "ReturnType") => Assign(() => function.ReturnType = OrNull(value)), diff --git a/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs index 7369db9..ae8e517 100644 --- a/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs @@ -29,7 +29,10 @@ namespace ktsu.Coder.Test.Languages; /// 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. +/// is the whole of what Go has in place of inheritance. The first of those is now asserted in the +/// generated file itself — var _ Shape = (*Circle)(nil), which the compiler checks — so a +/// declaration saying Circle implements Shape is a claim this test can break rather +/// than a comment it cannot. /// /// /// The test is inconclusive rather than failing where no toolchain is on the path, which is the @@ -142,6 +145,11 @@ private static SourceFile Exemplar() ClassDeclaration circle = new("Circle") { BaseType = "Point" }; circle.Documentation.Add("A shape with one radius."); + + // The assertion the generator writes for this is the point of declaring it: Circle satisfies + // Shape by having the methods the driver supplies, and Go would otherwise have nothing in the + // file that says so and nothing that fails when one of them is renamed. + circle.Interfaces.Add(TypeReference.Parse("Shape")); circle.Members.Add(new VariableDeclaration("radius", "double")); circle.Members.Add(Released()); diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs new file mode 100644 index 0000000..02fff37 --- /dev/null +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -0,0 +1,359 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using System.Linq; +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using ktsu.CodeBlocker; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// What a type declaration says about itself beyond its name, in each target. +/// +/// +/// Four things: the interfaces it implements, and whether the language supplies its value +/// semantics, whether the rest of it may be declared elsewhere, and whether any member of it +/// modifies it. Each target answers all four, and the answers are what is pinned here — including +/// the ones that are a comment, because "this language has no word for it" is a decision somebody +/// made rather than something that fell out. +/// +/// The interfaces are the interesting half. Every target has a different amount of the idea: C++ +/// does not distinguish an interface from a base class at all, C can give the first-member position +/// to only one of them, Rust answers it exactly for a trait and not at all for a struct, and Go +/// satisfies one structurally and so writes an assertion rather than a declaration. +/// +/// +[TestClass] +public class TypeDeclarationShapeTests +{ + private static string NewLine => CodeBlocker.DefaultNewLineString; + + /// + /// A type implementing two interfaces and deriving from a base. + /// + /// What kind of type it declares. + /// The declaration. + private static ClassDeclaration Widget(TypeDeclarationKind kind = TypeDeclarationKind.Class) + { + ClassDeclaration widget = new("Widget") { Kind = kind, BaseType = "Control" }; + widget.Interfaces.Add(TypeReference.Parse("Drawable")); + widget.Interfaces.Add(TypeReference.Parse("Clickable")); + return widget; + } + + /// + /// A type asking for all three modifiers at once. + /// + /// The declaration. + private static ClassDeclaration Money() => + new("Money") + { + Kind = TypeDeclarationKind.Struct, + IsRecord = true, + IsPartial = true, + IsReadOnly = true, + }; + + private static string Generate(ILanguageGenerator generator, AstNode node) => generator.Generate(node); + + // ------------------------------------------------------------------ Interfaces + + /// + /// C# writes the base and the interfaces in one list, base first, which is the order the + /// language requires and the reason the AST keeps the two apart. + /// + [TestMethod] + public void CSharp_WritesOneInheritanceListWithTheBaseFirst() + { + string code = Generate(new CSharpGenerator(), Widget()); + + StringAssert.Contains(code, "class Widget : Control, Drawable, Clickable"); + } + + /// + /// C++ makes every entry public, and does not distinguish an interface from a base. + /// + /// + /// Public rather than the default, which for a class is private: a base nobody outside + /// could use the type through is not what the declaration asked for. + /// + [TestMethod] + public void Cpp_InheritsPubliclyFromEveryOneOfThem() + { + string code = Generate(new CppGenerator(), Widget()); + + StringAssert.Contains(code, "class Widget : public Control, public Drawable, public Clickable"); + } + + /// + /// C embeds each as a member, and says which one holds the position that makes a pointer to the + /// whole a pointer to it. + /// + [TestMethod] + public void C_EmbedsEachAndSaysWhichOneIsLayoutCompatible() + { + string code = Generate(new CGenerator(), Widget()); + + StringAssert.Contains(code, "base is first, so that a pointer to this is a pointer to it"); + StringAssert.Contains(code, "the rest are reached by taking their address"); + StringAssert.Contains(code, $"Control base;{NewLine}"); + StringAssert.Contains(code, $"Drawable drawable;{NewLine}"); + StringAssert.Contains(code, $"Clickable clickable;{NewLine}"); + } + + /// + /// A trait takes all of them as supertraits, which is the one place a target answers the + /// question exactly rather than working around it. + /// + [TestMethod] + public void Rust_MakesEveryInterfaceOnATraitASupertrait() + { + string code = Generate(new RustGenerator(), Widget(TypeDeclarationKind.Interface)); + + StringAssert.Contains(code, "trait Widget: Control + Drawable + Clickable"); + } + + /// + /// A struct's interfaces are written down rather than implemented, because an impl block needs + /// the bodies the declaration does not have. + /// + [TestMethod] + public void Rust_SaysWhatAStructImplementsRatherThanImplementingIt() + { + string code = Generate(new RustGenerator(), Widget()); + + StringAssert.Contains(code, "// implements Drawable, Clickable"); + StringAssert.Contains(code, "which this declaration does not say how to fill"); + } + + /// + /// Python has one list of bases and no separate notion of an interface. + /// + [TestMethod] + public void Python_TakesThemAllAsBases() + { + string code = Generate(new PythonGenerator(), Widget()); + + StringAssert.Contains(code, "class Widget(Control, Drawable, Clickable):"); + } + + /// + /// JavaScript extends one thing and has no interfaces, so it writes down what it cannot say. + /// + [TestMethod] + public void JavaScript_WritesDownWhatItCannotExtend() + { + string code = Generate(new JavaScriptGenerator(), Widget()); + + StringAssert.Contains(code, "// implements Drawable, Clickable"); + StringAssert.Contains(code, "class Widget extends Control"); + } + + /// + /// A Go interface embeds the others by name, which means every method of each. + /// + [TestMethod] + public void Go_EmbedsEveryInterfaceInAnInterface() + { + string code = Generate(new GoGenerator(), Widget(TypeDeclarationKind.Interface)); + + StringAssert.Contains(code, $"Control{NewLine}"); + StringAssert.Contains(code, $"Drawable{NewLine}"); + StringAssert.Contains(code, $"Clickable{NewLine}"); + } + + /// + /// A Go struct asserts what it implements rather than declaring it, because Go satisfies an + /// interface by having the methods and never says so. + /// + /// + /// The pointer form is the one that always holds: a method on the pointer receiver is not in + /// the value's method set, and one on the value is in both. + /// + [TestMethod] + public void Go_AssertsWhatAStructImplements() + { + string code = Generate(new GoGenerator(), Widget()); + + StringAssert.Contains(code, "var _ Drawable = (*Widget)(nil)"); + StringAssert.Contains(code, "var _ Clickable = (*Widget)(nil)"); + } + + /// + /// A declaration with no interfaces writes nothing about them anywhere. + /// + /// + /// Over every generator at once, because "nothing to say" is the common case and a note that + /// appeared on every type in a file would be worse than the gap it filled. + /// + [TestMethod] + public void EveryTarget_SaysNothingWhenThereAreNoInterfaces() + { + ILanguageGenerator[] generators = + [ + new CSharpGenerator(), + new CppGenerator(), + new CGenerator(), + new RustGenerator(), + new PythonGenerator(), + new JavaScriptGenerator(), + new GoGenerator(), + ]; + + foreach (ILanguageGenerator generator in generators) + { + string code = Generate(generator, new ClassDeclaration("Plain")); + + Assert.DoesNotContain("implements", code, $"{generator.DisplayName} wrote about interfaces it was not given."); + Assert.DoesNotContain("var _ ", code, $"{generator.DisplayName} asserted an interface it was not given."); + } + } + + // ------------------------------------------------------------------ Modifiers + + /// + /// C# is the one target with a word for all three, and writes them in the order it takes them. + /// + /// + /// record goes before the keyword rather than instead of it, which is what makes + /// record struct reachable at all. + /// + [TestMethod] + public void CSharp_WritesAllThreeModifiers() + { + string code = Generate(new CSharpGenerator(), Money()); + + StringAssert.Contains(code, "public readonly partial record struct Money"); + } + + /// + /// Rust asks for the record's members with derive, which is what derive is for. + /// + [TestMethod] + public void Rust_DerivesWhatARecordAsksFor() + { + string code = Generate(new RustGenerator(), Money()); + + StringAssert.Contains(code, "#[derive(Clone, Debug, PartialEq)]"); + Assert.DoesNotContain("// record:", code, "Rust said it in the derive; it should not also apologise for it."); + } + + /// + /// A target with no word for a promise writes the promise down. + /// + /// The generator to ask. + /// + /// Both of these are claims about the type — it compares by value, no member of it modifies it — + /// so a file that dropped one would look like a file that still made it. + /// + [TestMethod] + [DataRow("cpp")] + [DataRow("c")] + [DataRow("python")] + [DataRow("javascript")] + [DataRow("go")] + public void TargetsWithNoWordForThem_WriteThePromisesDown(string language) + { + ILanguageGenerator generator = language switch + { + "cpp" => new CppGenerator(), + "c" => new CGenerator(), + "python" => new PythonGenerator(), + "javascript" => new JavaScriptGenerator(), + _ => new GoGenerator(), + }; + + string code = Generate(generator, Money()); + + StringAssert.Contains(code, "record: compares by value, and copies and prints itself"); + StringAssert.Contains(code, "readonly: no member of this type modifies it"); + } + + /// + /// Nothing writes a note about partial, which is the one modifier here that is dropped + /// in silence. + /// + /// + /// It claims nothing about the type. record and readonly say how the type behaves; + /// partial says the rest of it may be declared in another file, and a generator that has + /// written the whole declaration has not used that permission for anything a reader of this file + /// could be missing. + /// + [TestMethod] + public void Partial_IsDroppedInSilenceEverywhereButCSharp() + { + ClassDeclaration split = new("Split") { IsPartial = true }; + + ILanguageGenerator[] generators = + [ + new CppGenerator(), + new CGenerator(), + new RustGenerator(), + new PythonGenerator(), + new JavaScriptGenerator(), + new GoGenerator(), + ]; + + foreach (ILanguageGenerator generator in generators) + { + Assert.DoesNotContain( + "partial", + Generate(generator, split), + $"{generator.DisplayName} wrote about partial, which says nothing about the type."); + } + + StringAssert.Contains(Generate(new CSharpGenerator(), split), "public partial class Split"); + } + + // ------------------------------------------------------------------ Round trip + + /// + /// All four survive a trip through YAML and back. + /// + /// + /// The interfaces are written as a sequence rather than one joined string, for the reason the + /// specialisation arguments already are: an interface can have type arguments, so a comma + /// inside one is part of it as often as it separates two. + /// + [TestMethod] + public void Yaml_CarriesTheInterfacesAndTheModifiers() + { + ClassDeclaration original = Money(); + original.BaseType = "Value"; + original.Interfaces.Add(TypeReference.Parse("Comparable")); + original.Interfaces.Add(TypeReference.Parse("Formattable")); + + ktsu.Coder.Serialization.YamlSerializer serializer = new(); + ktsu.Coder.Serialization.YamlDeserializer deserializer = new(); + + AstNode? read = deserializer.Deserialize(serializer.Serialize(original)); + + ClassDeclaration restored = (ClassDeclaration)read!; + Assert.IsTrue(restored.IsRecord); + Assert.IsTrue(restored.IsPartial); + Assert.IsTrue(restored.IsReadOnly); + Assert.AreEqual("Value", restored.BaseType?.ToString()); + Assert.AreSequenceEqual( + (string[])["Comparable", "Formattable"], + [.. restored.Interfaces.Select(contract => contract.ToString())]); + } + + /// + /// A clone carries them too, which is what the editor's undo stack puts back. + /// + [TestMethod] + public void Clone_CarriesTheInterfacesAndTheModifiers() + { + ClassDeclaration original = Money(); + original.Interfaces.Add(TypeReference.Parse("Formattable")); + + ClassDeclaration clone = (ClassDeclaration)original.Clone(); + clone.Interfaces[0] = TypeReference.Parse("Something else"); + + Assert.IsTrue(clone.IsRecord); + Assert.IsTrue(clone.IsPartial); + Assert.IsTrue(clone.IsReadOnly); + Assert.AreEqual("Formattable", original.Interfaces[0].ToString(), "the clone shares the collection with its original."); + } +} diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs index b68b27c..50927c0 100644 --- a/Coder/Ast/ClassDeclaration.cs +++ b/Coder/Ast/ClassDeclaration.cs @@ -12,8 +12,11 @@ namespace ktsu.Coder.Ast; /// holds rather than a narrower type. Which members a /// target language will actually accept is the generator's business, not the AST's. /// -/// Only one base type is carried. Every language the generators target names a single base class in -/// its declaration syntax, and interfaces are a language feature the AST does not model yet. +/// A base type and a list of interfaces are separate because the languages separate them: C# writes +/// them in one list but takes at most one class in it, C++ writes public before each and does +/// not distinguish them at all, Rust turns the first into a supertrait and has nowhere to put the +/// rest, and C can make exactly one of them layout-compatible with the whole. Carrying one list +/// would leave every generator guessing which entry was the class. /// /// public class ClassDeclaration : AstCompositeNode, IHasVisibility, IHasDocumentation @@ -80,6 +83,63 @@ public ClassDeclaration() /// public bool IsSpecialisation => SpecialisationArguments.Count > 0; + /// + /// Gets the interfaces this type implements, which may be none. + /// + /// + /// Separate from rather than folded into one list, because what a target + /// does with the two is different often enough to matter. Rust makes a base type on a trait a + /// supertrait and has no answer for a struct's interfaces at all; C embeds the base as the first + /// member, which is the position that makes a pointer to the one a pointer to the other, and can + /// only give that position to one thing; Go satisfies an interface structurally, so declaring + /// one is an assertion rather than a declaration. Every one of those decisions needs to know + /// which entry is the class. + /// + public Collection Interfaces { get; init; } = []; + + /// + /// Gets or sets a value indicating whether the language should supply this type's value + /// semantics rather than the declaration spelling them out. + /// + /// + /// Not a fourth , because it is orthogonal to the three: C# + /// has a record and a record struct, and an enumeration would have to carry the + /// product of the two ideas rather than either of them. + /// + /// What it asks for is one thing — equality, a readable form and a copy, written by the + /// compiler rather than by hand — and three targets have a way to ask for exactly that: + /// record, Rust's #[derive(…)] and Python's @dataclass. The rest write a + /// comment, because a type that quietly stops comparing by value is a type that still looks + /// like it does. + /// + /// + public bool IsRecord { get; set; } + + /// + /// Gets or sets a value indicating whether the rest of this type may be declared elsewhere. + /// + /// + /// The one modifier here that is dropped in silence where it cannot be spelled, and the reason + /// is what it says. and are claims about the + /// type — it compares by value, it does not mutate — so a file that loses one looks like a file + /// that still makes it. partial claims nothing about the type; it is permission to + /// declare the rest of it in another file, and a generator that has written the whole + /// declaration has not used the permission for anything a reader could miss. + /// + public bool IsPartial { get; set; } + + /// + /// Gets or sets a value indicating whether no member of this type modifies it. + /// + /// + /// A promise about the type rather than about any one member, which is what distinguishes it + /// from : that one says a call does not modify the + /// receiver, and this says none of them does. Only C# has a word for it, so the others write a + /// comment — marking every member const in C++ would be the same promise made in a + /// different place, and would be wrong for a static one. + /// + public bool IsReadOnly { get; set; } + /// /// Gets or sets how widely the class is visible. /// @@ -107,6 +167,9 @@ public override AstNode Clone() Name = Name, Kind = Kind, BaseType = BaseType?.Clone(), + IsRecord = IsRecord, + IsPartial = IsPartial, + IsReadOnly = IsReadOnly, Visibility = Visibility }; @@ -115,6 +178,11 @@ public override AstNode Clone() clone.SpecialisationArguments.Add(argument.Clone()); } + foreach (TypeReference contract in Interfaces) + { + clone.Interfaces.Add(contract.Clone()); + } + foreach ((string key, object? value) in Metadata) { clone.Metadata[key] = value; diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 901e367..73701d1 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -65,6 +65,27 @@ public class CGenerator : CFamilyGenerator /// private const string BaseMemberName = "base"; + /// + /// What an embedded interface is called as a member. + /// + /// The interface being embedded. + /// The member's name. + /// + /// Its own name with the first letter lowered, which is what the member of a C struct usually + /// looks like and, more to the point, is derivable from the type by anyone reading the header: + /// a caller passing the object where the interface is wanted has to write + /// &object->drawable, and a name it could not have guessed would send it back to + /// the declaration every time. + /// + private static string MemberNameOf(TypeReference contract) + { + string name = contract.Name; + + return name.Length == 0 + ? "implemented" + : char.ToLowerInvariant(name[0]) + name[1..]; + } + private static readonly Dictionary TypeMappings = new(StringComparer.OrdinalIgnoreCase) { { "str", "const char*" }, @@ -484,6 +505,7 @@ .. classDecl.Members.Where(member => } GenerateDocumentation(classDecl, code); + WriteTypePromises(classDecl, code); code.WriteLine($"typedef struct {name}"); code.WriteLine("{"); @@ -491,12 +513,27 @@ .. classDecl.Members.Where(member => insideType++; - if (classDecl.BaseType is TypeReference baseType) + // A base and an interface are the same thing here: a struct embedded as a member, whose + // own members are reached through it. What the first position buys is that a pointer to the + // whole is a pointer to that member, so the two are interchangeable without a cast -- and C + // has exactly one first position to give, so the base takes it and an interface after it is + // reached by taking its address instead. + List<(TypeReference Type, string Member)> embedded = + [ + .. classDecl.BaseType is TypeReference baseType ? (List<(TypeReference, string)>)[(baseType, BaseMemberName)] : [], + .. classDecl.Interfaces.Select(contract => (contract, MemberNameOf(contract))), + ]; + + if (embedded.Count > 0) { - // First, and said so: the position is what makes the two layout-compatible, and a - // reader moving it would have no way to know that from the declaration alone. - WriteInexpressible(code, "the base, first so that a pointer to this is a pointer to it"); - code.WriteLine($"{SpellDeclarator(baseType, BaseMemberName)};"); + WriteInexpressible(code, embedded.Count == 1 + ? $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it" + : $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it; the rest are reached by taking their address"); + + foreach ((TypeReference embeddedType, string member) in embedded) + { + code.WriteLine($"{SpellDeclarator(embeddedType, member)};"); + } if (fields.Count > 0) { diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index a4a3928..4dfedeb 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -178,11 +178,32 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) _ => "class", }; - code.Write($"{SpellVisibility(classDecl.Visibility) ?? DefaultVisibility} {keyword} {classDecl.Name ?? "UnnamedClass"}"); - - if (classDecl.BaseType is TypeReference baseType) - { - code.Write($" : {MapToCSType(baseType)}"); + // In the order C# takes them: access, then the promise the type makes about itself, then + // the permission to declare the rest of it elsewhere, then what kind of type it is. A + // record is written before the keyword rather than instead of it, which is what makes + // `record struct` reachable. + string[] modifiers = + [ + SpellVisibility(classDecl.Visibility) ?? DefaultVisibility, + .. classDecl.IsReadOnly ? (string[])["readonly"] : [], + .. classDecl.IsPartial ? (string[])["partial"] : [], + .. classDecl.IsRecord ? (string[])["record"] : [], + keyword, + ]; + + code.Write($"{string.Join(" ", modifiers)} {classDecl.Name ?? "UnnamedClass"}"); + + // One list, base first. C# takes at most one class in it and puts it first, which is why + // the AST keeps the two apart: the order is not something a generator could recover. + string[] inherited = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[MapToCSType(baseType)] : [], + .. classDecl.Interfaces.Select(MapToCSType), + ]; + + if (inherited.Length > 0) + { + code.Write($" : {string.Join(", ", inherited)}"); } // The line is ended before the scope opens, so C#'s brace lands on its own line. diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index d50a568..11cd414 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -314,6 +314,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod code.WriteLine("template <>"); } + WriteTypePromises(classDecl, code); + code.Write($"{(isStruct ? "struct" : "class")} {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.IsSpecialisation) @@ -322,9 +324,19 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod code.Write($"<{string.Join(", ", arguments)}>"); } - if (classDecl.BaseType is TypeReference baseType) + // C++ does not distinguish a base class from an interface -- an interface is a class whose + // members are all pure virtual -- so the two lists join into one. What it does distinguish + // is public from private inheritance, and the default for a class is private, which would + // make a base nobody outside could use the base through. + string[] inherited = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[$"public {MapToCppType(baseType)}"] : [], + .. classDecl.Interfaces.Select(contract => $"public {MapToCppType(contract)}"), + ]; + + if (inherited.Length > 0) { - code.Write($" : public {MapToCppType(baseType)}"); + code.Write($" : {string.Join(", ", inherited)}"); } code.WriteLine(); diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index b4a0280..a25ac44 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -501,6 +501,7 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod } GenerateStruct(classDecl, name, code); + WriteInterfaceAssertions(classDecl, name, code); foreach (FieldDeclaration field in classDecl.Members.OfType().Where(field => field.IsStatic)) { @@ -515,6 +516,39 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod } } + /// + /// Asserts, at compile time, that a type implements what it said it implements. + /// + /// The declaration to emit the assertions for. + /// The name the type is written under. + /// The writer to emit into. + /// + /// Go satisfies an interface structurally: a type implements one by having its methods, and + /// never says so. That leaves a declaration that meant to implement something with nothing in + /// the file to show for it, and nothing to fail when a method is renamed out from under it. + /// + /// var _ Contract = (*Type)(nil) is the language's own answer, and it is a check rather + /// than a comment: the file stops compiling when the type stops implementing the interface, + /// which is the same trade the C++ projection of a relationship makes. The pointer form is the + /// one that always holds -- a method declared on the pointer receiver is not in the value's + /// method set, and one declared on the value is in both. + /// + /// + private static void WriteInterfaceAssertions(ClassDeclaration classDecl, string name, CodeBlocker code) + { + if (classDecl.Interfaces.Count == 0) + { + return; + } + + code.NewLine(); + + foreach (TypeReference contract in classDecl.Interfaces) + { + code.WriteLine($"var _ {SpellType(contract)} = (*{name})(nil)"); + } + } + /// /// Reports whether a member declares a type rather than data or behaviour. /// @@ -543,6 +577,7 @@ private static bool IsTypeDeclaration(AstNode member) => private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteTypePromises(classDecl, code); WriteExportNote(name, classDecl.Visibility, code); List fields = [.. StructFields(classDecl)]; @@ -627,11 +662,22 @@ private AlignedLine Field(string? name, TypeReference? type, Visibility visibili private void GenerateInterface(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteTypePromises(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) + // An interface embedded in another is written as its bare name among the members, and means + // every method of it. A base and an interface are the same thing at this end -- it is the + // struct below where they part, Go having no inheritance for one and structural + // satisfaction for the other. + string[] embedded = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[SpellType(baseType)] : [], + .. classDecl.Interfaces.Select(SpellType), + ]; + + if (embedded.Length == 0 && members.Count == 0) { code.WriteLine($"type {name} interface{{}}"); return; @@ -641,9 +687,9 @@ private void GenerateInterface(ClassDeclaration classDecl, string name, CodeBloc using Scope body = new(code); - if (classDecl.BaseType is TypeReference baseType) + foreach (string contract in embedded) { - code.WriteLine(SpellType(baseType)); + code.WriteLine(contract); } insideInterface = true; diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 8e37d3c..b9d929a 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -3,6 +3,7 @@ namespace ktsu.Coder.Languages; using System.Globalization; +using System.Linq; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -272,6 +273,16 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); } + WriteTypePromises(classDecl, code); + + // JavaScript extends one thing and has no interfaces at all, so anything the declaration + // implements is written down rather than lost: a duck-typed object is expected to have the + // members, and nothing in the file would otherwise say which ones. + if (classDecl.Interfaces.Count > 0) + { + WriteInexpressible(code, $"implements {string.Join(", ", classDecl.Interfaces.Select(contract => contract.Name))}"); + } + code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.BaseType is TypeReference baseType) diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index fbfeb1f..32f1bb3 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -188,6 +188,42 @@ protected void WriteInexpressible(CodeBlocker code, string what) /// protected virtual string CommentPrefix => "//"; + /// + /// Writes the promises a type declaration makes that this language has no word for. + /// + /// The declaration being emitted. + /// The writer to emit into. + /// + /// Whether this target has already asked for the record's members some other way, such as + /// Rust's #[derive]. + /// + /// + /// and are + /// claims about the type rather than about any one member of it — it compares by value, and no + /// member of it modifies it — so a target that drops either in silence writes a file that looks + /// like it still makes the claim. + /// + /// is deliberately not among them, and is dropped + /// without a note. It claims nothing about the type: it is permission to declare the rest of it + /// in another file, and a generator that has written the whole declaration has not used the + /// permission for anything a reader of this file could be missing. + /// + /// + protected void WriteTypePromises(ClassDeclaration classDecl, CodeBlocker code, bool recordIsSpelled = false) + { + Ensure.NotNull(classDecl); + + if (classDecl.IsRecord && !recordIsSpelled) + { + WriteInexpressible(code, "record: compares by value, and copies and prints itself"); + } + + if (classDecl.IsReadOnly) + { + WriteInexpressible(code, "readonly: no member of this type modifies it"); + } + } + /// /// Spells one of a file's imports, or reports that the language has nothing to write for it. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index b3b224b..fdce831 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -3,6 +3,7 @@ namespace ktsu.Coder.Languages; using System.Globalization; +using System.Linq; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -311,11 +312,26 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); } + // Python's @dataclass is what a record asks for, and it is not written here: the decorator + // needs an import, and a class is generated on its own as readily as inside a file whose + // imports the AST carries. Emitting one would be a change to how this generator writes a + // file rather than to how it writes a class. + WriteTypePromises(classDecl, code); + code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); - if (classDecl.BaseType is TypeReference baseType) + // Python inherits from as many things as it is given and has no separate notion of an + // interface, so the base and the interfaces are one list of bases -- which is what the + // abstract base classes in the standard library already are. + string[] bases = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[PythonTypeFromGenericType(baseType)] : [], + .. classDecl.Interfaces.Select(PythonTypeFromGenericType), + ]; + + if (bases.Length > 0) { - code.Write($"({PythonTypeFromGenericType(baseType)})"); + code.Write($"({string.Join(", ", bases)})"); } code.WriteLine(":"); diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 256d38c..6f72591 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -409,6 +409,28 @@ private static bool IsTypeDeclaration(AstNode member) => private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + + // A trait a struct implements needs an impl block, and an impl block needs the bodies of + // the methods it supplies, which the declaration does not have: the members here belong to + // the struct rather than to any one of the traits. So it is written down instead. + if (classDecl.Interfaces.Count > 0) + { + WriteInexpressible( + code, + $"implements {string.Join(", ", classDecl.Interfaces.Select(SpellType))}: " + + "each needs an impl block of its own, which this declaration does not say how to fill"); + } + + // What a record asks for is exactly what derive supplies, which makes this the one target + // besides C# that has a word for it rather than a comment about it. Clone is the copy, + // PartialEq the comparison, Debug the readable form. + if (classDecl.IsRecord) + { + code.WriteLine("#[derive(Clone, Debug, PartialEq)]"); + } + + WriteTypePromises(classDecl, code, recordIsSpelled: true); + code.Write($"{SpellVisibilityOf(classDecl)}struct {name} "); using Scope body = new(code); @@ -466,12 +488,22 @@ private static void WriteStructMember(string? name, TypeReference? type, Visibil private void GenerateTrait(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteTypePromises(classDecl, code); code.Write($"{SpellVisibilityOf(classDecl)}trait {name}"); - // A base type is a supertrait: something every implementation of this one must also be. - if (classDecl.BaseType is TypeReference baseType) + // A supertrait: something every implementation of this one must also be. A base type and an + // interface are the same thing to a trait, which is the one place Rust answers the + // distinction exactly rather than working around it -- the reason the two are kept apart in + // the AST is the struct below, where only one of them has an answer at all. + string[] supertraits = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[SpellType(baseType)] : [], + .. classDecl.Interfaces.Select(SpellType), + ]; + + if (supertraits.Length > 0) { - code.Write($": {SpellType(baseType)}"); + code.Write($": {string.Join(" + ", supertraits)}"); } code.Write(" "); diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 0300283..ce178e7 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -777,9 +777,14 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) classDecl.Kind = kind; } + classDecl.IsRecord = ReadFlag(dict, "record", classDecl.IsRecord); + classDecl.IsPartial = ReadFlag(dict, "partial", classDecl.IsPartial); + classDecl.IsReadOnly = ReadFlag(dict, "readOnly", classDecl.IsReadOnly); + DeserializeVisibility(classDecl, dict); ReadStrings(dict, DocumentationKey, classDecl.Documentation); - DeserializeSpecialisationArguments(classDecl, dict); + DeserializeTypeList(dict, "interfaces", classDecl.Interfaces); + DeserializeTypeList(dict, "specialisationArguments", classDecl.SpecialisationArguments); DeserializeClassMembers(classDecl, dict); DeserializeMetadata(classDecl, dict); @@ -787,23 +792,33 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) return classDecl; } - private static void DeserializeSpecialisationArguments(ClassDeclaration classDecl, Dictionary dict) + /// + /// Reads a sequence of written types into a collection. + /// + /// The mapping to read from. + /// The key the sequence is written under. + /// The collection to fill. + /// + /// Both of a class declaration's type lists are read this way, and each is written as a + /// sequence rather than one joined string for the same reason: a type argument can itself have + /// type arguments, so a comma inside one is part of it as often as it separates two. + /// + private static void DeserializeTypeList(Dictionary dict, string key, Collection types) { - if (!dict.TryGetValue("specialisationArguments", out object? argumentsObj) || - argumentsObj is not List argumentList) + if (!dict.TryGetValue(key, out object? writtenObj) || writtenObj is not List written) { return; } - // A null or empty entry is not an argument. Filtering before the loop rather than inside it - // so that what the loop takes is what the loop does. - IEnumerable written = argumentList - .Select(argument => argument?.ToString() ?? string.Empty) + // A null or empty entry is not a type. Filtering before the loop rather than inside it so + // that what the loop takes is what the loop does. + IEnumerable spelled = written + .Select(type => type?.ToString() ?? string.Empty) .Where(text => text.Length > 0); - foreach (string text in written) + foreach (string text in spelled) { - classDecl.SpecialisationArguments.Add(TypeReference.Parse(text)); + types.Add(TypeReference.Parse(text)); } } diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 2b26267..ac592dc 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -597,6 +597,13 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio nodeData["baseType"] = classDecl.BaseType.ToString(); } + if (classDecl.Interfaces.Count > 0) + { + // Each on its own for the same reason the specialisation arguments are: an interface + // can have type arguments, so a comma inside one is not a separator between two. + nodeData["interfaces"] = classDecl.Interfaces.Select(contract => contract.ToString()).ToList(); + } + if (classDecl.SpecialisationArguments.Count > 0) { // Each argument on its own, rather than joined: a type argument can itself have type @@ -605,6 +612,23 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio classDecl.SpecialisationArguments.Select(argument => argument.ToString()).ToList(); } + // Written only when set, the way every other flag in this file is: a document says what a + // declaration is rather than what it is not, and the reader's default is the same false. + if (classDecl.IsRecord) + { + nodeData["record"] = true; + } + + if (classDecl.IsPartial) + { + nodeData["partial"] = true; + } + + if (classDecl.IsReadOnly) + { + nodeData["readOnly"] = true; + } + SerializeVisibility(classDecl, nodeData); SerializeDocumentation(classDecl, nodeData); From ba2b341744b04bedc4f46bb073e8939697e5fd6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 13:02:43 +0000 Subject: [PATCH 3/7] Teach a declaration the types it is written over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [minor] `ClassDeclaration` and `FunctionDeclaration` now carry type parameters, each with the requirements on it. `public readonly partial record struct Mass : IVector0, T> where T : struct, INumber` is now reachable in full, which was the point. `TypeParameter` and `TypeConstraint` are values rather than nodes, like `SpecialisationArguments` and for the same reason: a type parameter is part of the thing being declared rather than a member of it, so there is nothing in the editor for it to be a node of. `Parse` and `ToString` are inverses, so a document carries a whole parameter — constraints and all — on one line a person can read, and the split respects the brackets because the comma in `IComparer` belongs to it rather than separating two constraints. The constraints are where the targets part, and settling that was the work: A parameter's *name* travels everywhere. What a language can say about that name does not. So `TypeConstraintKind` names four intents — implements a type, is a value, is a reference, is constructible — and stops. Those are the ones with a shared idea underneath. A C++ concept is a predicate that can ask anything at all (`requires (T a) { a.begin(); }`), which is the same reason `CompileTimeAssertion.Condition` is text: there is no idea to model, only a language's own way of asking a question. Each target then spells what it has a word for and writes down the rest, the way `WriteTypePromises` already does: - C# spells all four, and *reorders* them. The language requires the class or struct constraint first and `new()` last and rejects any other order; the AST has no reason to know that, and a caller listing them as they think of them should still get a file that compiles. - Rust spells two — a trait bound is exactly `Implements`, `Default` is exactly `Constructible` — and carries the parameters onto every `impl` block it opens: the inherent one, an operator, a conversion and `Drop`. That is the part that had to be right rather than plausible, since `impl Mass` beside a `struct Mass` reads correctly and does not build. `RustGeneratedSourceCompilesTests` now compiles a generic struct whose bound the body depends on, so rustc checks it. - Go spells one, `Implements` being exactly a Go constraint interface, and only for a function. A method on a generic type needs the parameters in three places and spelled two ways — `NewPoint` for the constructor's name but `Point[T]` for its receiver and its result — so a generic type is written down instead, and the interface assertion is suppressed with it rather than asserting something about a name that is not a type. - C++ writes `template ` and notes every constraint. The standard concepts need an include the AST does not carry for a declaration generated on its own, which is the reason Python does not get its `@dataclass` either. - C, Python and JavaScript write the whole parameter down. Eleven more tests, over what each target writes and over the parse/print round trip, plus the rustc one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 21 +++ .../RustGeneratedSourceCompilesTests.cs | 27 +++ .../Languages/TypeDeclarationShapeTests.cs | 169 ++++++++++++++++++ Coder/Ast/ClassDeclaration.cs | 16 ++ Coder/Ast/FunctionDeclaration.cs | 15 ++ Coder/Ast/TypeConstraint.cs | 143 +++++++++++++++ Coder/Ast/TypeParameter.cs | 164 +++++++++++++++++ Coder/Languages/CGenerator.cs | 1 + Coder/Languages/CSharpGenerator.cs | 59 +++++- Coder/Languages/CppGenerator.cs | 32 ++++ Coder/Languages/GoGenerator.cs | 62 ++++++- Coder/Languages/JavaScriptGenerator.cs | 1 + Coder/Languages/LanguageGeneratorBase.cs | 70 ++++++++ Coder/Languages/PythonGenerator.cs | 4 + Coder/Languages/RustGenerator.cs | 115 ++++++++++-- Coder/Serialization/YamlDeserializer.cs | 29 +++ Coder/Serialization/YamlSerializer.cs | 14 ++ 17 files changed, 927 insertions(+), 15 deletions(-) create mode 100644 Coder/Ast/TypeConstraint.cs create mode 100644 Coder/Ast/TypeParameter.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6f9afe9..6776f43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,27 @@ source in seven target languages. The solution uses: 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/TypeParameter.cs` and `TypeConstraint.cs` — what a declaration is written *over*, on + `ClassDeclaration` and on `FunctionDeclaration`. Values rather than nodes, like + `SpecialisationArguments` and for the same reason: a type parameter is part of the thing being + declared rather than a member of it, and `Parse`/`ToString` are inverses so a document carries a + whole parameter on one line. **The constraints are where the targets part, and that is the + decision worth knowing.** A parameter's *name* travels everywhere; what a language can say about + that name does not. `TypeConstraintKind` names four intents — implements a type, is a value, is a + reference, is constructible — and stops there, because those are the ones with a shared idea + underneath; a C++ concept is a predicate that can ask anything at all (`requires (T a) { + a.begin(); }`), which is the same reason `CompileTimeAssertion.Condition` is text. Then: C# spells + all four, and reorders them, because C# requires the class or struct constraint first and `new()` + last and the AST has no reason to know that. Rust spells two — a trait bound is exactly + `Implements` and `Default` is exactly `Constructible` — and carries the parameters onto every + `impl` block, which is what makes `impl Mass` compile where `impl Mass` would not. + Go spells one, `Implements` being exactly a Go constraint interface, and only for a *function*: a + method on a generic type needs the parameters in three places and spelled two ways (`NewPoint` for + the constructor's name, `Point[T]` for its receiver and result), so a generic type is written + down instead. C++ writes `template ` and notes every constraint, the standard concepts + needing an include the AST does not carry. C, Python and JavaScript write the whole parameter + down. `RustGeneratedSourceCompilesTests` compiles a generic struct with a load-bearing bound, so + the `impl` repetition is checked rather than asserted. - `Coder/Ast/ClassDeclaration.cs`'s `Interfaces`, `IsRecord`, `IsPartial` and `IsReadOnly` — what a type declaration says about itself beyond its name. `Interfaces` is separate from `BaseType` rather than folded into one list, because what a target does with the two differs: C# writes them diff --git a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs index af7ced3..8e0f69a 100644 --- a/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/RustGeneratedSourceCompilesTests.cs @@ -98,6 +98,7 @@ private static SourceFile Exemplar() geometry.Members.Add(CompiledExemplar.OriginAlias()); geometry.Members.Add(CompiledExemplar.OriginTable()); geometry.Members.Add(CompiledExemplar.Measure()); + geometry.Members.Add(Boxed()); geometry.Members.Add(new CompileTimeAssertion { Condition = "std::mem::size_of::() == 4", @@ -108,6 +109,32 @@ private static SourceFile Exemplar() return file; } + /// + /// A type written over one parameter, so its impl block has to carry the parameter too. + /// + /// The declaration. + /// + /// The whole of what makes this worth compiling: a generic struct's inherent impl has to repeat + /// the parameter and bound it — impl<T: Clone> Boxed<T> — and a generator + /// that wrote impl Boxed beside a struct Boxed<T> would produce something + /// that reads correctly and does not build. The bound is load-bearing rather than decoration: + /// without it the body's call to clone does not resolve. + /// + private static ClassDeclaration Boxed() + { + ClassDeclaration boxed = new("Boxed") { Kind = TypeDeclarationKind.Struct }; + boxed.Documentation.Add("Holds one of whatever it was given."); + boxed.TypeParameters.Add(TypeParameter.Parse("T : Clone")); + boxed.Members.Add(new FieldDeclaration("held", "T")); + + FunctionDeclaration copy = new("copy") { ReturnType = "T", IsReadOnly = true }; + copy.Body.Add(new ReturnStatement( + new CallExpression(new VariableReference("self.held"), "clone"))); + boxed.Members.Add(copy); + + return boxed; + } + /// /// Builds a trait whose constant every implementation has to supply. /// diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index 02fff37..e65d14f 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -306,6 +306,175 @@ public void Partial_IsDroppedInSilenceEverywhereButCSharp() StringAssert.Contains(Generate(new CSharpGenerator(), split), "public partial class Split"); } + // ------------------------------------------------------------------ Type parameters + + /// + /// A type written over one parameter with the constraints ktsu.Semantics actually uses. + /// + /// The declaration. + private static ClassDeclaration Mass() + { + ClassDeclaration mass = new("Mass") { Kind = TypeDeclarationKind.Struct }; + mass.TypeParameters.Add(TypeParameter.Parse("T : struct, INumber")); + return mass; + } + + /// + /// C# writes the names beside the type and the requirements in a clause of their own. + /// + [TestMethod] + public void CSharp_WritesTheParametersAndAWhereClause() + { + string code = Generate(new CSharpGenerator(), Mass()); + + StringAssert.Contains(code, "struct Mass"); + StringAssert.Contains(code, "where T : struct, INumber"); + } + + /// + /// The order within a clause is the language's rather than the declaration's. + /// + /// + /// C# requires the class or struct constraint first and new() last and rejects any other + /// order, which the AST has no reason to know. A caller listing them as they think of them + /// should still get a file that compiles. + /// + [TestMethod] + public void CSharp_PutsTheConstraintsInTheOrderTheLanguageAccepts() + { + ClassDeclaration table = new("Table"); + table.TypeParameters.Add(TypeParameter.Parse("T : new(), IComparable, class")); + + StringAssert.Contains( + Generate(new CSharpGenerator(), table), + "where T : class, IComparable, new()"); + } + + /// + /// A generic method carries its own parameters, which are not its type's. + /// + [TestMethod] + public void CSharp_WritesAMethodsOwnTypeParameters() + { + FunctionDeclaration pick = new("Pick") { ReturnType = "T", IsStatic = true }; + pick.TypeParameters.Add(TypeParameter.Parse("T : IComparable")); + pick.Parameters.Add(new Parameter("first", "T")); + + string code = Generate(new CSharpGenerator(), pick); + + StringAssert.Contains(code, "Pick("); + StringAssert.Contains(code, "where T : IComparable"); + } + + /// + /// C++ writes the parameters as a template and the requirements as a note. + /// + /// + /// A concept is a predicate over a type and can ask anything at all, so there is nothing shared + /// underneath struct and std::floating_point to translate between — and the + /// standard ones need an include the AST does not carry, which is the same reason Python does + /// not get its @dataclass. + /// + [TestMethod] + public void Cpp_WritesATemplateAndNotesWhatItCannotRequire() + { + string code = Generate(new CppGenerator(), Mass()); + + StringAssert.Contains(code, "template "); + StringAssert.Contains(code, "requires that T is a value type, and that T is INumber"); + } + + /// + /// Rust puts the bounds it has beside the parameter, and notes the one it does not. + /// + /// + /// A trait bound is exactly what Implements means, and Default is exactly what + /// new() means. "Value type" is not a thing Rust says about a parameter at all — every + /// type is one — so that is the one written down. + /// + [TestMethod] + public void Rust_BoundsWhatItCanAndNotesWhatItCannot() + { + string code = Generate(new RustGenerator(), Mass()); + + StringAssert.Contains(code, "struct Mass>"); + StringAssert.Contains(code, "requires that T is a value type"); + Assert.DoesNotContain("INumber,", code, "the trait bound should not also be written down."); + } + + /// + /// Rust repeats the parameters on the impl block, where the methods need them. + /// + /// + /// The bounds go on the impl and the bare names on the type it is for — + /// impl<T: Bound> Mass<T> — which is the one shape that compiles. + /// + [TestMethod] + public void Rust_CarriesTheParametersOntoTheImplBlock() + { + ClassDeclaration mass = Mass(); + mass.Members.Add(new FunctionDeclaration("value") { ReturnType = "T", IsReadOnly = true }); + + StringAssert.Contains(Generate(new RustGenerator(), mass), "impl> Mass"); + } + + /// + /// Go writes a generic free function, and writes a generic type's parameters down. + /// + /// + /// A Go method on a generic type needs the parameters in three places and spelled two ways — + /// NewPoint for the constructor's name but Point[T] for its receiver and its + /// result — so making a type generic is a change to how this generator writes a whole type + /// rather than to how it writes one line. A free function has none of that. + /// + [TestMethod] + public void Go_WritesAGenericFunctionAndNotesAGenericType() + { + FunctionDeclaration first = new("First") { ReturnType = "T" }; + first.TypeParameters.Add(TypeParameter.Parse("T : Ordered")); + + StringAssert.Contains(Generate(new GoGenerator(), first), "First[T Ordered]("); + StringAssert.Contains(Generate(new GoGenerator(), Mass()), "over T : struct, INumber"); + } + + /// + /// A target with no generics at all writes the whole parameter down, constraints and all. + /// + /// The generator to ask. + [TestMethod] + [DataRow("c")] + [DataRow("python")] + [DataRow("javascript")] + public void TargetsWithNoGenerics_WriteTheWholeParameterDown(string language) + { + ILanguageGenerator generator = language switch + { + "c" => new CGenerator(), + "python" => new PythonGenerator(), + _ => new JavaScriptGenerator(), + }; + + StringAssert.Contains(Generate(generator, Mass()), "over T : struct, INumber"); + } + + /// + /// Parsing a written parameter and writing it back gives the same text. + /// + /// + /// Which is what lets a document carry a whole parameter on one line. The comma inside + /// IComparer<T, U> belongs to it rather than separating two constraints, so the + /// split has to respect the brackets. + /// + [TestMethod] + public void TypeParameter_ParseAndToStringAreInverses() + { + const string written = "T : class, IComparer, new()"; + + Assert.AreEqual(written, TypeParameter.Parse(written).ToString()); + Assert.AreEqual(3, TypeParameter.Parse(written).Constraints.Count); + Assert.AreEqual("U", TypeParameter.Parse("U").ToString()); + } + // ------------------------------------------------------------------ Round trip /// diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs index 50927c0..89ccad4 100644 --- a/Coder/Ast/ClassDeclaration.cs +++ b/Coder/Ast/ClassDeclaration.cs @@ -83,6 +83,17 @@ public ClassDeclaration() /// public bool IsSpecialisation => SpecialisationArguments.Count > 0; + /// + /// Gets the types this declaration is written over, which may be none. + /// + /// + /// Separate from and the opposite of it: these are the + /// parameters a declaration takes, and those are the arguments a specialisation supplies. A + /// declaration has one or the other and not both, since a full specialisation by definition + /// leaves nothing open. + /// + public Collection TypeParameters { get; init; } = []; + /// /// Gets the interfaces this type implements, which may be none. /// @@ -183,6 +194,11 @@ public override AstNode Clone() clone.Interfaces.Add(contract.Clone()); } + foreach (TypeParameter parameter in TypeParameters) + { + clone.TypeParameters.Add(parameter.Clone()); + } + foreach ((string key, object? value) in Metadata) { clone.Metadata[key] = value; diff --git a/Coder/Ast/FunctionDeclaration.cs b/Coder/Ast/FunctionDeclaration.cs index 1b0840b..879a829 100644 --- a/Coder/Ast/FunctionDeclaration.cs +++ b/Coder/Ast/FunctionDeclaration.cs @@ -167,6 +167,16 @@ public FunctionDeclaration() /// public Collection Parameters { get; init; } = []; + /// + /// Gets the types this function is written over, which may be none. + /// + /// + /// A function's own parameters rather than its enclosing type's: a method of + /// Table<TKey> that takes a TValue of its own declares the second here and + /// not the first, and a generator writes the enclosing type's where it writes the type. + /// + public Collection TypeParameters { get; init; } = []; + /// /// Gets or sets a list of statements that make up the function body. /// @@ -220,6 +230,11 @@ public override AstNode Clone() } // Clone parameters + foreach (TypeParameter typeParameter in TypeParameters) + { + clone.TypeParameters.Add(typeParameter.Clone()); + } + foreach (Parameter parameter in Parameters) { clone.Parameters.Add((Parameter)parameter.Clone()); diff --git a/Coder/Ast/TypeConstraint.cs b/Coder/Ast/TypeConstraint.cs new file mode 100644 index 0000000..88824a9 --- /dev/null +++ b/Coder/Ast/TypeConstraint.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System; + +/// +/// What a type parameter is required to be. +/// +/// +/// Named intents rather than the text of a constraint, for the reason is +/// an enumeration rather than a modifier's spelling: each target writes them differently, and three +/// of them write most of these not at all. A generator handed struct, INumber<T> as a +/// string could only paste it, which would put C# in every file the AST produced. +/// +/// Four, and deliberately not more. These are the ones with an idea underneath that survives the +/// trip between languages — a value, a reference, something constructible, something that is a +/// named other thing. A C++ concept is a predicate over a type and can say anything at all +/// (requires (T a) { a.begin(); }), which is the same reason +/// is text: there is no shared idea to model, only a +/// language's own way of asking a question. +/// +/// +public enum TypeConstraintKind +{ + /// The argument is, or derives from, a named type. + Implements, + + /// The argument is copied rather than referenced, and is never absent. + ValueType, + + /// The argument is referenced rather than copied, and may be absent. + ReferenceType, + + /// The argument can be made with no arguments of its own. + Constructible, +} + +/// +/// One requirement on a type parameter. +/// +/// +/// A value rather than an , and for the same reason +/// holds values: a constraint is part of the +/// thing being declared rather than a member of it, so there is nothing in the editor for it to be +/// a node of. and are inverses, which is what +/// lets a document carry a whole parameter as one readable line. +/// +public sealed class TypeConstraint : IEquatable +{ + /// What a constraint asking for a value type is written as. + private const string ValueTypeKeyword = "struct"; + + /// What a constraint asking for a reference type is written as. + private const string ReferenceTypeKeyword = "class"; + + /// What a constraint asking for a no-argument constructor is written as. + private const string ConstructibleKeyword = "new()"; + + /// + /// Initializes a new instance of the class. + /// + public TypeConstraint() + { + } + + /// + /// Initializes a new instance of the class for a named type. + /// + /// The type the argument has to be. + public TypeConstraint(TypeReference? type) + { + Kind = TypeConstraintKind.Implements; + Type = type; + } + + /// + /// Initializes a new instance of the class of a given kind. + /// + /// What the argument is required to be. + public TypeConstraint(TypeConstraintKind kind) => Kind = kind; + + /// + /// Gets or sets what the argument is required to be. + /// + public TypeConstraintKind Kind { get; set; } + + /// + /// Gets or sets the type the argument has to be, when is + /// ; null otherwise. + /// + public TypeReference? Type { get; set; } + + /// + /// Reads a written constraint. + /// + /// The constraint as it is written. + /// The constraint. + /// + /// The three keywords are spelled the way C# spells them, which is a choice rather than a + /// discovery — some spelling had to be the written one, and already + /// writes a generic argument the way the C family does. Anything that is not one of the three + /// is a type, so a spelling this does not recognise becomes a requirement to be that named + /// thing rather than an error. + /// + public static TypeConstraint Parse(string text) + { + string written = (text ?? string.Empty).Trim(); + + return written switch + { + ValueTypeKeyword => new TypeConstraint(TypeConstraintKind.ValueType), + ReferenceTypeKeyword => new TypeConstraint(TypeConstraintKind.ReferenceType), + ConstructibleKeyword => new TypeConstraint(TypeConstraintKind.Constructible), + _ => new TypeConstraint(TypeReference.Parse(written)), + }; + } + + /// + public override string ToString() => Kind switch + { + TypeConstraintKind.ValueType => ValueTypeKeyword, + TypeConstraintKind.ReferenceType => ReferenceTypeKeyword, + TypeConstraintKind.Constructible => ConstructibleKeyword, + _ => Type?.ToString() ?? string.Empty, + }; + + /// + /// Creates a copy. + /// + /// The copy. + public TypeConstraint Clone() => new() { Kind = Kind, Type = Type?.Clone() }; + + /// + public bool Equals(TypeConstraint? other) => + other is not null && Kind == other.Kind && Equals(Type, other.Type); + + /// + public override bool Equals(object? obj) => Equals(obj as TypeConstraint); + + /// + public override int GetHashCode() => ToString().GetHashCode(StringComparison.Ordinal); +} diff --git a/Coder/Ast/TypeParameter.cs b/Coder/Ast/TypeParameter.cs new file mode 100644 index 0000000..3235188 --- /dev/null +++ b/Coder/Ast/TypeParameter.cs @@ -0,0 +1,164 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +/// +/// One of the types a declaration is written over. +/// +/// +/// A value rather than an , the same as +/// : a type parameter is part of the thing +/// being declared rather than a member of it. and +/// are inverses, so a document carries a whole parameter, constraints and +/// all, as one line a person can read. +/// +/// The name travels everywhere and the constraints do not. Four of the seven targets have type +/// parameters at all, and of those, what each can say about one differs so much that the +/// constraints are the part a generator has to decide about rather than translate — which is why +/// they are named intents in rather than text. +/// +/// +public sealed class TypeParameter : IEquatable +{ + /// What separates a parameter's name from its constraints. + private const char ConstraintSeparator = ':'; + + /// + /// Initializes a new instance of the class. + /// + public TypeParameter() + { + } + + /// + /// Initializes a new instance of the class with a name. + /// + /// What the parameter is called. + public TypeParameter(string name) => Name = name; + + /// + /// Gets or sets what the parameter is called. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets what an argument for this parameter is required to be, which may be nothing. + /// + public Collection Constraints { get; init; } = []; + + /// + /// Reads a written parameter. + /// + /// The parameter as it is written, such as T : struct, INumber<T>. + /// The parameter. + /// + /// The constraints are split at the commas that are not inside a generic argument list, for the + /// reason is a sequence rather than one + /// joined string: the comma in IComparer<T, U> belongs to it rather than separating + /// two constraints. + /// + public static TypeParameter Parse(string text) + { + string written = (text ?? string.Empty).Trim(); + int separator = written.IndexOf(ConstraintSeparator); + + if (separator < 0) + { + return new TypeParameter(written); + } + + TypeParameter parameter = new(written[..separator].Trim()); + + foreach (string constraint in SplitAtTopLevel(written[(separator + 1)..])) + { + parameter.Constraints.Add(TypeConstraint.Parse(constraint)); + } + + return parameter; + } + + /// + /// Splits a constraint list at the commas that separate its entries. + /// + /// The list, without the colon before it. + /// The entries, with nothing empty among them. + private static IEnumerable SplitAtTopLevel(string text) + { + int depth = 0; + int start = 0; + + for (int index = 0; index < text.Length; index++) + { + switch (text[index]) + { + case '<': + depth++; + break; + + case '>': + depth--; + break; + + case ',' when depth == 0: + yield return text[start..index]; + start = index + 1; + break; + + default: + break; + } + } + + yield return text[start..]; + } + + /// + public override string ToString() => + Constraints.Count == 0 + ? Name + : $"{Name} {ConstraintSeparator} {string.Join(", ", Constraints.Select(constraint => constraint.ToString()))}"; + + /// + /// Creates a copy. + /// + /// The copy. + public TypeParameter Clone() + { + TypeParameter clone = new(Name); + + foreach (TypeConstraint constraint in Constraints) + { + clone.Constraints.Add(constraint.Clone()); + } + + return clone; + } + + /// + /// Reads a written parameter, so a caller with a name and nothing else can write just the name. + /// + /// The parameter as it is written. + public static implicit operator TypeParameter?(string? text) => text is null ? null : Parse(text); + + /// + /// Reads a written parameter. + /// + /// The parameter as it is written. + /// The parameter, or null when there was no text. + public static TypeParameter? FromString(string? text) => text is null ? null : Parse(text); + + /// + public bool Equals(TypeParameter? other) => + other is not null && string.Equals(ToString(), other.ToString(), StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => Equals(obj as TypeParameter); + + /// + public override int GetHashCode() => ToString().GetHashCode(StringComparison.Ordinal); +} diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 73701d1..2671bf6 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -506,6 +506,7 @@ .. classDecl.Members.Where(member => GenerateDocumentation(classDecl, code); WriteTypePromises(classDecl, code); + WriteTypeParametersDown(classDecl.TypeParameters, code); code.WriteLine($"typedef struct {name}"); code.WriteLine("{"); diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 4dfedeb..8aa795a 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -191,7 +191,7 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) keyword, ]; - code.Write($"{string.Join(" ", modifiers)} {classDecl.Name ?? "UnnamedClass"}"); + code.Write($"{string.Join(" ", modifiers)} {classDecl.Name ?? "UnnamedClass"}{SpellTypeParameters(classDecl.TypeParameters)}"); // One list, base first. C# takes at most one class in it and puts it first, which is why // the AST keeps the two apart: the order is not something a generator could recover. @@ -208,6 +208,7 @@ .. classDecl.Interfaces.Select(MapToCSType), // The line is ended before the scope opens, so C#'s brace lands on its own line. code.WriteLine(); + WriteConstraintClauses(classDecl.TypeParameters, code); using Scope members = new(code); foreach (AstNode member in classDecl.Members) @@ -223,6 +224,59 @@ .. classDecl.Interfaces.Select(MapToCSType), } } + /// + /// Spells a declaration's type parameters, or nothing when it has none. + /// + /// The declaration's type parameters. + /// The list, angle brackets and all, or an empty string. + /// + /// Names only. C# writes the requirements in a where clause of their own rather than + /// beside the name, which is what is for. + /// + private static string SpellTypeParameters(IEnumerable parameters) + { + string[] names = [.. parameters.Select(parameter => parameter.Name)]; + + return names.Length == 0 ? string.Empty : $"<{string.Join(", ", names)}>"; + } + + /// + /// Writes one where clause per constrained parameter, indented under the declaration. + /// + /// The declaration's type parameters. + /// The writer to emit into. + /// + /// The order within a clause is the language's rather than the declaration's, and putting it + /// right is this generator's job: C# requires the class or struct constraint first and + /// new() last, and rejects any other order. The AST has no reason to know that, and a + /// caller listing them as they think of them should still get a file that compiles. + /// + private static void WriteConstraintClauses(IEnumerable parameters, CodeBlocker code) + { + using IndentScope clauses = new(code); + + foreach (TypeParameter parameter in parameters.Where(parameter => parameter.Constraints.Count > 0)) + { + IEnumerable written = InDeclarationOrder(parameter.Constraints) + .Select(constraint => constraint.ToString()); + + code.WriteLine($"where {parameter.Name} : {string.Join(", ", written)}"); + } + } + + /// + /// Puts a parameter's constraints into the order C# accepts them in. + /// + /// The constraints as the declaration lists them. + /// The constraints, reordered. + private static IEnumerable InDeclarationOrder(IEnumerable constraints) => + constraints.OrderBy(constraint => constraint.Kind switch + { + TypeConstraintKind.ValueType or TypeConstraintKind.ReferenceType => 0, + TypeConstraintKind.Constructible => 2, + _ => 1, + }); + /// protected override string? SpellImport(string import) => $"using {import};"; @@ -510,6 +564,7 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st WriteFunctionModifiers(function, code); code.Write(SpellFunctionName(function, enclosingType)); + code.Write(SpellTypeParameters(function.TypeParameters)); code.Write("("); for (int i = 0; i < function.Parameters.Count; i++) @@ -527,11 +582,13 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st if (function.IsAbstract) { code.WriteLine(";"); + WriteConstraintClauses(function.TypeParameters, code); return; } // The line is ended before the scope opens, so C#'s brace lands on its own line. code.WriteLine(); + WriteConstraintClauses(function.TypeParameters, code); using Scope body = new(code); diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index 11cd414..456d61e 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -72,6 +72,27 @@ public class CppGenerator : CFamilyGenerator /// public override string FileExtension => "cpp"; + /// + /// Writes the template head a declaration written over types needs. + /// + /// The declaration's type parameters. + /// The writer to emit into. + /// + /// Names only, and typename for each: C++ takes non-type parameters as well, and the AST + /// models only the ones that are types. What each has to be goes in a note beside the + /// declaration rather than in a requires clause, for the reason + /// gives. + /// + private static void WriteTemplateHead(IEnumerable parameters, CodeBlocker code) + { + string[] names = [.. parameters.Select(parameter => $"typename {parameter.Name}")]; + + if (names.Length > 0) + { + code.WriteLine($"template <{string.Join(", ", names)}>"); + } + } + /// /// /// A constructor and a destructor are named after the type rather than after themselves, so the @@ -91,6 +112,8 @@ protected override void GenerateFunction(FunctionDeclaration funcDecl, CodeBlock Ensure.NotNull(code); GenerateDocumentation(funcDecl, code); + WriteUnaskedConstraints(funcDecl.TypeParameters, code); + WriteTemplateHead(funcDecl.TypeParameters, code); // Purity earns [[nodiscard]] on its own: a call that does nothing else and whose result is // thrown away did nothing at all. @@ -314,8 +337,17 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod code.WriteLine("template <>"); } + WriteTemplateHead(classDecl.TypeParameters, code); + WriteTypePromises(classDecl, code); + // Every constraint. A concept is a predicate over a type and can ask anything at all, so + // there is no shared idea underneath `struct` and `std::floating_point` to translate + // between -- the same reason CompileTimeAssertion.Condition is text. The standard ones + // would also need included, which the AST does not carry for a declaration + // generated on its own. + WriteUnaskedConstraints(classDecl.TypeParameters, code); + code.Write($"{(isStruct ? "struct" : "class")} {classDecl.Name ?? "UnnamedClass"}"); if (classDecl.IsSpecialisation) diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index a25ac44..691c116 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -70,6 +70,9 @@ public class GoGenerator : StandardLanguageGenerator /// private const string ReceiverName = "self"; + /// The interface that asks nothing of a type parameter. + private const string AnyConstraint = "any"; + /// /// The package a file with an entry point is in. /// @@ -536,7 +539,12 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod /// private static void WriteInterfaceAssertions(ClassDeclaration classDecl, string name, CodeBlocker code) { - if (classDecl.Interfaces.Count == 0) + // Not for a type this generator wrote down rather than wrote. The assertion names the type, + // and the name of a generic type is not the name of a type -- `(*Mass)(nil)` where the + // declaration said `Mass[T]` asserts something about nothing. The note above the + // declaration already says the whole type is approximate; a second wrong line under it + // would not add to that. + if (classDecl.Interfaces.Count == 0 || classDecl.TypeParameters.Count > 0) { return; } @@ -578,6 +586,13 @@ private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker { GenerateDocumentation(classDecl, code); WriteTypePromises(classDecl, code); + + // Go has generics, and a generic type is still written down here. A method on one needs its + // parameters in three places and spelled two ways -- `NewPoint` for the constructor's name + // but `Point[T]` for its receiver and for what the constructor answers with -- so making a + // type generic is a change to how this generator writes a whole type rather than to how it + // writes one line. A function of its own has none of that, and is written as a generic one. + WriteTypeParametersDown(classDecl.TypeParameters, code); WriteExportNote(name, classDecl.Visibility, code); List fields = [.. StructFields(classDecl)]; @@ -663,6 +678,7 @@ private void GenerateInterface(ClassDeclaration classDecl, string name, CodeBloc { GenerateDocumentation(classDecl, code); WriteTypePromises(classDecl, code); + WriteTypeParametersDown(classDecl.TypeParameters, code); WriteExportNote(name, classDecl.Visibility, code); List members = [.. classDecl.Members.Where(member => member is FunctionDeclaration or FieldDeclaration)]; @@ -807,7 +823,7 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st /// 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}("); + code.Write($"{name}{SpellTypeParameters(funcDecl.TypeParameters)}("); GenerateParameterList(funcDecl.Parameters, code); code.Write(")"); @@ -817,6 +833,48 @@ private void WriteSignature(FunctionDeclaration funcDecl, string name, CodeBlock } } + /// + /// Spells a function's own type parameters, or nothing when it has none. + /// + /// The function's type parameters. + /// Something like [T Ordered], or an empty string. + /// + /// A Go type parameter is constrained by an interface, and every one of them is constrained by + /// something: any is the interface that asks for nothing, and is what a parameter with + /// no requirement gets. Several requirements become an interface written in place, which is + /// Go's own way of combining them. + /// + /// Only maps, and it maps exactly. The other three + /// are not things Go says about a type parameter at all, so they are written down beside the + /// declaration instead. + /// + /// + private static string SpellTypeParameters(IEnumerable parameters) + { + string[] declared = [.. parameters.Select(SpellOneTypeParameter)]; + + return declared.Length == 0 ? string.Empty : $"[{string.Join(", ", declared)}]"; + } + + private static string SpellOneTypeParameter(TypeParameter parameter) + { + string[] required = + [ + .. parameter.Constraints + .Where(constraint => constraint.Kind == TypeConstraintKind.Implements) + .Select(constraint => SpellType(constraint.Type ?? new TypeReference(AnyConstraint))), + ]; + + string constraint = required.Length switch + { + 0 => AnyConstraint, + 1 => required[0], + _ => $"interface{{ {string.Join("; ", required)} }}", + }; + + return $"{parameter.Name} {constraint}"; + } + /// /// Spells what a function answers with, or nothing when it answers nothing. /// diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index b9d929a..94c0f89 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -274,6 +274,7 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod } WriteTypePromises(classDecl, code); + WriteTypeParametersDown(classDecl.TypeParameters, code); // JavaScript extends one thing and has no interfaces at all, so anything the declaration // implements is written down rather than lost: a duck-typed object is expected to have the diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 32f1bb3..d725f34 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -5,6 +5,7 @@ namespace ktsu.Coder.Languages; using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -188,6 +189,75 @@ protected void WriteInexpressible(CodeBlocker code, string what) /// protected virtual string CommentPrefix => "//"; + /// + /// Writes down the types a declaration is written over, for a target that has no generics. + /// + /// The declaration's type parameters. + /// The writer to emit into. + /// + /// Constraints and all, because for a target in this position the whole of the parameter is + /// something it cannot say: there is no name in the file for the requirement to be attached to. + /// + protected void WriteTypeParametersDown(IEnumerable parameters, CodeBlocker code) + { + Ensure.NotNull(parameters); + + string[] written = [.. parameters.Select(parameter => parameter.ToString())]; + + if (written.Length > 0) + { + WriteInexpressible(code, $"over {string.Join("; ", written)}"); + } + } + + /// + /// Writes down the requirements on a declaration's type parameters that this target has type + /// parameters but no way to ask for. + /// + /// The declaration's type parameters. + /// The writer to emit into. + /// The kinds this target wrote for itself; anything else is written down. + /// + /// The middle case, and the common one. A target with generics can always write the parameter's + /// name, and what it can say about that name is where they part: C# says all four, Rust says + /// two of them and Go one, and C++ says none of them without an include the AST does not carry. + /// A requirement that goes unwritten is a guarantee quietly dropped, which is the same reason + /// exists. + /// + protected void WriteUnaskedConstraints( + IEnumerable parameters, + CodeBlocker code, + params TypeConstraintKind[] asked) + { + Ensure.NotNull(parameters); + Ensure.NotNull(asked); + + string[] unasked = + [ + .. parameters.SelectMany(parameter => parameter.Constraints + .Where(constraint => Array.IndexOf(asked, constraint.Kind) < 0) + .Select(constraint => $"{parameter.Name} is {Describe(constraint)}")), + ]; + + if (unasked.Length > 0) + { + WriteInexpressible(code, $"requires that {string.Join(", and that ", unasked)}"); + } + } + + /// + /// Says what a constraint asks for, in a reader's terms rather than a language's. + /// + /// The constraint to describe. + /// The description, to follow "T is". + private static string Describe(TypeConstraint constraint) => constraint.Kind switch + { + TypeConstraintKind.ValueType => "a value type", + TypeConstraintKind.ReferenceType => "a reference type", + TypeConstraintKind.Constructible => "constructible with no arguments", + _ => constraint.Type?.ToString() ?? "unconstrained", + }; + /// /// Writes the promises a type declaration makes that this language has no word for. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index fdce831..9af0957 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -318,6 +318,10 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod // file rather than to how it writes a class. WriteTypePromises(classDecl, code); + // Python has TypeVar and Generic, and both need an import the AST does not carry for a + // class generated on its own -- the same reason @dataclass is not written above. + WriteTypeParametersDown(classDecl.TypeParameters, code); + code.Write($"class {classDecl.Name ?? "UnnamedClass"}"); // Python inherits from as many things as it is given and has no separate notion of an diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index 6f72591..dd656b3 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -105,6 +105,72 @@ public class RustGenerator : StandardLanguageGenerator /// private bool insideTraitImplementation; + /// + /// The bounded parameter list every impl block for the type being written needs. + /// + /// + /// Held rather than passed because the four places that open an impl — the inherent + /// block, an operator, a conversion and Drop — are reached by three different routes + /// from the type that owns them, and every one of them has to say the same thing. A generic + /// type whose impl block forgot the parameter does not compile, which makes this the one + /// piece of state here that is load-bearing rather than a convenience. + /// + private string implBounds = string.Empty; + + /// + /// Spells type parameters where they are being declared, bounds and all. + /// + /// The declaration's type parameters. + /// Something like <T: INumber<T>>, or an empty string. + /// + /// A trait bound is exactly what means, and + /// Default is exactly what means. The + /// other two have no bound at all: every Rust type is a value, and whether one is referenced + /// is a property of the binding rather than of the type. + /// + private static string SpellParameterDeclarations(IEnumerable parameters) + { + string[] declared = [.. parameters.Select(SpellOneParameter)]; + + return declared.Length == 0 ? string.Empty : $"<{string.Join(", ", declared)}>"; + } + + private static string SpellOneParameter(TypeParameter parameter) + { + string[] bounds = + [ + .. parameter.Constraints + .Select(SpellBound) + .Where(bound => bound.Length > 0), + ]; + + return bounds.Length == 0 ? parameter.Name : $"{parameter.Name}: {string.Join(" + ", bounds)}"; + } + + private static string SpellBound(TypeConstraint constraint) => constraint.Kind switch + { + TypeConstraintKind.Implements => SpellType(constraint.Type ?? new TypeReference(UnknownTypeName)), + TypeConstraintKind.Constructible => "Default", + _ => string.Empty, + }; + + /// + /// Spells type parameters where they are being used, names only. + /// + /// The declaration's type parameters. + /// Something like <T>, or an empty string. + /// + /// The bounds belong to the declaration and are a repetition anywhere else, which Rust warns + /// about: impl<T: Bound> Mass<T> declares the parameter once and then names + /// it. + /// + private static string SpellParameterArguments(IEnumerable parameters) + { + string[] names = [.. parameters.Select(parameter => parameter.Name)]; + + return names.Length == 0 ? string.Empty : $"<{string.Join(", ", names)}>"; + } + /// /// Whether a member should say nothing about who may see it. /// @@ -311,6 +377,12 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod return; } + // Everything below writes `Name` where it names the type and `impl` where it + // opens a block, which is the one shape that compiles: the parameter is declared once, on + // the impl, and named everywhere else. + implBounds = SpellParameterDeclarations(classDecl.TypeParameters); + string applied = $"{name}{SpellParameterArguments(classDecl.TypeParameters)}"; + GenerateStruct(classDecl, name, code); List functions = @@ -326,7 +398,7 @@ .. functions.Where(member => member.Kind is FunctionKind.Method or FunctionKind. if (inherent.Count > 0) { code.NewLine(); - code.Write($"impl {name} "); + code.Write($"impl{implBounds} {applied} "); using Scope block = new(code); bool first = true; @@ -345,7 +417,7 @@ .. functions.Where(member => member.Kind is FunctionKind.Method or FunctionKind. foreach (FunctionDeclaration function in functions.Except(inherent)) { code.NewLine(); - GenerateTraitImplementation(function, name, code); + GenerateTraitImplementation(function, applied, code); } foreach (FunctionDeclaration destructor in classDecl.Members @@ -353,8 +425,10 @@ .. functions.Where(member => member.Kind is FunctionKind.Method or FunctionKind. .Where(member => member.Kind == FunctionKind.Destructor)) { code.NewLine(); - GenerateDrop(destructor, name, code); + GenerateDrop(destructor, applied, code); } + + implBounds = string.Empty; } /// @@ -421,17 +495,23 @@ private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker + "each needs an impl block of its own, which this declaration does not say how to fill"); } + WriteTypePromises(classDecl, code, recordIsSpelled: true); + WriteUnaskedConstraints( + classDecl.TypeParameters, + code, + TypeConstraintKind.Implements, + TypeConstraintKind.Constructible); + // What a record asks for is exactly what derive supplies, which makes this the one target // besides C# that has a word for it rather than a comment about it. Clone is the copy, - // PartialEq the comparison, Debug the readable form. + // PartialEq the comparison, Debug the readable form. Written last of the lines above the + // struct, so that it sits against the item it applies to rather than behind the notes. if (classDecl.IsRecord) { code.WriteLine("#[derive(Clone, Debug, PartialEq)]"); } - WriteTypePromises(classDecl, code, recordIsSpelled: true); - - code.Write($"{SpellVisibilityOf(classDecl)}struct {name} "); + code.Write($"{SpellVisibilityOf(classDecl)}struct {name}{SpellParameterDeclarations(classDecl.TypeParameters)} "); using Scope body = new(code); @@ -489,7 +569,12 @@ private void GenerateTrait(ClassDeclaration classDecl, string name, CodeBlocker { GenerateDocumentation(classDecl, code); WriteTypePromises(classDecl, code); - code.Write($"{SpellVisibilityOf(classDecl)}trait {name}"); + WriteUnaskedConstraints( + classDecl.TypeParameters, + code, + TypeConstraintKind.Implements, + TypeConstraintKind.Constructible); + code.Write($"{SpellVisibilityOf(classDecl)}trait {name}{SpellParameterDeclarations(classDecl.TypeParameters)}"); // A supertrait: something every implementation of this one must also be. A base type and an // interface are the same thing to a trait, which is the one place Rust answers the @@ -563,7 +648,7 @@ private void GenerateTraitImplementation(FunctionDeclaration funcDecl, string ty } GenerateDocumentation(funcDecl, code); - code.Write($"impl {op.Name} for {typeName} "); + code.Write($"impl{implBounds} {op.Name} for {typeName} "); using Scope block = new(code); @@ -624,7 +709,7 @@ private void GenerateConversion(FunctionDeclaration funcDecl, string typeName, C string target = SpellType(funcDecl.ReturnType ?? new TypeReference(UnknownTypeName)); GenerateDocumentation(funcDecl, code); - code.Write($"impl From<{typeName}> for {target} "); + code.Write($"impl{implBounds} From<{typeName}> for {target} "); using Scope block = new(code); code.Write($"fn from(value: {typeName}) -> {target} "); @@ -647,7 +732,7 @@ private void GenerateConversion(FunctionDeclaration funcDecl, string typeName, C private void GenerateDrop(FunctionDeclaration funcDecl, string typeName, CodeBlocker code) { GenerateDocumentation(funcDecl, code); - code.Write($"impl Drop for {typeName} "); + code.Write($"impl{implBounds} Drop for {typeName} "); using Scope block = new(code); code.Write("fn drop(&mut self) "); @@ -719,7 +804,13 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st code.Write("const "); } - code.Write($"fn {SpellFunctionName(funcDecl)}("); + WriteUnaskedConstraints( + funcDecl.TypeParameters, + code, + TypeConstraintKind.Implements, + TypeConstraintKind.Constructible); + + code.Write($"fn {SpellFunctionName(funcDecl)}{SpellParameterDeclarations(funcDecl.TypeParameters)}("); WriteReceiver(funcDecl, enclosingType, code); GenerateParameterList(funcDecl.Parameters, code); code.Write(")"); diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index ce178e7..1965d43 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -179,6 +179,7 @@ private void DeserializeFunctionBasicProperties(FunctionDeclaration funcDecl, Di DeserializeVisibility(funcDecl, dict); ReadStrings(dict, DocumentationKey, funcDecl.Documentation); + DeserializeTypeParameters(dict, funcDecl.TypeParameters); } /// @@ -783,6 +784,7 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) DeserializeVisibility(classDecl, dict); ReadStrings(dict, DocumentationKey, classDecl.Documentation); + DeserializeTypeParameters(dict, classDecl.TypeParameters); DeserializeTypeList(dict, "interfaces", classDecl.Interfaces); DeserializeTypeList(dict, "specialisationArguments", classDecl.SpecialisationArguments); @@ -792,6 +794,33 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) return classDecl; } + /// + /// Reads a sequence of written type parameters into a collection. + /// + /// The mapping to read from. + /// The collection to fill. + /// + /// One entry per parameter, carrying its constraints with it, because + /// and are inverses and + /// a parameter written on one line is a parameter a person can read. + /// + private static void DeserializeTypeParameters(Dictionary dict, Collection parameters) + { + if (!dict.TryGetValue("typeParameters", out object? writtenObj) || writtenObj is not List written) + { + return; + } + + IEnumerable spelled = written + .Select(parameter => parameter?.ToString() ?? string.Empty) + .Where(text => text.Length > 0); + + foreach (string text in spelled) + { + parameters.Add(TypeParameter.Parse(text)); + } + } + /// /// Reads a sequence of written types into a collection. /// diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index ac592dc..bb4671c 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -226,6 +226,12 @@ private static void SerializeFunctionDeclaration(FunctionDeclaration funcDecl, D SerializeFunctionShape(funcDecl, nodeData); + if (funcDecl.TypeParameters.Count > 0) + { + nodeData["typeParameters"] = + funcDecl.TypeParameters.Select(parameter => parameter.ToString()).ToList(); + } + if (funcDecl.Parameters.Count > 0) { nodeData["parameters"] = SerializeParameters(funcDecl.Parameters); @@ -597,6 +603,14 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio nodeData["baseType"] = classDecl.BaseType.ToString(); } + if (classDecl.TypeParameters.Count > 0) + { + // One line per parameter, constraints and all: TypeParameter.Parse and ToString are + // inverses, so what is written is what a person would write. + nodeData["typeParameters"] = + classDecl.TypeParameters.Select(parameter => parameter.ToString()).ToList(); + } + if (classDecl.Interfaces.Count > 0) { // Each on its own for the same reason the specialisation arguments are: an interface From ff9950b768c0dae750db0038b677916184ef5066 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 13:08:25 +0000 Subject: [PATCH 4/7] Teach a declaration to carry metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [minor] An `Annotation` on a `ClassDeclaration`, a `FunctionDeclaration` or a `FieldDeclaration`. C# calls it an attribute, C++ calls it an attribute and spells it differently, Rust calls it an attribute macro and Python calls it a decorator; the name here is the one that is nobody's keyword. Its `Name` and its `Arguments` are text, written verbatim, for the reason `CallExpression.Callee` is: `[Obsolete]`, `#[serde(rename = "x")]` and `@staticmethod` have nothing underneath them for the AST to hold, and one of them usually means nothing at all in the others. What *is* shared — and is the whole of what the four targets with metadata disagree about — is the syntax around them, so that is what each generator supplies through `SpellAnnotation`. The same split `SpellImport` already makes, and the same shape. C# [SuppressMessage("Usage", "CA2225:…")] C++ [[SuppressMessage("Usage", "CA2225:…")]] Rust #[SuppressMessage("Usage", "CA2225:…")] Python @SuppressMessage("Usage", "CA2225:…") C, JavaScript and Go have no metadata syntax and write the annotation down rather than dropping it, which is what `WriteInexpressible` is for: a file that quietly loses its `[Obsolete]` looks like a file that never had one. The arguments are a sequence rather than one string so that a comma inside an argument stays inside it — `SuppressMessage("Usage", "CA2225:Operator overloads have named alternates")` has two arguments and three commas — and the reader that splits them back out of a document respects quotes and brackets for the same reason. Nine more tests, including the round trip that proves the commas survive it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 10 ++ .../Languages/TypeDeclarationShapeTests.cs | 115 ++++++++++++++++++ Coder/Ast/Annotation.cs | 93 ++++++++++++++ Coder/Ast/ClassDeclaration.cs | 10 ++ Coder/Ast/FieldDeclaration.cs | 10 ++ Coder/Ast/FunctionDeclaration.cs | 10 ++ Coder/Languages/CGenerator.cs | 1 + Coder/Languages/CSharpGenerator.cs | 10 ++ Coder/Languages/CppGenerator.cs | 10 ++ Coder/Languages/GoGenerator.cs | 3 + Coder/Languages/JavaScriptGenerator.cs | 1 + Coder/Languages/LanguageGeneratorBase.cs | 41 +++++++ Coder/Languages/PythonGenerator.cs | 9 ++ Coder/Languages/RustGenerator.cs | 7 ++ Coder/Serialization/YamlDeserializer.cs | 113 +++++++++++++++++ Coder/Serialization/YamlSerializer.cs | 22 ++++ 16 files changed, 465 insertions(+) create mode 100644 Coder/Ast/Annotation.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6776f43..8331991 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,16 @@ source in seven target languages. The solution uses: 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/Annotation.cs` — metadata attached to a declaration: an attribute in C# and C++, an + attribute macro in Rust, a decorator in Python. The name here is the one that is nobody's keyword. + Its `Name` and `Arguments` are **text, written verbatim**, for the reason `CallExpression.Callee` + is: `[Obsolete]`, `#[serde(rename = "x")]` and `@staticmethod` have nothing underneath them to + hold, and one of them usually means nothing at all in the others. What *is* shared, and is what + each generator supplies, is the syntax around them — `[…]`, `[[…]]`, `#[…]`, `@…` — which is + exactly the split `SpellImport` already makes. C, JavaScript and Go have no metadata syntax and + write the annotation down, because a file that quietly loses its `[Obsolete]` looks like a file + that never had one. The arguments are a sequence rather than one string so that a comma inside an + argument stays inside it. - `Coder/Ast/TypeParameter.cs` and `TypeConstraint.cs` — what a declaration is written *over*, on `ClassDeclaration` and on `FunctionDeclaration`. Values rather than nodes, like `SpecialisationArguments` and for the same reason: a type parameter is part of the thing being diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index e65d14f..8e4c6af 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -475,6 +475,121 @@ public void TypeParameter_ParseAndToStringAreInverses() Assert.AreEqual("U", TypeParameter.Parse("U").ToString()); } + // ------------------------------------------------------------------ Annotations + + /// + /// A declaration carrying the attribute ktsu.Semantics puts on its physics operators. + /// + /// The declaration. + private static FunctionDeclaration Suppressed() + { + FunctionDeclaration multiply = new("Multiply") { ReturnType = "Energy", IsStatic = true }; + Annotation suppress = new("SuppressMessage"); + suppress.Arguments.Add("\"Usage\""); + suppress.Arguments.Add("\"CA2225:Operator overloads have named alternates\""); + multiply.Annotations.Add(suppress); + return multiply; + } + + /// + /// The four targets with a metadata syntax each write the same name in their own brackets. + /// + /// The generator to ask. + /// The line it should write. + /// + /// The name and the arguments are the caller's, written verbatim — a [TestMethod] means + /// nothing outside the framework that reads it, so there is nothing underneath for the AST to + /// translate. What is around them is the language's, and that is the whole of what these four + /// disagree about. + /// + [TestMethod] + [DataRow("csharp", "[Obsolete(\"use Mass\")]")] + [DataRow("cpp", "[[Obsolete(\"use Mass\")]]")] + [DataRow("rust", "#[Obsolete(\"use Mass\")]")] + [DataRow("python", "@Obsolete(\"use Mass\")")] + public void TargetsWithMetadataSyntax_WriteItInTheirOwnBrackets(string language, string expected) + { + ClassDeclaration mass = new("Mass"); + Annotation obsolete = new("Obsolete"); + obsolete.Arguments.Add("\"use Mass\""); + mass.Annotations.Add(obsolete); + + ILanguageGenerator generator = language switch + { + "csharp" => new CSharpGenerator(), + "cpp" => new CppGenerator(), + "rust" => new RustGenerator(), + _ => new PythonGenerator(), + }; + + StringAssert.Contains(Generate(generator, mass), expected); + } + + /// + /// A target with no metadata syntax writes the annotation down rather than dropping it. + /// + /// The generator to ask. + /// + /// A file that quietly loses its [Obsolete] looks like a file that never had one. + /// + [TestMethod] + [DataRow("c")] + [DataRow("javascript")] + [DataRow("go")] + public void TargetsWithNoMetadataSyntax_WriteItDown(string language) + { + ILanguageGenerator generator = language switch + { + "c" => new CGenerator(), + "javascript" => new JavaScriptGenerator(), + _ => new GoGenerator(), + }; + + ClassDeclaration mass = new("Mass"); + mass.Annotations.Add(new Annotation("Obsolete")); + + StringAssert.Contains(Generate(generator, mass), "// annotated Obsolete"); + } + + /// + /// A function's annotations are written above it, arguments and all. + /// + [TestMethod] + public void CSharp_WritesAFunctionsAnnotations() + { + StringAssert.Contains( + Generate(new CSharpGenerator(), Suppressed()), + "[SuppressMessage(\"Usage\", \"CA2225:Operator overloads have named alternates\")]"); + } + + /// + /// An annotation survives a trip through YAML, and the comma inside an argument stays inside it. + /// + /// + /// Which is the whole reason the arguments are a sequence rather than one string: + /// SuppressMessage("Usage", "CA2225:Operator overloads have named alternates") has two + /// arguments and three commas. + /// + [TestMethod] + public void Yaml_CarriesAnAnnotationWithCommasInsideItsArguments() + { + FunctionDeclaration original = Suppressed(); + original.Annotations.Add(new Annotation("Pure")); + + ktsu.Coder.Serialization.YamlSerializer serializer = new(); + ktsu.Coder.Serialization.YamlDeserializer deserializer = new(); + + FunctionDeclaration restored = (FunctionDeclaration)deserializer.Deserialize(serializer.Serialize(original))!; + + Assert.AreEqual(2, restored.Annotations.Count); + Assert.AreEqual("SuppressMessage", restored.Annotations[0].Name); + Assert.AreSequenceEqual( + (string[])["\"Usage\"", "\"CA2225:Operator overloads have named alternates\""], + [.. restored.Annotations[0].Arguments]); + Assert.AreEqual("Pure", restored.Annotations[1].Name); + Assert.IsEmpty(restored.Annotations[1].Arguments); + } + // ------------------------------------------------------------------ Round trip /// diff --git a/Coder/Ast/Annotation.cs b/Coder/Ast/Annotation.cs new file mode 100644 index 0000000..6c4db44 --- /dev/null +++ b/Coder/Ast/Annotation.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System; +using System.Collections.ObjectModel; + +/// +/// One piece of metadata attached to a declaration. +/// +/// +/// C# calls it an attribute, Rust and C++ call it an attribute and spell it differently again, +/// Python calls it a decorator and Java calls it an annotation. The name here is the one that is +/// nobody's keyword. +/// +/// The and the are text, written verbatim, for the +/// reason is: [Obsolete], #[serde(rename = "x")] +/// and @staticmethod have nothing underneath them for the AST to hold, and one of them +/// usually means nothing at all in the others. What is shared, and is what each generator +/// supplies, is the syntax around them — […], #[…], [[…]], @… — and +/// whether the target has any at all. +/// +/// +/// So an annotation is written for a language, the same as are, +/// and the three targets with no metadata syntax write down the one they were given rather than +/// dropping it. A file that quietly loses its [Obsolete] looks like a file that never had +/// one. +/// +/// +public sealed class Annotation : IEquatable +{ + /// + /// Initializes a new instance of the class. + /// + public Annotation() + { + } + + /// + /// Initializes a new instance of the class with a name. + /// + /// What the annotation is called, as the target language spells it. + public Annotation(string name) => Name = name; + + /// + /// Gets or sets what the annotation is called, as the target language spells it. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets the arguments it is given, each written the way the target language writes it. + /// + /// + /// A sequence rather than one string, so a comma inside an argument stays inside it: + /// SuppressMessage("Usage", "CA2225:Operator overloads have named alternates") has two + /// arguments and three commas. + /// + public Collection Arguments { get; init; } = []; + + /// + /// Creates a copy. + /// + /// The copy. + public Annotation Clone() + { + Annotation clone = new(Name); + + foreach (string argument in Arguments) + { + clone.Arguments.Add(argument); + } + + return clone; + } + + /// + /// + /// The name and its arguments with no syntax around them, which is the part every target shares + /// and the part a target with no metadata syntax writes down. + /// + public override string ToString() => + Arguments.Count == 0 ? Name : $"{Name}({string.Join(", ", Arguments)})"; + + /// + public bool Equals(Annotation? other) => + other is not null && string.Equals(ToString(), other.ToString(), StringComparison.Ordinal); + + /// + public override bool Equals(object? obj) => Equals(obj as Annotation); + + /// + public override int GetHashCode() => ToString().GetHashCode(StringComparison.Ordinal); +} diff --git a/Coder/Ast/ClassDeclaration.cs b/Coder/Ast/ClassDeclaration.cs index 89ccad4..de569f2 100644 --- a/Coder/Ast/ClassDeclaration.cs +++ b/Coder/Ast/ClassDeclaration.cs @@ -83,6 +83,11 @@ public ClassDeclaration() /// public bool IsSpecialisation => SpecialisationArguments.Count > 0; + /// + /// Gets the metadata attached to this declaration, which may be none. + /// + public Collection Annotations { get; init; } = []; + /// /// Gets the types this declaration is written over, which may be none. /// @@ -199,6 +204,11 @@ public override AstNode Clone() clone.TypeParameters.Add(parameter.Clone()); } + foreach (Annotation annotation in Annotations) + { + clone.Annotations.Add(annotation.Clone()); + } + foreach ((string key, object? value) in Metadata) { clone.Metadata[key] = value; diff --git a/Coder/Ast/FieldDeclaration.cs b/Coder/Ast/FieldDeclaration.cs index 596d3a9..9fbfcfd 100644 --- a/Coder/Ast/FieldDeclaration.cs +++ b/Coder/Ast/FieldDeclaration.cs @@ -82,6 +82,11 @@ public FieldDeclaration(string name, TypeReference? type = null) /// public Collection Documentation { get; init; } = []; + /// + /// Gets the metadata attached to this declaration, which may be none. + /// + public Collection Annotations { get; init; } = []; + /// /// Gets the type name of this node for serialization purposes. /// @@ -114,6 +119,11 @@ public override AstNode Clone() clone.Documentation.Add(line); } + foreach (Annotation annotation in Annotations) + { + clone.Annotations.Add(annotation.Clone()); + } + return clone; } } diff --git a/Coder/Ast/FunctionDeclaration.cs b/Coder/Ast/FunctionDeclaration.cs index 879a829..900f009 100644 --- a/Coder/Ast/FunctionDeclaration.cs +++ b/Coder/Ast/FunctionDeclaration.cs @@ -167,6 +167,11 @@ public FunctionDeclaration() /// public Collection Parameters { get; init; } = []; + /// + /// Gets the metadata attached to this declaration, which may be none. + /// + public Collection Annotations { get; init; } = []; + /// /// Gets the types this function is written over, which may be none. /// @@ -235,6 +240,11 @@ public override AstNode Clone() clone.TypeParameters.Add(typeParameter.Clone()); } + foreach (Annotation annotation in Annotations) + { + clone.Annotations.Add(annotation.Clone()); + } + foreach (Parameter parameter in Parameters) { clone.Parameters.Add((Parameter)parameter.Clone()); diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 2671bf6..8aa6d06 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -505,6 +505,7 @@ .. classDecl.Members.Where(member => } GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); WriteTypeParametersDown(classDecl.TypeParameters, code); diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 8aa795a..0569768 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -162,6 +162,7 @@ private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code) private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); // C++ can attach a declaration to a type it does not own, by specialising a template on it. // Nothing here can, so the fact is written down rather than lost: what follows is an @@ -277,6 +278,13 @@ private static IEnumerable InDeclarationOrder(IEnumerable 1, }); + /// + /// + /// Square brackets, and one attribute per line rather than several in one pair of them: a + /// declaration with four of them reads down the page, and a diff that adds one touches one line. + /// + protected override string? SpellAnnotation(Annotation annotation) => $"[{annotation}]"; + /// protected override string? SpellImport(string import) => $"using {import};"; @@ -492,6 +500,7 @@ private void GenerateEnum(EnumDeclaration enumDecl, CodeBlocker code) private void GenerateField(FieldDeclaration field, CodeBlocker code) { GenerateDocumentation(field, code); + WriteAnnotations(field.Annotations, code); code.Write($"{SpellVisibility(field.Visibility) ?? DefaultVisibility} "); @@ -560,6 +569,7 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st return; } + WriteAnnotations(function.Annotations, code); WriteFunctionAttributes(function, code); WriteFunctionModifiers(function, code); diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index 456d61e..6ecb693 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -93,6 +93,13 @@ private static void WriteTemplateHead(IEnumerable parameters, Cod } } + /// + /// + /// Doubled brackets, which is the standard syntax rather than a compiler's own. An attribute + /// the compiler does not know is ignored with a warning rather than refused, which is what + /// makes writing a caller's attribute through safe here. + /// + protected override string? SpellAnnotation(Annotation annotation) => $"[[{annotation}]]"; /// /// /// A constructor and a destructor are named after the type rather than after themselves, so the @@ -112,6 +119,7 @@ protected override void GenerateFunction(FunctionDeclaration funcDecl, CodeBlock Ensure.NotNull(code); GenerateDocumentation(funcDecl, code); + WriteAnnotations(funcDecl.Annotations, code); WriteUnaskedConstraints(funcDecl.TypeParameters, code); WriteTemplateHead(funcDecl.TypeParameters, code); @@ -339,6 +347,7 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod WriteTemplateHead(classDecl.TypeParameters, code); + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); // Every constraint. A concept is a predicate over a type and can ask anything at all, so @@ -551,6 +560,7 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo Ensure.NotNull(code); GenerateDocumentation(field, code); + WriteAnnotations(field.Annotations, code); code.Write(SpellStorage(field)); code.Write(SpellDeclarator(field.Type ?? new TypeReference(UnknownTypeName), field.Name ?? string.Empty)); diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index 691c116..867d6de 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -585,6 +585,7 @@ private static bool IsTypeDeclaration(AstNode member) => private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); // Go has generics, and a generic type is still written down here. A method on one needs its @@ -677,6 +678,7 @@ private AlignedLine Field(string? name, TypeReference? type, Visibility visibili private void GenerateInterface(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); WriteTypeParametersDown(classDecl.TypeParameters, code); WriteExportNote(name, classDecl.Visibility, code); @@ -793,6 +795,7 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st return; } + WriteAnnotations(funcDecl.Annotations, code); WriteExportNote(name, funcDecl.Visibility, code); code.Write("func "); diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 94c0f89..634a39a 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -273,6 +273,7 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod WriteInexpressible(code, $"specialised for {string.Join(", ", classDecl.SpecialisationArguments)}"); } + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); WriteTypeParametersDown(classDecl.TypeParameters, code); diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index d725f34..a0df1f6 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -189,6 +189,47 @@ protected void WriteInexpressible(CodeBlocker code, string what) /// protected virtual string CommentPrefix => "//"; + /// + /// Spells one piece of metadata attached to a declaration, or reports that the language has no + /// syntax for one. + /// + /// The annotation as the declaration carries it. + /// The line to write, or null when the language has no metadata syntax. + /// + /// The same shape as , and for the same reason: what the annotation + /// says is the caller's — a [TestMethod] means nothing outside the framework that reads + /// it — and what is around it is the language's. Four targets have somewhere to put one and + /// three do not. + /// + protected virtual string? SpellAnnotation(Annotation annotation) => null; + + /// + /// Writes a declaration's metadata, above the declaration. + /// + /// The annotations the declaration carries. + /// The writer to emit into. + /// + /// A target with no metadata syntax writes down the one it was given rather than dropping it. A + /// file that quietly loses its [Obsolete] looks like a file that never had one. + /// + protected void WriteAnnotations(IEnumerable annotations, CodeBlocker code) + { + Ensure.NotNull(annotations); + Ensure.NotNull(code); + + foreach (Annotation annotation in annotations) + { + if (SpellAnnotation(annotation) is string spelled) + { + code.WriteLine(spelled); + } + else + { + WriteInexpressible(code, $"annotated {annotation}"); + } + } + } + /// /// Writes down the types a declaration is written over, for a target that has no generics. /// diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 9af0957..0d1690a 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -86,6 +86,14 @@ protected override void GenerateConditionalExpression(ConditionalExpression cond /// protected override string CommentPrefix => "#"; + /// + /// + /// A decorator, which is the one of these that is an ordinary runtime value: @name looks + /// the name up and calls it on what follows. That makes it the closest of the four to being a + /// caller's own, and the least likely to be something the language itself reads. + /// + protected override string? SpellAnnotation(Annotation annotation) => $"@{annotation}"; + /// protected override string? SpellImport(string import) => $"import {import}"; @@ -316,6 +324,7 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod // needs an import, and a class is generated on its own as readily as inside a file whose // imports the AST carries. Emitting one would be a change to how this generator writes a // file rather than to how it writes a class. + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); // Python has TypeVar and Generic, and both need an import the AST does not carry for a diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index dd656b3..f2295a5 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -275,6 +275,8 @@ private sealed record OperatorTrait(string Name, string Method, bool ReturnsBool /// public override string FileExtension => "rs"; + /// + protected override string? SpellAnnotation(Annotation annotation) => $"#[{annotation}]"; /// /// /// use, which is what Rust writes where the others write an include or an import. A path @@ -483,6 +485,7 @@ private static bool IsTypeDeclaration(AstNode member) => private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); // A trait a struct implements needs an impl block, and an impl block needs the bodies of // the methods it supplies, which the declaration does not have: the members here belong to @@ -568,6 +571,7 @@ private static void WriteStructMember(string? name, TypeReference? type, Visibil private void GenerateTrait(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); WriteTypePromises(classDecl, code); WriteUnaskedConstraints( classDecl.TypeParameters, @@ -787,6 +791,8 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st return; } + WriteAnnotations(funcDecl.Annotations, code); + // #[must_use] says what a pure function's purity means to a caller, and is worth nothing on // one that answers nothing. if ((funcDecl.IsPure || funcDecl.MustUseResult) && ReturnsAValue(funcDecl)) @@ -1022,6 +1028,7 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo if (insideType > 0) { GenerateDocumentation(field, code); + WriteAnnotations(field.Annotations, code); WriteStructMember(field.Name, field.Type, field.Visibility, code); return; } diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 1965d43..5b6df03 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -179,6 +179,7 @@ private void DeserializeFunctionBasicProperties(FunctionDeclaration funcDecl, Di DeserializeVisibility(funcDecl, dict); ReadStrings(dict, DocumentationKey, funcDecl.Documentation); + DeserializeAnnotations(dict, funcDecl.Annotations); DeserializeTypeParameters(dict, funcDecl.TypeParameters); } @@ -680,6 +681,7 @@ private FieldDeclaration DeserializeFieldDeclaration(object? nodeData) DeserializeVisibility(field, dict); ReadStrings(dict, DocumentationKey, field.Documentation); + DeserializeAnnotations(dict, field.Annotations); if (dict.TryGetValue("initialValue", out object? initialObj) && initialObj is Dictionary initialDict && initialDict.Count > 0) @@ -784,6 +786,7 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) DeserializeVisibility(classDecl, dict); ReadStrings(dict, DocumentationKey, classDecl.Documentation); + DeserializeAnnotations(dict, classDecl.Annotations); DeserializeTypeParameters(dict, classDecl.TypeParameters); DeserializeTypeList(dict, "interfaces", classDecl.Interfaces); DeserializeTypeList(dict, "specialisationArguments", classDecl.SpecialisationArguments); @@ -794,6 +797,116 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) return classDecl; } + /// + /// Reads a declaration's metadata into a collection. + /// + /// The mapping to read from. + /// The collection to fill. + /// + /// Each written the way writes it: a name, and its arguments + /// in brackets when it has any. The arguments are split at the commas outside any brackets of + /// their own, so an argument holding one keeps it. + /// + private static void DeserializeAnnotations(Dictionary dict, Collection annotations) + { + if (!dict.TryGetValue("annotations", out object? writtenObj) || writtenObj is not List written) + { + return; + } + + IEnumerable spelled = written + .Select(annotation => annotation?.ToString() ?? string.Empty) + .Where(text => text.Length > 0); + + foreach (string text in spelled) + { + annotations.Add(ReadAnnotation(text)); + } + } + + /// + /// Reads one written annotation. + /// + /// The annotation as it is written. + /// The annotation. + private static Annotation ReadAnnotation(string text) + { + string written = text.Trim(); + int opened = written.IndexOf('('); + + if (opened < 0 || !written.EndsWith(')')) + { + return new Annotation(written); + } + + Annotation annotation = new(written[..opened].Trim()); + + foreach (string argument in SplitArguments(written[(opened + 1)..^1])) + { + string trimmed = argument.Trim(); + if (trimmed.Length > 0) + { + annotation.Arguments.Add(trimmed); + } + } + + return annotation; + } + + /// + /// Splits an argument list at the commas that separate its entries. + /// + /// The list, without the brackets around it. + /// The entries. + /// + /// A comma inside a string or inside brackets of an argument's own belongs to it: + /// SuppressMessage("Usage", "CA2225:Operator overloads have named alternates") has two + /// arguments and three commas. + /// + private static IEnumerable SplitArguments(string text) + { + int depth = 0; + bool quoted = false; + int start = 0; + + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + + if (character == '"') + { + quoted = !quoted; + continue; + } + + if (quoted) + { + continue; + } + + switch (character) + { + case '(' or '[' or '<': + depth++; + break; + + case ')' or ']' or '>': + depth--; + break; + + case ',' when depth == 0: + yield return text[start..index]; + start = index + 1; + break; + + default: + break; + } + } + + yield return text[start..]; + } + /// /// Reads a sequence of written type parameters into a collection. /// diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index bb4671c..4f5cb10 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -2,6 +2,7 @@ namespace ktsu.Coder.Serialization; +using System.Collections.ObjectModel; using System.Collections.Generic; using System.Linq; using ktsu.Coder.Ast; @@ -224,6 +225,7 @@ private static void SerializeFunctionDeclaration(FunctionDeclaration funcDecl, D nodeData["isPure"] = funcDecl.IsPure; } + SerializeAnnotations(funcDecl.Annotations, nodeData); SerializeFunctionShape(funcDecl, nodeData); if (funcDecl.TypeParameters.Count > 0) @@ -496,6 +498,7 @@ private static void SerializeFieldDeclaration(FieldDeclaration field, Dictionary SerializeVisibility(field, nodeData); SerializeDocumentation(field, nodeData); + SerializeAnnotations(field.Annotations, nodeData); if (field.InitialValue != null) { @@ -586,6 +589,24 @@ private static void SerializeDocumentation(IHasDocumentation declaration, Dictio } } + /// + /// Writes a declaration's metadata, when it has any. + /// + /// The annotations the declaration carries. + /// The mapping to write into. + /// + /// One line per annotation, name and arguments together, because + /// writes what a person would and the syntax around it is the + /// generator's rather than the document's. + /// + private static void SerializeAnnotations(Collection annotations, Dictionary nodeData) + { + if (annotations.Count > 0) + { + nodeData["annotations"] = annotations.Select(annotation => annotation.ToString()).ToList(); + } + } + private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictionary nodeData) { if (classDecl.Name != null) @@ -645,6 +666,7 @@ private static void SerializeClassDeclaration(ClassDeclaration classDecl, Dictio SerializeVisibility(classDecl, nodeData); SerializeDocumentation(classDecl, nodeData); + SerializeAnnotations(classDecl.Annotations, nodeData); if (classDecl.Members.Count > 0) { From c54334618fba350a32db8c15d6b1ebb4e8ba5b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 13:20:24 +0000 Subject: [PATCH 5/7] Teach the AST what a property is, and where one lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [minor] A `PropertyDeclaration`: a named, typed member read and written through code rather than stored. C# spells it `T Name { get; set; }`, Python `@property`, JavaScript `get name()`. The other four have the two halves of what it is and no word joining them, which makes the decision here not the syntax but **where a property lands**. It lands in one of two places, and neither is an approximation: A property whose accessors have no bodies IS a field with a storage location the compiler supplies. One with bodies IS a pair of functions. So a target with no properties writes the field, or writes the pair, and both are what a person would have written. That is also why `FieldDeclaration` is not reused for the first: a field says where a value is kept and this says how it is reached, a difference that is invisible in C# and load-bearing everywhere else. The separation happens to the *member list*, in `StandardLanguageGenerator`, and that is the whole of why it works. Rust puts data in a `struct` and behaviour in an `impl`; Go writes fields in the type and methods beside it; C lowers a method to a free function taking the instance. Every one of those routes reads the member list before anything is written, so a property separated at the point it is written arrives after the routing that decides all of that and comes out wherever it happened to be standing — which is what the first attempt did, and what put a member function inside a C struct. Separated first, each generator's existing routing sees an ordinary field or an ordinary function and needs to know nothing about properties at all. Rust pub Value: i32, / pub fn doubled(&self) -> i32 Go Value int / func (self Box) Doubled() int C++ int Value{}; / int doubled() const C int Value; / int Box_doubled(const Box* self) Two things fall out of it. A property may be readable and not writable and a field is neither or both, so the field carries that in its own documentation rather than in a note beside it — which means it travels to wherever the target puts the field. And a separated setter has no name until somebody invents one, so `NamingStyle` gives each target its own convention: rustc warns on a member name that is not snake case, and an unexported Go name cannot be called from outside its package, so two of the four are more than taste. The AST still renames nothing it was given; this is only for the names a generator has to make up. `AstSchema` gives the property two statement slots, `AstFields` its seven editable properties, and the palette an entry — all three of which the exhaustiveness test from the first commit here demanded rather than my remembering. `TryDetachAt` is split in two along the same line the three attach methods already are, the sequences having outgrown one switch. Eighteen more tests, over what each of the seven writes for both kinds and over the round trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- CLAUDE.md | 15 ++ Coder.Graph/AstFields.cs | 24 ++ Coder.Graph/AstNodeCatalog.cs | 1 + Coder.Graph/AstSchema.cs | 106 +++++++-- .../Languages/TypeDeclarationShapeTests.cs | 210 ++++++++++++++++++ Coder/Ast/NamingStyle.cs | 68 ++++++ Coder/Ast/PropertyDeclaration.cs | 199 +++++++++++++++++ Coder/Languages/CGenerator.cs | 8 + Coder/Languages/CSharpGenerator.cs | 69 ++++++ Coder/Languages/CppGenerator.cs | 8 + Coder/Languages/GoGenerator.cs | 8 + Coder/Languages/JavaScriptGenerator.cs | 56 +++++ Coder/Languages/PythonGenerator.cs | 72 ++++++ Coder/Languages/RustGenerator.cs | 8 + Coder/Languages/StandardLanguageGenerator.cs | 156 +++++++++++++ Coder/Serialization/YamlDeserializer.cs | 65 ++++++ Coder/Serialization/YamlSerializer.cs | 60 +++++ 17 files changed, 1110 insertions(+), 23 deletions(-) create mode 100644 Coder/Ast/NamingStyle.cs create mode 100644 Coder/Ast/PropertyDeclaration.cs diff --git a/CLAUDE.md b/CLAUDE.md index 8331991..355a039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,21 @@ source in seven target languages. The solution uses: 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/PropertyDeclaration.cs` — a member read and written through code rather than stored. + Three targets have the thing itself (C# `T Name { get; set; }`, Python `@property`, JavaScript + `get name()`); the other four have the two halves of what it is and no word joining them, which + makes the decision here not the syntax but **where a property lands**. A property whose accessors + have no bodies *is* a field with a storage location the compiler supplies, so it comes out as a + field; one with bodies *is* a pair of functions, so it comes out as the pair. Neither is an + approximation. The separation happens to the member list, in `StandardLanguageGenerator.Separated`, + rather than at the point each member is written — Rust puts data in a `struct` and behaviour in an + `impl`, Go writes fields in the type and methods beside it, C lowers a method to a free function + taking the instance, and every one of those routes reads the member list before anything is + written, so a property separated afterwards arrives too late to be routed. Separated first, each + generator's existing routing sees an ordinary field or function and needs to know nothing about + properties. `NamingStyle` is for the setter's name, which is the one name a generator has to + *invent* rather than repeat: rustc warns on a member name that is not snake case and an unexported + Go name cannot be called from outside its package, so the convention is more than taste. - `Coder/Ast/Annotation.cs` — metadata attached to a declaration: an attribute in C# and C++, an attribute macro in Rust, a decorator in Python. The name here is the one that is nobody's keyword. Its `Name` and `Arguments` are **text, written verbatim**, for the reason `CallExpression.Callee` diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index 0d75e2a..bc7f879 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -198,6 +198,17 @@ public static IReadOnlyList Of(AstNode node) new("Constant", AstFieldKind.Flag, Spell(fieldDecl.IsConstant)), ], + PropertyDeclaration property => + [ + new("Name", AstFieldKind.Text, property.Name ?? string.Empty), + new(TypeField, AstFieldKind.Text, property.Type?.ToString() ?? string.Empty), + new(VisibilityField, AstFieldKind.Choice, property.Visibility.ToString(), Visibilities), + new("Static", AstFieldKind.Flag, Spell(property.IsStatic)), + new("Readable", AstFieldKind.Flag, Spell(property.HasGetter)), + new("Writable", AstFieldKind.Flag, Spell(property.HasSetter)), + new("InitOnly", AstFieldKind.Flag, Spell(property.SetterIsInitOnly)), + ], + ClassDeclaration classDecl => [ new("Name", AstFieldKind.Text, classDecl.Name ?? string.Empty), @@ -415,6 +426,19 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (FieldDeclaration fieldDecl, "Constant") => TryParseBool(value, out bool fieldIsConstant) && Assign(() => fieldDecl.IsConstant = fieldIsConstant), + (PropertyDeclaration property, "Name") => Assign(() => property.Name = OrNull(value)), + (PropertyDeclaration property, TypeField) => Assign(() => property.Type = OrNull(value)), + (PropertyDeclaration property, VisibilityField) => + TryParseVisibility(value, out Visibility propertyVisibility) && Assign(() => property.Visibility = propertyVisibility), + (PropertyDeclaration property, "Static") => + TryParseBool(value, out bool propertyIsStatic) && Assign(() => property.IsStatic = propertyIsStatic), + (PropertyDeclaration property, "Readable") => + TryParseBool(value, out bool readable) && Assign(() => property.HasGetter = readable), + (PropertyDeclaration property, "Writable") => + TryParseBool(value, out bool writable) && Assign(() => property.HasSetter = writable), + (PropertyDeclaration property, "InitOnly") => + TryParseBool(value, out bool initOnly) && Assign(() => property.SetterIsInitOnly = initOnly), + (ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)), (ClassDeclaration classDecl, "Kind") => Enum.TryParse(value, out TypeDeclarationKind typeKind) && Assign(() => classDecl.Kind = typeKind), diff --git a/Coder.Graph/AstNodeCatalog.cs b/Coder.Graph/AstNodeCatalog.cs index 0ef779a..9bca422 100644 --- a/Coder.Graph/AstNodeCatalog.cs +++ b/Coder.Graph/AstNodeCatalog.cs @@ -59,6 +59,7 @@ public static class AstNodeCatalog new(Declarations, "Class", () => new ClassDeclaration("NewClass")), new(Declarations, "Function", () => new FunctionDeclaration("newFunction") { ReturnType = "void" }), new(Declarations, "Parameter", () => new Parameter("value", "int")), + new(Declarations, "Property", () => new PropertyDeclaration("Value", "int") { HasSetter = true }), new(Declarations, "Variable", () => new VariableDeclaration("value", "int")), new(Declarations, "Constant", () => new VariableDeclaration("VALUE", "int", Literal.Number(0)) { IsConstant = true }), new(Declarations, "Entry point", () => new EntryPoint()), diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs index 7f02872..887fd30 100644 --- a/Coder.Graph/AstSchema.cs +++ b/Coder.Graph/AstSchema.cs @@ -35,6 +35,15 @@ public static class AstSchema private static readonly AstSlot ParametersSlot = new("Parameters", AstSlotCardinality.Many, AstSlotKind.Parameter); private static readonly AstSlot BodySlot = new("Body", AstSlotCardinality.Many, AstSlotKind.Statement); private static readonly AstSlot MembersSlot = new("Members", AstSlotCardinality.Many, AstSlotKind.Member); + + /// The name of the slot a property's read accessor keeps its statements in. + private const string GetterSlotName = "Get"; + + /// The name of the slot a property's write accessor keeps its statements in. + private const string SetterSlotName = "Set"; + + private static readonly AstSlot GetterSlot = new(GetterSlotName, AstSlotCardinality.Many, AstSlotKind.Statement); + private static readonly AstSlot SetterSlot = new(SetterSlotName, AstSlotCardinality.Many, AstSlotKind.Statement); /// The name of the slot an expression's arguments sit in. private const string ArgumentsSlotName = "Arguments"; @@ -72,6 +81,7 @@ public static class AstSchema ClassDeclaration => [MembersSlot], EnumDeclaration => [EnumMembersSlot], FieldDeclaration => [InitialValueSlot], + PropertyDeclaration => [GetterSlot, SetterSlot], MemberInitialiser => [ValueSlot], ConstructionExpression => [ArgumentsSlot], CallExpression => [ReceiverSlot, ArgumentsSlot], @@ -133,6 +143,8 @@ public static IReadOnlyList ChildrenOf(AstNode node, AstSlot slot) (CallExpression callExpr, ArgumentsSlotName) => [.. callExpr.Arguments], (FunctionDeclaration function, "Parameters") => [.. function.Parameters], (FunctionDeclaration function, "Body") => [.. function.Body], + (PropertyDeclaration property, GetterSlotName) => [.. property.GetterBody], + (PropertyDeclaration property, SetterSlotName) => [.. property.SetterBody], (EntryPoint entryPoint, "Body") => [.. entryPoint.Body], _ when SlotsOf(node).Contains(slot) => [], _ => throw new ArgumentException($"{node.GetNodeTypeName()} has no slot named '{slot.Name}'.", nameof(slot)), @@ -297,6 +309,14 @@ private static bool TryAttachSequence(AstNode parent, AstSlot slot, AstNode chil function.Body.Add(child); return true; + case (PropertyDeclaration property, GetterSlotName): + property.GetterBody.Add(child); + return true; + + case (PropertyDeclaration property, SetterSlotName): + property.SetterBody.Add(child); + return true; + case (EntryPoint entryPoint, "Body"): entryPoint.Body.Add(child); return true; @@ -342,6 +362,14 @@ private static bool TryReplaceAt(AstNode parent, AstSlot slot, int index, AstNod function.Body[index] = child; return true; + case (PropertyDeclaration property, GetterSlotName): + property.GetterBody[index] = child; + return true; + + case (PropertyDeclaration property, SetterSlotName): + property.SetterBody[index] = child; + return true; + case (EntryPoint entryPoint, "Body"): entryPoint.Body[index] = child; return true; @@ -444,6 +472,52 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index) assignment.Value = Unfilled(); return true; + // A receiver is genuinely optional -- a call with none is a free function rather than an + // unfinished member call -- so detaching one clears it instead of leaving a placeholder. + case (CallExpression callExpr, ReceiverSlotName): + bool hadReceiver = callExpr.Receiver is not null; + callExpr.Receiver = null; + return hadReceiver; + + case (ConditionalExpression conditional, ConditionSlotName): + conditional.Condition = Unfilled(); + return true; + + case (ConditionalExpression conditional, WhenTrueSlotName): + conditional.WhenTrue = Unfilled(); + return true; + + case (ConditionalExpression conditional, WhenFalseSlotName): + conditional.WhenFalse = Unfilled(); + return true; + + case (ExpressionStatement statement, "Expression"): + statement.Expression = Unfilled(); + return true; + + default: + return TryDetachFromSequence(parent, slot, index); + } + } + + /// + /// Removes a child from one of the sequences a node owns. + /// + /// The parent node. + /// The slot to remove from. + /// The position to remove. + /// True if a child was removed; false if this is not one of these slots, or the + /// position is past the end of it. + /// + /// Split from the single-valued slots for the reason the three attach methods already are: one + /// switch over every slot the AST has is more branches than the analyzer accepts. The line is + /// the same one the cardinality draws — a sequence loses an entry and keeps its order, and a + /// single-valued slot goes back to whatever standing empty means for it. + /// + private static bool TryDetachFromSequence(AstNode parent, AstSlot slot, int index) + { + switch (parent, slot.Name) + { case (FunctionDeclaration function, "Parameters") when index < function.Parameters.Count: function.Parameters.RemoveAt(index); return true; @@ -452,6 +526,14 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index) function.Body.RemoveAt(index); return true; + case (PropertyDeclaration property, GetterSlotName) when index < property.GetterBody.Count: + property.GetterBody.RemoveAt(index); + return true; + + case (PropertyDeclaration property, SetterSlotName) when index < property.SetterBody.Count: + property.SetterBody.RemoveAt(index); + return true; + case (EntryPoint entryPoint, "Body") when index < entryPoint.Body.Count: entryPoint.Body.RemoveAt(index); return true; @@ -480,29 +562,6 @@ public static bool TryDetachAt(AstNode parent, AstSlot slot, int index) callExpr.Arguments.RemoveAt(index); return true; - // A receiver is genuinely optional -- a call with none is a free function rather than an - // unfinished member call -- so detaching one clears it instead of leaving a placeholder. - case (CallExpression callExpr, ReceiverSlotName): - bool hadReceiver = callExpr.Receiver is not null; - callExpr.Receiver = null; - return hadReceiver; - - case (ConditionalExpression conditional, ConditionSlotName): - conditional.Condition = Unfilled(); - return true; - - case (ConditionalExpression conditional, WhenTrueSlotName): - conditional.WhenTrue = Unfilled(); - return true; - - case (ConditionalExpression conditional, WhenFalseSlotName): - conditional.WhenFalse = Unfilled(); - return true; - - case (ExpressionStatement statement, "Expression"): - statement.Expression = Unfilled(); - return true; - default: return false; } @@ -605,6 +664,7 @@ public static string Describe(AstNode node) return node switch { ClassDeclaration classDecl => $"class {classDecl.Name ?? Unnamed}", + PropertyDeclaration property => $"property {property.Name ?? Unnamed}", FunctionDeclaration function => $"function {function.Name ?? Unnamed}", EntryPoint => "entry point", Parameter parameter => $"param {parameter.Name ?? Unnamed}", diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index 8e4c6af..ff3bef5 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -590,6 +590,216 @@ public void Yaml_CarriesAnAnnotationWithCommasInsideItsArguments() Assert.IsEmpty(restored.Annotations[1].Arguments); } + // ------------------------------------------------------------------ Properties + + /// + /// A type with one property of each kind: the language supplies the storage for one, and the + /// other has a body. + /// + /// The declaration. + private static ClassDeclaration Box() + { + ClassDeclaration box = new("Box"); + box.Members.Add(new PropertyDeclaration("Value", "int") + { + HasSetter = true, + Visibility = Visibility.Public, + }); + + PropertyDeclaration doubled = new("Doubled", "int") { Visibility = Visibility.Public }; + doubled.GetterBody.Add(new ReturnStatement( + new BinaryExpression(new VariableReference("Value"), BinaryOperator.Multiply, Literal.Number(2)))); + box.Members.Add(doubled); + + return box; + } + + /// + /// The three targets that have properties write them as properties. + /// + /// The generator to ask. + /// What the property with no body should look like. + /// What the property with a body should start with. + [TestMethod] + [DataRow("csharp", "public int Value { get; set; }", "public int Doubled")] + [DataRow("python", "Value: int = None", "def Doubled(self) -> int:")] + [DataRow("javascript", "Value;", "get Doubled() ")] + public void TargetsWithProperties_WriteThemAsProperties(string language, string automatic, string computed) + { + ILanguageGenerator generator = language switch + { + "csharp" => new CSharpGenerator(), + "python" => new PythonGenerator(), + _ => new JavaScriptGenerator(), + }; + + string code = Generate(generator, Box()); + + StringAssert.Contains(code, automatic); + StringAssert.Contains(code, computed); + } + + /// + /// A property with no body is a field, in every target that has no properties. + /// + /// The generator to ask. + /// The field as that target writes one. + /// + /// Not an approximation of a property: a property whose accessors have no bodies is a + /// field with a storage location the compiler supplies, and this is what a person would have + /// written. + /// + [TestMethod] + [DataRow("rust", "pub Value: i32,")] + [DataRow("go", "Value int")] + [DataRow("cpp", "int Value{};")] + [DataRow("c", "int Value;")] + public void TargetsWithoutProperties_WriteAnAutomaticOneAsAField(string language, string expected) + { + ILanguageGenerator generator = language switch + { + "rust" => new RustGenerator(), + "go" => new GoGenerator(), + "cpp" => new CppGenerator(), + _ => new CGenerator(), + }; + + StringAssert.Contains(Generate(generator, Box()), expected); + } + + /// + /// A property with a body is a function, and lands where that target puts its functions. + /// + /// The generator to ask. + /// The function as that target writes one. + /// + /// The landing is the point. Rust puts data in a struct and behaviour in an impl, + /// Go writes fields in the type and methods beside it, and C lowers a method to a free function + /// taking the instance — so a property separated at the point it is written would arrive after + /// the routing that decides all of that and come out wherever it happened to be standing. It is + /// separated from the member list first, and each generator's own routing then sees an ordinary + /// field or an ordinary function. + /// + [TestMethod] + [DataRow("rust", "pub fn doubled(&self) -> i32")] + [DataRow("go", "func (self Box) Doubled() int")] + [DataRow("cpp", "int doubled() const")] + [DataRow("c", "int Box_doubled(const Box* self)")] + public void TargetsWithoutProperties_WriteAComputedOneAsAFunction(string language, string expected) + { + ILanguageGenerator generator = language switch + { + "rust" => new RustGenerator(), + "go" => new GoGenerator(), + "cpp" => new CppGenerator(), + _ => new CGenerator(), + }; + + StringAssert.Contains(Generate(generator, Box()), expected); + } + + /// + /// A setter a target has to invent a name for gets one in that target's own convention. + /// + /// The generator to ask. + /// The name it should invent. + /// + /// The AST renames nothing it was given — a generator that changed a declaration's name would + /// break every reference to it — but a setter separated out of a property has no name until + /// somebody makes one up, and making one up in the wrong convention is how generated code + /// announces itself. rustc warns on a member name that is not snake case, and an unexported Go + /// name cannot be called from outside its package, so two of these are more than taste. + /// + [TestMethod] + [DataRow("rust", "set_value")] + [DataRow("go", "SetValue")] + [DataRow("cpp", "set_value")] + [DataRow("c", "set_value")] + public void AnInventedSetterName_FollowsTheTargetsOwnConvention(string language, string expected) + { + ClassDeclaration box = new("Box"); + PropertyDeclaration guarded = new("Value", "int") { HasSetter = true, Visibility = Visibility.Public }; + guarded.GetterBody.Add(new ReturnStatement(new VariableReference("Value"))); + guarded.SetterBody.Add(new ReturnStatement()); + box.Members.Add(guarded); + + ILanguageGenerator generator = language switch + { + "rust" => new RustGenerator(), + "go" => new GoGenerator(), + "cpp" => new CppGenerator(), + _ => new CGenerator(), + }; + + StringAssert.Contains(Generate(generator, box), expected); + } + + /// + /// The one thing a field cannot carry is said rather than dropped. + /// + /// + /// A property may be readable and not writable, and a field is neither or both. Said in the + /// field's own documentation rather than in a note beside it, so it travels with the + /// declaration to wherever the target puts it. + /// + [TestMethod] + public void AReadOnlyAutomaticProperty_SaysSoWhereItBecomesAField() + { + ClassDeclaration box = new("Box"); + box.Members.Add(new PropertyDeclaration("Value", "int") { Visibility = Visibility.Public }); + + StringAssert.Contains( + Generate(new RustGenerator(), box), + "Value is read-only, which a field is not."); + } + + /// + /// C# writes init where the declaration asked for it. + /// + [TestMethod] + public void CSharp_WritesAnInitOnlySetter() + { + ClassDeclaration box = new("Box"); + box.Members.Add(new PropertyDeclaration("Value", "int") + { + HasSetter = true, + SetterIsInitOnly = true, + Visibility = Visibility.Public, + }); + + StringAssert.Contains(Generate(new CSharpGenerator(), box), "public int Value { get; init; }"); + } + + /// + /// A property survives a trip through YAML, accessor bodies and all. + /// + [TestMethod] + public void Yaml_CarriesAProperty() + { + PropertyDeclaration original = new("Doubled", "int") + { + HasGetter = true, + HasSetter = true, + SetterIsInitOnly = true, + Visibility = Visibility.Public, + }; + original.GetterBody.Add(new ReturnStatement(Literal.Number(4))); + + ktsu.Coder.Serialization.YamlSerializer serializer = new(); + ktsu.Coder.Serialization.YamlDeserializer deserializer = new(); + + PropertyDeclaration restored = (PropertyDeclaration)deserializer.Deserialize(serializer.Serialize(original))!; + + Assert.AreEqual("Doubled", restored.Name); + Assert.AreEqual("int", restored.Type?.ToString()); + Assert.IsTrue(restored.HasSetter); + Assert.IsTrue(restored.SetterIsInitOnly); + Assert.AreEqual(Visibility.Public, restored.Visibility); + Assert.HasCount(1, restored.GetterBody); + Assert.IsEmpty(restored.SetterBody); + Assert.IsFalse(restored.IsAutomatic); + } + // ------------------------------------------------------------------ Round trip /// diff --git a/Coder/Ast/NamingStyle.cs b/Coder/Ast/NamingStyle.cs new file mode 100644 index 0000000..700ca2a --- /dev/null +++ b/Coder/Ast/NamingStyle.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System.Globalization; +using System.Linq; + +/// +/// How a target spells a member's name. +/// +/// +/// The AST renames nothing — a declaration's name is what the caller wrote, and a generator that +/// changed it would break every reference to it. This exists for the names a generator has to +/// invent, which is a different thing: a property becoming a pair of functions in a target +/// with no properties has no name for the setter until somebody makes one up, and making one up in +/// the wrong convention is how generated code announces itself. +/// +public enum NamingStyle +{ + /// Each word capitalised, run together: SetValue. + Pascal, + + /// The first word lower, the rest capitalised: setValue. + Camel, + + /// All lower, words separated by underscores: set_value. + Snake, +} + +/// +/// Spells an invented name the way a target writes one. +/// +internal static class NameStyles +{ + /// + /// Spells a name made of space-separated words. + /// + /// The name, as words separated by spaces. + /// How the target spells a member's name. + /// The name. + /// + /// The words come in separated because the caller knows where they are and this cannot: a name + /// already written as setValue or set_value would have to be taken apart first, + /// and every rule for doing that is wrong about something. + /// + internal static string Spell(string words, NamingStyle style) + { + string[] parts = [.. words.Split(' ').Where(part => part.Length > 0)]; + + if (parts.Length == 0) + { + return string.Empty; + } + + return style switch + { + NamingStyle.Snake => string.Join("_", parts.Select(part => part.ToLowerInvariant())), + NamingStyle.Camel => Lower(parts[0]) + string.Concat(parts.Skip(1).Select(Upper)), + _ => string.Concat(parts.Select(Upper)), + }; + } + + private static string Upper(string word) => + char.ToUpper(word[0], CultureInfo.InvariantCulture) + word[1..]; + + private static string Lower(string word) => + char.ToLower(word[0], CultureInfo.InvariantCulture) + word[1..]; +} diff --git a/Coder/Ast/PropertyDeclaration.cs b/Coder/Ast/PropertyDeclaration.cs new file mode 100644 index 0000000..027c6dd --- /dev/null +++ b/Coder/Ast/PropertyDeclaration.cs @@ -0,0 +1,199 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +using System.Collections.ObjectModel; + +/// +/// A named, typed member that is read and written through code rather than stored directly. +/// +/// +/// Three of the seven targets have one: C# spells it T Name { get; set; }, Python +/// @property, JavaScript get name(). The other four have the two halves of what it is +/// and no word joining them, which is what makes the interesting decision here not the syntax but +/// where a property lands. +/// +/// It lands in one of two places depending on whether it has a body. A property with no accessor +/// bodies is exactly a field with a storage location the compiler supplies, so a target with no +/// properties writes it as a field — which is what it is, and what a person would have written. +/// One with a body is exactly a pair of functions, so the same target writes the pair. Neither is +/// an approximation: they are the two things a property is, separated. +/// +/// +/// That is also why is not reused. A field says where a value is +/// kept; this says how it is reached, and the difference is invisible in C# and load-bearing +/// everywhere else. +/// +/// +public class PropertyDeclaration : AstNode, IHasVisibility, IHasDocumentation +{ + /// + /// Initializes a new instance of the class. + /// + public PropertyDeclaration() + { + } + + /// + /// Initializes a new instance of the class with a name. + /// + /// What the property is called. + /// The type of value it holds. + public PropertyDeclaration(string name, TypeReference? type = null) + { + Name = name; + Type = type; + } + + /// + /// Gets or sets what the property is called. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the type of value it holds. + /// + public TypeReference? Type { get; set; } + + /// + /// Gets or sets how widely the property is visible. + /// + public Visibility Visibility { get; set; } + + /// + /// Gets or sets a value indicating whether the property belongs to the type rather than to an + /// instance of it. + /// + public bool IsStatic { get; set; } + + /// + /// Gets or sets a value indicating whether the property can be read. + /// + /// + /// True by default: a property that can be neither read nor written is not a member of + /// anything, and a write-only one is rare enough to be worth asking for. + /// + public bool HasGetter { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the property can be written. + /// + public bool HasSetter { get; set; } + + /// + /// Gets or sets a value indicating whether it can be written only while the object is being + /// built. + /// + /// + /// C#'s init, and nothing else has a word for it. Where it cannot be spelled the + /// property is written as one that can be set, which is the safe direction to be wrong in: a + /// setter that should have been an initialiser compiles every call the declaration meant to + /// allow, and the reverse does not. + /// + public bool SetterIsInitOnly { get; set; } + + /// + /// Gets the statements run when the property is read, which may be none. + /// + /// + /// None means the storage is the language's to supply, which is what makes the property a field + /// in a target that has no properties. + /// + public Collection GetterBody { get; init; } = []; + + /// + /// Gets the statements run when the property is written, which may be none. + /// + public Collection SetterBody { get; init; } = []; + + /// + public Collection Documentation { get; init; } = []; + + /// + /// Gets the metadata attached to this declaration, which may be none. + /// + public Collection Annotations { get; init; } = []; + + /// + /// Gets a value indicating whether the property can be read. + /// + public bool CanRead => HasGetter || GetterBody.Count > 0; + + /// + /// Gets a value indicating whether the property can be written. + /// + public bool CanWrite => HasSetter || SetterBody.Count > 0; + + /// + /// Gets a value indicating whether the language supplies the storage and both accessors. + /// + /// + /// The question every generator asks first, because it is what decides whether the property is + /// a field or a pair of functions. + /// + public bool IsAutomatic => GetterBody.Count == 0 && SetterBody.Count == 0; + + /// + /// The name a getter takes where the property has to become a function. + /// + /// How the target spells a member's name. + /// The name. + public string GetterName(NamingStyle style) => NameStyles.Spell(Name ?? "value", style); + + /// + /// The name a setter takes where the property has to become a function. + /// + /// How the target spells a member's name. + /// The name. + /// + /// Prefixed rather than overloaded. Two functions of one name differing only in whether they + /// take an argument is legal C++ and illegal in Rust, Go and C, and a reader of any of them + /// reads set_value faster than an overload set. + /// + public string SetterName(NamingStyle style) => NameStyles.Spell($"set {Name ?? "value"}", style); + + /// + public override string GetNodeTypeName() => "PropertyDeclaration"; + + /// + public override AstNode Clone() + { + PropertyDeclaration clone = new() + { + Name = Name, + Type = Type?.Clone(), + Visibility = Visibility, + IsStatic = IsStatic, + HasGetter = HasGetter, + HasSetter = HasSetter, + SetterIsInitOnly = SetterIsInitOnly, + }; + + foreach ((string key, object? value) in Metadata) + { + clone.Metadata[key] = value; + } + + foreach (string line in Documentation) + { + clone.Documentation.Add(line); + } + + foreach (Annotation annotation in Annotations) + { + clone.Annotations.Add(annotation.Clone()); + } + + foreach (AstNode statement in GetterBody) + { + clone.GetterBody.Add(statement.Clone()); + } + + foreach (AstNode statement in SetterBody) + { + clone.SetterBody.Add(statement.Clone()); + } + + return clone; + } +} diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 8aa6d06..12fc7fa 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -155,6 +155,12 @@ private static string SnakeCase(string name) => /// public override string FileExtension => "c"; + /// + /// + /// C writes an invented name in lower case with underscores, and a header that did otherwise would stand out beside every other one in the project. + /// + protected override NamingStyle MemberNaming => NamingStyle.Snake; + /// /// /// C has no member functions, so a member becomes a free function named for the type it belongs @@ -478,6 +484,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + classDecl = Separated(classDecl); + string name = classDecl.Name ?? "UnnamedStruct"; bool isInterface = classDecl.Kind == TypeDeclarationKind.Interface; diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 0569768..7152f6b 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -3,6 +3,7 @@ namespace ktsu.Coder.Languages; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using ktsu.Coder.Ast; @@ -122,6 +123,9 @@ private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code) case EnumDeclaration enumDecl: GenerateEnum(enumDecl, code); break; + case PropertyDeclaration property: + GenerateProperty(property, code); + break; case FieldDeclaration field: GenerateField(field, code); break; @@ -487,6 +491,71 @@ private void GenerateEnum(EnumDeclaration enumDecl, CodeBlocker code) } } + /// + /// Emits a property, which C# has a word for. + /// + /// The declaration to emit. + /// The writer to emit into. + /// + /// The accessors go on one line when the language supplies them and in a block when they have + /// bodies, which is how a person writes the two and how every C# formatter will put them back + /// if a generator chooses otherwise. + /// + private void GenerateProperty(PropertyDeclaration property, CodeBlocker code) + { + GenerateDocumentation(property, code); + WriteAnnotations(property.Annotations, code); + + string type = property.Type is TypeReference declared ? MapToCSType(declared) : "object"; + string modifiers = property.IsStatic ? " static" : string.Empty; + + code.Write($"{SpellVisibility(property.Visibility) ?? DefaultVisibility}{modifiers} {type} {property.Name ?? "Value"}"); + + // init rather than set where the declaration asked for it: the two differ only in when the + // call is legal, and nothing else here has a word for the difference. + string setter = property.SetterIsInitOnly ? "init" : "set"; + + if (property.IsAutomatic) + { + string accessors = property.CanWrite ? $"get; {setter};" : "get;"; + code.WriteLine($" {{ {accessors} }}"); + return; + } + + code.WriteLine(); + + using Scope accessorBlock = new(code); + + if (property.CanRead) + { + code.Write("get"); + WriteAccessorBody(property.GetterBody, code); + } + + if (property.CanWrite) + { + code.Write(setter); + WriteAccessorBody(property.SetterBody, code); + } + } + + /// + /// Writes an accessor's statements, in a brace scope. + /// + /// The statements to write. + /// The writer to emit into. + private void WriteAccessorBody(Collection body, CodeBlocker code) + { + code.WriteLine(); + + using Scope statements = new(code); + + foreach (AstNode statement in body) + { + GenerateInternal(statement, code); + } + } + /// /// Emits a field of a type. /// diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index 6ecb693..aaca629 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -93,6 +93,12 @@ private static void WriteTemplateHead(IEnumerable parameters, Cod } } + /// + /// + /// The standard library spells an invented accessor set_value, and that is the convention a reader of any C++ header already has. + /// + protected override NamingStyle MemberNaming => NamingStyle.Snake; + /// /// /// Doubled brackets, which is the standard syntax rather than a compiler's own. An attribute @@ -329,6 +335,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + classDecl = Separated(classDecl); + GenerateDocumentation(classDecl, code); // A struct's members are public already, so labelling them would be noise. An interface has diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index 867d6de..ee5034c 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -179,6 +179,12 @@ public class GoGenerator : StandardLanguageGenerator /// public override string FileExtension => "go"; + /// + /// + /// An exported Go name is capitalised, and the invented setter of an exported property has to be exported too or nothing outside the package can call it. + /// + protected override NamingStyle MemberNaming => NamingStyle.Pascal; + /// /// /// A tab, because gofmt writes a tab. This is the one target where the indentation is not @@ -478,6 +484,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + classDecl = Separated(classDecl); + string name = classDecl.Name ?? UnnamedType; // A type declared inside another is written beside it: Go nests nothing but a function. diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 634a39a..7fd5ba1 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -2,6 +2,7 @@ namespace ktsu.Coder.Languages; +using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using ktsu.Coder.Ast; @@ -34,6 +35,61 @@ public class JavaScriptGenerator : StandardLanguageGenerator /// public override string FileExtension => "js"; + /// + /// + /// JavaScript has the thing itself, in a class body. An automatic property is a public class + /// field, which is what it is — there is no type to declare and nothing else to say. One with + /// bodies is get name() and set name(value), which is the language's own + /// spelling. + /// + /// Visibility is not written. A JavaScript member is private only if its name begins with a + /// #, which is a rename rather than a modifier, and this generator renames nothing. + /// + /// + protected override void GeneratePropertyDeclaration(PropertyDeclaration declaration, CodeBlocker code) + { + Ensure.NotNull(declaration); + Ensure.NotNull(code); + + string name = declaration.Name ?? "value"; + + GenerateDocumentation(declaration, code); + WriteAnnotations(declaration.Annotations, code); + + if (declaration.IsAutomatic) + { + code.WriteLine($"{(declaration.IsStatic ? "static " : string.Empty)}{name};"); + return; + } + + if (declaration.CanRead) + { + code.Write($"{(declaration.IsStatic ? "static " : string.Empty)}get {name}() "); + WriteAccessorBody(declaration.GetterBody, code); + } + + if (declaration.CanWrite) + { + code.Write($"{(declaration.IsStatic ? "static " : string.Empty)}set {name}(value) "); + WriteAccessorBody(declaration.SetterBody, code); + } + } + + /// + /// Writes an accessor's statements, in a brace scope that hangs off the line it is on. + /// + /// The statements to write. + /// The writer to emit into. + private void WriteAccessorBody(Collection body, CodeBlocker code) + { + using Scope statements = new(code); + + foreach (AstNode statement in body) + { + GenerateInternal(statement, code); + } + } + /// /// /// JavaScript's documentation convention is a /** */ block, which is a shape the shared diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 0d1690a..74d505e 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -2,6 +2,7 @@ namespace ktsu.Coder.Languages; +using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using ktsu.Coder.Ast; @@ -94,6 +95,77 @@ protected override void GenerateConditionalExpression(ConditionalExpression cond /// protected override string? SpellAnnotation(Annotation annotation) => $"@{annotation}"; + /// + /// + /// Python has the thing itself. An automatic property is a plain attribute — Python stores + /// whatever is assigned to one and there is nothing to declare — so it comes out as the + /// annotated name, which is what a reader and a type checker both want. One with bodies is a + /// @property and its @name.setter, which is the language's own spelling and needs + /// no import. + /// + protected override void GeneratePropertyDeclaration(PropertyDeclaration declaration, CodeBlocker code) + { + Ensure.NotNull(declaration); + Ensure.NotNull(code); + + string name = declaration.Name ?? "value"; + // The two spellings of a type: an annotation on a name is a colon, and on a function it is + // an arrow. Same type, and Python is particular about which goes where. + string annotation = declaration.Type is TypeReference type ? $": {PythonTypeFromGenericType(type)}" : string.Empty; + string result = declaration.Type is TypeReference answered ? $" -> {PythonTypeFromGenericType(answered)}" : string.Empty; + + GenerateDocumentation(declaration, code); + WriteAnnotations(declaration.Annotations, code); + + if (declaration.IsAutomatic) + { + code.WriteLine($"{name}{annotation} = None"); + return; + } + + if (declaration.CanRead) + { + code.WriteLine("@property"); + code.Write($"def {name}(self){result}:"); + WriteAccessorBody(declaration.GetterBody, code); + } + + if (declaration.CanWrite) + { + code.WriteLine($"@{name}.setter"); + code.Write($"def {name}(self, value{annotation}) -> None:"); + WriteAccessorBody(declaration.SetterBody, code); + } + } + + /// + /// Writes an accessor's statements, indented under it. + /// + /// The statements to write. + /// The writer to emit into. + /// + /// An empty one is pass, for the reason an empty class is: Python's body is delimited by + /// indentation, so there is nothing to write that is nothing. + /// + private void WriteAccessorBody(Collection body, CodeBlocker code) + { + code.WriteLine(); + + using IndentScope statements = new(code); + + if (body.Count == 0) + { + code.WriteLine("pass"); + return; + } + + foreach (AstNode statement in body) + { + GenerateInternal(statement, code); + code.WriteLine(); + } + } + /// protected override string? SpellImport(string import) => $"import {import}"; diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs index f2295a5..6892a04 100644 --- a/Coder/Languages/RustGenerator.cs +++ b/Coder/Languages/RustGenerator.cs @@ -275,6 +275,12 @@ private sealed record OperatorTrait(string Name, string Method, bool ReturnsBool /// public override string FileExtension => "rs"; + /// + /// + /// Enforced rather than conventional: rustc warns on a member name that is not snake case, so an invented one in any other convention makes the generated file noisy to build. + /// + protected override NamingStyle MemberNaming => NamingStyle.Snake; + /// protected override string? SpellAnnotation(Annotation annotation) => $"#[{annotation}]"; /// @@ -359,6 +365,8 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod Ensure.NotNull(classDecl); Ensure.NotNull(code); + classDecl = Separated(classDecl); + string name = classDecl.Name ?? "UnnamedStruct"; foreach (AstNode nested in classDecl.Members.Where(IsTypeDeclaration)) diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index cd35a3b..5dd9416 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -92,6 +92,10 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) GenerateEnumDeclaration(enumDecl, code); break; + case PropertyDeclaration property: + GeneratePropertyDeclaration(property, code); + break; + case FieldDeclaration field: GenerateFieldDeclaration(field, code); break; @@ -263,6 +267,158 @@ protected static bool IsDocumented(AstNode member) => /// The writer to emit into. protected abstract void GenerateFieldDeclaration(FieldDeclaration field, CodeBlocker code); + /// + /// Emits a property, as whichever of the two things it is in this language. + /// + /// The declaration to emit. + /// The writer to emit into. + /// + /// The default is for the four targets that have no properties, and it does not approximate + /// one: it separates the property into the declarations it is made of and emits those through + /// the emitters that already exist. A property whose accessors have no bodies is a field with a + /// storage location the compiler supplies, so it comes out as a field; one with bodies is a + /// pair of functions, so it comes out as the pair. Both are what a person would have written. + /// + /// The one thing the field form loses is that a property may be readable and not writable, + /// which a plain field cannot be. That is said rather than dropped. + /// + /// + protected virtual void GeneratePropertyDeclaration(PropertyDeclaration declaration, CodeBlocker code) + { + Ensure.NotNull(declaration); + Ensure.NotNull(code); + + bool first = true; + foreach (AstNode lowered in Separate(declaration)) + { + if (!first) + { + code.NewLine(); + } + + first = false; + GenerateInternal(lowered, code); + } + } + + /// + /// Gets the convention this target spells an invented member name in. + /// + /// + /// For the names a generator has to make up rather than the ones it was given. A property + /// becoming a pair of functions has no name for its setter until somebody invents one, and + /// inventing it in the wrong convention is how generated code announces itself. + /// + protected virtual NamingStyle MemberNaming => NamingStyle.Camel; + + /// + /// A type's members, with every property separated into the declarations it is made of. + /// + /// The type being emitted. + /// The declaration itself when it holds no properties; otherwise a copy of it whose + /// members are fields and functions. + /// + /// Done to the member list rather than at the point each member is written, and that is the + /// whole of why it works. Three of these generators route a type's members by what they are — + /// Rust puts the data in a struct and the behaviour in an impl, Go writes the + /// fields in the type and the methods beside it, C lowers a method to a free function taking + /// the instance — and every one of those routes reads the member list before anything is + /// written. A property separated afterwards arrives too late to be routed and comes out + /// wherever it happened to be standing. + /// + /// So it is separated first, and each generator's existing routing then sees an ordinary field + /// or an ordinary function and needs to know nothing about properties at all. + /// + /// + protected ClassDeclaration Separated(ClassDeclaration classDecl) + { + Ensure.NotNull(classDecl); + + if (!classDecl.Members.OfType().Any()) + { + return classDecl; + } + + ClassDeclaration separated = (ClassDeclaration)classDecl.Clone(); + separated.Members.Clear(); + + foreach (AstNode member in classDecl.Members) + { + if (member is PropertyDeclaration property) + { + foreach (AstNode part in Separate(property)) + { + separated.Members.Add(part); + } + + continue; + } + + separated.Members.Add(member); + } + + return separated; + } + + /// + /// Separates a property into the declarations it is made of. + /// + /// The property to separate. + /// A field, or the accessors, in the order they should be written. + private IEnumerable Separate(PropertyDeclaration property) + { + if (property.IsAutomatic) + { + // The one thing the field form cannot carry: a property may be readable and not + // writable, and a field is neither or both. Said in the field's own documentation + // rather than in a note beside it, so it travels with the declaration to wherever this + // target puts it. + IEnumerable documentation = property.CanWrite + ? property.Documentation + : [.. property.Documentation, $"{property.Name} is read-only, which a field is not."]; + + yield return new FieldDeclaration(property.Name ?? "value", property.Type) + { + Visibility = property.Visibility, + IsStatic = property.IsStatic, + Documentation = [.. documentation], + Annotations = [.. property.Annotations.Select(annotation => annotation.Clone())], + }; + + yield break; + } + + if (property.CanRead) + { + FunctionDeclaration getter = new(property.GetterName(MemberNaming)) + { + ReturnType = property.Type, + Visibility = property.Visibility, + IsStatic = property.IsStatic, + IsReadOnly = true, + Documentation = [.. property.Documentation], + Annotations = [.. property.Annotations.Select(annotation => annotation.Clone())], + Body = [.. property.GetterBody.Select(statement => statement.Clone())], + }; + + yield return getter; + } + + if (property.CanWrite) + { + FunctionDeclaration setter = new(property.SetterName(MemberNaming)) + { + ReturnType = new TypeReference("void"), + Visibility = property.Visibility, + IsStatic = property.IsStatic, + Body = [.. property.SetterBody.Select(statement => statement.Clone())], + }; + + setter.Parameters.Add(new Parameter("value") { Type = property.Type ?? new TypeReference("object") }); + yield return setter; + } + } + /// /// Emits the program's entry point, and whatever else the language needs in order to run it. /// diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 5b6df03..da83872 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -75,6 +75,8 @@ public YamlDeserializer() "EnumDeclaration" => DeserializeEnumDeclaration(nodeData), "enumMember" => DeserializeEnumMember(nodeData), "EnumMember" => DeserializeEnumMember(nodeData), + "propertyDeclaration" => DeserializePropertyDeclaration(nodeData), + "PropertyDeclaration" => DeserializePropertyDeclaration(nodeData), "fieldDeclaration" => DeserializeFieldDeclaration(nodeData), "FieldDeclaration" => DeserializeFieldDeclaration(nodeData), "classDeclaration" => DeserializeClassDeclaration(nodeData), @@ -658,6 +660,69 @@ private static EnumMember DeserializeEnumMember(object? nodeData) return member; } + private PropertyDeclaration DeserializePropertyDeclaration(object? nodeData) + { + PropertyDeclaration property = new(); + if (nodeData is not Dictionary dict) + { + return property; + } + + if (dict.TryGetValue("name", out object? nameObj)) + { + property.Name = nameObj?.ToString(); + } + + if (dict.TryGetValue("type", out object? typeObj)) + { + property.Type = typeObj?.ToString(); + } + + property.HasGetter = ReadFlag(dict, "readable", property.HasGetter); + property.HasSetter = ReadFlag(dict, "writable", property.HasSetter); + property.SetterIsInitOnly = ReadFlag(dict, "initOnly", property.SetterIsInitOnly); + property.IsStatic = ReadFlag(dict, "isStatic", property.IsStatic); + + DeserializeVisibility(property, dict); + ReadStrings(dict, DocumentationKey, property.Documentation); + DeserializeAnnotations(dict, property.Annotations); + ReadStatements(dict, "get", property.GetterBody); + ReadStatements(dict, "set", property.SetterBody); + + DeserializeMetadata(property, dict); + return property; + } + + /// + /// Reads a sequence of statements into a collection. + /// + /// The mapping to read from. + /// The key the statements are written under. + /// The collection to fill. + private void ReadStatements(Dictionary dict, string key, Collection statements) + { + if (!dict.TryGetValue(key, out object? bodyObj) || bodyObj is not List bodyList) + { + return; + } + + foreach (object statementObj in bodyList) + { + if (statementObj is not Dictionary statementDict) + { + continue; + } + + foreach ((object statementType, object statementData) in statementDict) + { + if (DeserializeNode(statementType.ToString() ?? string.Empty, statementData) is AstNode statement) + { + statements.Add(statement); + } + } + } + } + private FieldDeclaration DeserializeFieldDeclaration(object? nodeData) { FieldDeclaration field = new(); diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 4f5cb10..7573710 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -95,6 +95,9 @@ private static void SerializeCompositeNode(AstNode node, Dictionary + /// Writes a property, omitting whatever it did not ask for. + /// + /// The declaration being serialized. + /// The mapping to write into. + /// + /// readable is written when it is false rather than when it is true, unlike every other + /// flag here, because it is the one that defaults to true: a property nobody can read is the + /// unusual thing and so is the one worth saying. + /// + private static void SerializePropertyDeclaration(PropertyDeclaration property, Dictionary nodeData) + { + if (property.Name != null) + { + nodeData["name"] = property.Name; + } + + if (property.Type != null) + { + nodeData["type"] = property.Type.ToString(); + } + + if (!property.HasGetter) + { + nodeData["readable"] = false; + } + + if (property.HasSetter) + { + nodeData["writable"] = true; + } + + if (property.SetterIsInitOnly) + { + nodeData["initOnly"] = true; + } + + if (property.IsStatic) + { + nodeData["isStatic"] = true; + } + + SerializeVisibility(property, nodeData); + SerializeDocumentation(property, nodeData); + SerializeAnnotations(property.Annotations, nodeData); + + if (property.GetterBody.Count > 0) + { + nodeData["get"] = SerializeBodyStatements(property.GetterBody); + } + + if (property.SetterBody.Count > 0) + { + nodeData["set"] = SerializeBodyStatements(property.SetterBody); + } + } + private static void SerializeFieldDeclaration(FieldDeclaration field, Dictionary nodeData) { if (field.Name != null) From 6a41e1618e3d34c0efe0e65d0ce14c1151909495 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 13:24:33 +0000 Subject: [PATCH 6/7] Address the review comment and the analyzer findings from this PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, each from a bot on this PR rather than from taste. The review comment on `ReadAnnotation`: the trim is a mapping and now says so, with the emptiness check moving into the same chain. Behaviour is identical and the loop does one thing. `CGenerator.GenerateClassDeclaration` went over its cognitive-complexity limit when the embedded bases went in, which is fair: the block decides how many there are, which of them is first, what to say about the position, and whether a blank line follows. It is `WriteEmbeddedBases` now, and the paragraph explaining why the first member is the first member lives with the code that puts it there. `AstFields` spelled "ReadOnly" four times. It is a constant beside the three that were already there, and worth one: it is one name for two different promises — on a function that the call does not modify the receiver, on a type that none of its members does — so a reader of the inspector should see the same word for the same idea. And two `Assert.AreEqual(n, x.Count)` become `Assert.HasCount`, which the same file already used three lines away. MSTEST0046 is not adopted, and that is deliberate rather than an oversight: it wants `Assert.Contains` over `StringAssert.Contains`, and this test project is 290 to 111 the other way. Sonar reports it as INFO and the quality gate passes with it outstanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- Coder.Graph/AstFields.cs | 16 +++-- .../Languages/TypeDeclarationShapeTests.cs | 4 +- Coder/Languages/CGenerator.cs | 69 +++++++++++-------- Coder/Serialization/YamlDeserializer.cs | 12 ++-- 4 files changed, 62 insertions(+), 39 deletions(-) diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index bc7f879..fdbf545 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -111,6 +111,14 @@ public static class AstFields /// The name of the field a node holding one type exposes. private const string TypeField = "Type"; + /// The name of the field a declaration promising not to modify something exposes. + /// + /// One name for two different promises, which is why it is shared rather than repeated: on a + /// function it says the call does not modify the receiver, and on a type that none of its + /// members does. A reader of the inspector sees the same word for the same idea. + /// + private const string ReadOnlyField = "ReadOnly"; + private static readonly IReadOnlyList FunctionKinds = [ .. Enum.GetValues().Select(kind => new AstFieldChoice(kind.ToString(), kind.ToString())), @@ -217,7 +225,7 @@ public static IReadOnlyList Of(AstNode node) new(VisibilityField, AstFieldKind.Choice, classDecl.Visibility.ToString(), Visibilities), new("Record", AstFieldKind.Flag, Spell(classDecl.IsRecord)), new("Partial", AstFieldKind.Flag, Spell(classDecl.IsPartial)), - new("ReadOnly", AstFieldKind.Flag, Spell(classDecl.IsReadOnly)), + new(ReadOnlyField, AstFieldKind.Flag, Spell(classDecl.IsReadOnly)), ], _ => OfCallable(node), @@ -248,7 +256,7 @@ private static IReadOnlyList OfCallable(AstNode node) new("Definition", AstFieldKind.Choice, function.Definition.ToString(), FunctionDefinitions), new("Virtual", AstFieldKind.Flag, Spell(function.IsVirtual)), new("Abstract", AstFieldKind.Flag, Spell(function.IsAbstract)), - new("ReadOnly", AstFieldKind.Flag, Spell(function.IsReadOnly)), + new(ReadOnlyField, AstFieldKind.Flag, Spell(function.IsReadOnly)), new("MustUseResult", AstFieldKind.Flag, Spell(function.MustUseResult)), ], @@ -449,7 +457,7 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v TryParseBool(value, out bool isRecord) && Assign(() => classDecl.IsRecord = isRecord), (ClassDeclaration classDecl, "Partial") => TryParseBool(value, out bool isPartial) && Assign(() => classDecl.IsPartial = isPartial), - (ClassDeclaration classDecl, "ReadOnly") => + (ClassDeclaration classDecl, ReadOnlyField) => TryParseBool(value, out bool isClassReadOnly) && Assign(() => classDecl.IsReadOnly = isClassReadOnly), (FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)), @@ -483,7 +491,7 @@ private static bool TryWriteFunctionShape(AstNode node, string fieldName, string TryParseBool(value, out bool isVirtual) && Assign(() => function.IsVirtual = isVirtual), (FunctionDeclaration function, "Abstract") => TryParseBool(value, out bool isAbstract) && Assign(() => function.IsAbstract = isAbstract), - (FunctionDeclaration function, "ReadOnly") => + (FunctionDeclaration function, ReadOnlyField) => TryParseBool(value, out bool isReadOnly) && Assign(() => function.IsReadOnly = isReadOnly), (FunctionDeclaration function, "MustUseResult") => TryParseBool(value, out bool mustUse) && Assign(() => function.MustUseResult = mustUse), diff --git a/Coder.Test/Languages/TypeDeclarationShapeTests.cs b/Coder.Test/Languages/TypeDeclarationShapeTests.cs index ff3bef5..24840bd 100644 --- a/Coder.Test/Languages/TypeDeclarationShapeTests.cs +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -471,7 +471,7 @@ public void TypeParameter_ParseAndToStringAreInverses() const string written = "T : class, IComparer, new()"; Assert.AreEqual(written, TypeParameter.Parse(written).ToString()); - Assert.AreEqual(3, TypeParameter.Parse(written).Constraints.Count); + Assert.HasCount(3, TypeParameter.Parse(written).Constraints); Assert.AreEqual("U", TypeParameter.Parse("U").ToString()); } @@ -581,7 +581,7 @@ public void Yaml_CarriesAnAnnotationWithCommasInsideItsArguments() FunctionDeclaration restored = (FunctionDeclaration)deserializer.Deserialize(serializer.Serialize(original))!; - Assert.AreEqual(2, restored.Annotations.Count); + Assert.HasCount(2, restored.Annotations); Assert.AreEqual("SuppressMessage", restored.Annotations[0].Name); Assert.AreSequenceEqual( (string[])["\"Usage\"", "\"CA2225:Operator overloads have named alternates\""], diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs index 12fc7fa..c04b36c 100644 --- a/Coder/Languages/CGenerator.cs +++ b/Coder/Languages/CGenerator.cs @@ -391,6 +391,47 @@ private static Parameter SelfParameter(string? typeName, bool isReadOnly) => }, }; + /// + /// Writes the bases and interfaces a type embeds, as the members they are. + /// + /// The declaration being emitted. + /// How many ordinary members follow, so the blank line is written only when one is wanted. + /// The writer to emit into. + /// + /// A base and an interface are the same thing here: a struct embedded as a member, whose own + /// members are reached through it. What the first position buys is that a pointer to the whole + /// is a pointer to that member, so the two are interchangeable without a cast — and C has + /// exactly one first position to give, so the base takes it and an interface after it is + /// reached by taking its address instead. + /// + private void WriteEmbeddedBases(ClassDeclaration classDecl, int fieldCount, CodeBlocker code) + { + List<(TypeReference Type, string Member)> embedded = + [ + .. classDecl.BaseType is TypeReference baseType ? (List<(TypeReference, string)>)[(baseType, BaseMemberName)] : [], + .. classDecl.Interfaces.Select(contract => (contract, MemberNameOf(contract))), + ]; + + if (embedded.Count == 0) + { + return; + } + + WriteInexpressible(code, embedded.Count == 1 + ? $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it" + : $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it; the rest are reached by taking their address"); + + foreach ((TypeReference embeddedType, string member) in embedded) + { + code.WriteLine($"{SpellDeclarator(embeddedType, member)};"); + } + + if (fieldCount > 0) + { + code.NewLine(); + } + } + /// /// Writes a parenthesised parameter list, saying void where there are none. /// @@ -523,33 +564,7 @@ .. classDecl.Members.Where(member => insideType++; - // A base and an interface are the same thing here: a struct embedded as a member, whose - // own members are reached through it. What the first position buys is that a pointer to the - // whole is a pointer to that member, so the two are interchangeable without a cast -- and C - // has exactly one first position to give, so the base takes it and an interface after it is - // reached by taking its address instead. - List<(TypeReference Type, string Member)> embedded = - [ - .. classDecl.BaseType is TypeReference baseType ? (List<(TypeReference, string)>)[(baseType, BaseMemberName)] : [], - .. classDecl.Interfaces.Select(contract => (contract, MemberNameOf(contract))), - ]; - - if (embedded.Count > 0) - { - WriteInexpressible(code, embedded.Count == 1 - ? $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it" - : $"{embedded[0].Member} is first, so that a pointer to this is a pointer to it; the rest are reached by taking their address"); - - foreach ((TypeReference embeddedType, string member) in embedded) - { - code.WriteLine($"{SpellDeclarator(embeddedType, member)};"); - } - - if (fields.Count > 0) - { - code.NewLine(); - } - } + WriteEmbeddedBases(classDecl, fields.Count, code); AstNode? previous = null; foreach (AstNode member in fields) diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index da83872..883abb0 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -906,13 +906,13 @@ private static Annotation ReadAnnotation(string text) Annotation annotation = new(written[..opened].Trim()); - foreach (string argument in SplitArguments(written[(opened + 1)..^1])) + IEnumerable arguments = SplitArguments(written[(opened + 1)..^1]) + .Select(argument => argument.Trim()) + .Where(argument => argument.Length > 0); + + foreach (string argument in arguments) { - string trimmed = argument.Trim(); - if (trimmed.Length > 0) - { - annotation.Arguments.Add(trimmed); - } + annotation.Arguments.Add(argument); } return annotation; From 0bceeeb9d67669e636a8ea936dd96bd50efb17b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 13:41:27 +0000 Subject: [PATCH 7/7] Fix the two Sonar findings this PR introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are on code this PR added, and both were worth taking. `TryWriteDeclaration` came out at cognitive complexity 20 against a limit of 15, and every point of it was one `&&`: a `TryParse(…) && Assign(…)` pair per field. There are forty such pairs across the three switches, so the shape was going to keep pushing a method over the line each time nodes were added — it is what forced the last split. Parsing and writing are now one call named for the kind of field, which takes that method to 1 and the other two with it. Four names rather than four overloads, because IDE0350 wants an inferred lambda parameter and an overload set is exactly what stops one being inferred. Every enumeration now reads case-insensitively. Visibility already did, with a comment saying why — a document hand-edited with "public" should read back the same as the inspector's own "Public" — and there was no reason for it to be the one that did. `JavaScriptGenerator` spelled `"static "` five times; it is a constant now. 843/843 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QGCMUrT3jBgmANHNHcPmBf --- Coder.Graph/AstFields.cs | 169 +++++++++++++++++-------- Coder/Languages/JavaScriptGenerator.cs | 16 ++- 2 files changed, 130 insertions(+), 55 deletions(-) diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index fdbf545..ed40ead 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -403,14 +403,14 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v return (node, fieldName) switch { (SourceFile file, "Name") => Assign(() => file.Name = OrNull(value)), - (SourceFile file, "Header") => TryParseBool(value, out bool isHeader) && Assign(() => file.IsHeader = isHeader), + (SourceFile file, "Header") => AssignFlag(value, isHeader => file.IsHeader = isHeader), (NamespaceDeclaration namespaceDecl, "Name") => Assign(() => namespaceDecl.Name = OrNull(value)), (EnumDeclaration enumDecl, "Name") => Assign(() => enumDecl.Name = OrNull(value)), (EnumDeclaration enumDecl, "UnderlyingType") => Assign(() => enumDecl.UnderlyingType = OrNull(value)), (EnumDeclaration enumDecl, VisibilityField) => - TryParseVisibility(value, out Visibility enumVisibility) && Assign(() => enumDecl.Visibility = enumVisibility), + AssignMember(value, enumVisibility => enumDecl.Visibility = enumVisibility), (EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)), (EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)), @@ -423,51 +423,51 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (UsingAlias usingAlias, "Name") => Assign(() => usingAlias.Name = OrNull(value)), (UsingAlias usingAlias, "AliasedType") => Assign(() => usingAlias.AliasedType = OrNull(value)), (UsingAlias usingAlias, VisibilityField) => - TryParseVisibility(value, out Visibility aliasVisibility) && Assign(() => usingAlias.Visibility = aliasVisibility), + AssignMember(value, aliasVisibility => usingAlias.Visibility = aliasVisibility), (FieldDeclaration fieldDecl, "Name") => Assign(() => fieldDecl.Name = OrNull(value)), (FieldDeclaration fieldDecl, "Type") => Assign(() => fieldDecl.Type = OrNull(value)), (FieldDeclaration fieldDecl, VisibilityField) => - TryParseVisibility(value, out Visibility fieldVisibility) && Assign(() => fieldDecl.Visibility = fieldVisibility), + AssignMember(value, fieldVisibility => fieldDecl.Visibility = fieldVisibility), (FieldDeclaration fieldDecl, "Static") => - TryParseBool(value, out bool fieldIsStatic) && Assign(() => fieldDecl.IsStatic = fieldIsStatic), + AssignFlag(value, fieldIsStatic => fieldDecl.IsStatic = fieldIsStatic), (FieldDeclaration fieldDecl, "Constant") => - TryParseBool(value, out bool fieldIsConstant) && Assign(() => fieldDecl.IsConstant = fieldIsConstant), + AssignFlag(value, fieldIsConstant => fieldDecl.IsConstant = fieldIsConstant), (PropertyDeclaration property, "Name") => Assign(() => property.Name = OrNull(value)), (PropertyDeclaration property, TypeField) => Assign(() => property.Type = OrNull(value)), (PropertyDeclaration property, VisibilityField) => - TryParseVisibility(value, out Visibility propertyVisibility) && Assign(() => property.Visibility = propertyVisibility), + AssignMember(value, propertyVisibility => property.Visibility = propertyVisibility), (PropertyDeclaration property, "Static") => - TryParseBool(value, out bool propertyIsStatic) && Assign(() => property.IsStatic = propertyIsStatic), + AssignFlag(value, propertyIsStatic => property.IsStatic = propertyIsStatic), (PropertyDeclaration property, "Readable") => - TryParseBool(value, out bool readable) && Assign(() => property.HasGetter = readable), + AssignFlag(value, readable => property.HasGetter = readable), (PropertyDeclaration property, "Writable") => - TryParseBool(value, out bool writable) && Assign(() => property.HasSetter = writable), + AssignFlag(value, writable => property.HasSetter = writable), (PropertyDeclaration property, "InitOnly") => - TryParseBool(value, out bool initOnly) && Assign(() => property.SetterIsInitOnly = initOnly), + AssignFlag(value, initOnly => property.SetterIsInitOnly = initOnly), (ClassDeclaration classDecl, "Name") => Assign(() => classDecl.Name = OrNull(value)), (ClassDeclaration classDecl, "Kind") => - Enum.TryParse(value, out TypeDeclarationKind typeKind) && Assign(() => classDecl.Kind = typeKind), + AssignMember(value, typeKind => classDecl.Kind = typeKind), (ClassDeclaration classDecl, "BaseType") => Assign(() => classDecl.BaseType = OrNull(value)), (ClassDeclaration classDecl, VisibilityField) => - TryParseVisibility(value, out Visibility classVisibility) && Assign(() => classDecl.Visibility = classVisibility), + AssignMember(value, classVisibility => classDecl.Visibility = classVisibility), (ClassDeclaration classDecl, "Record") => - TryParseBool(value, out bool isRecord) && Assign(() => classDecl.IsRecord = isRecord), + AssignFlag(value, isRecord => classDecl.IsRecord = isRecord), (ClassDeclaration classDecl, "Partial") => - TryParseBool(value, out bool isPartial) && Assign(() => classDecl.IsPartial = isPartial), + AssignFlag(value, isPartial => classDecl.IsPartial = isPartial), (ClassDeclaration classDecl, ReadOnlyField) => - TryParseBool(value, out bool isClassReadOnly) && Assign(() => classDecl.IsReadOnly = isClassReadOnly), + AssignFlag(value, isClassReadOnly => classDecl.IsReadOnly = isClassReadOnly), (FunctionDeclaration function, "Name") => Assign(() => function.Name = OrNull(value)), (FunctionDeclaration function, "ReturnType") => Assign(() => function.ReturnType = OrNull(value)), (FunctionDeclaration function, VisibilityField) => - TryParseVisibility(value, out Visibility functionVisibility) && Assign(() => function.Visibility = functionVisibility), + AssignMember(value, functionVisibility => function.Visibility = functionVisibility), (FunctionDeclaration function, "Static") => - TryParseBool(value, out bool isStatic) && Assign(() => function.IsStatic = isStatic), + AssignFlag(value, isStatic => function.IsStatic = isStatic), (FunctionDeclaration function, "Pure") => - TryParseBool(value, out bool isPure) && Assign(() => function.IsPure = isPure), + AssignFlag(value, isPure => function.IsPure = isPure), _ => TryWriteFunctionShape(node, fieldName, value), }; } @@ -484,34 +484,34 @@ private static bool TryWriteFunctionShape(AstNode node, string fieldName, string return (node, fieldName) switch { (FunctionDeclaration function, "Kind") => - Enum.TryParse(value, out FunctionKind kind) && Assign(() => function.Kind = kind), + AssignMember(value, kind => function.Kind = kind), (FunctionDeclaration function, "Definition") => - Enum.TryParse(value, out FunctionDefinition definition) && Assign(() => function.Definition = definition), + AssignMember(value, definition => function.Definition = definition), (FunctionDeclaration function, "Virtual") => - TryParseBool(value, out bool isVirtual) && Assign(() => function.IsVirtual = isVirtual), + AssignFlag(value, isVirtual => function.IsVirtual = isVirtual), (FunctionDeclaration function, "Abstract") => - TryParseBool(value, out bool isAbstract) && Assign(() => function.IsAbstract = isAbstract), + AssignFlag(value, isAbstract => function.IsAbstract = isAbstract), (FunctionDeclaration function, ReadOnlyField) => - TryParseBool(value, out bool isReadOnly) && Assign(() => function.IsReadOnly = isReadOnly), + AssignFlag(value, isReadOnly => function.IsReadOnly = isReadOnly), (FunctionDeclaration function, "MustUseResult") => - TryParseBool(value, out bool mustUse) && Assign(() => function.MustUseResult = mustUse), + AssignFlag(value, mustUse => function.MustUseResult = mustUse), (EntryPoint entryPoint, "Arguments") => - TryParseBool(value, out bool acceptsArguments) && Assign(() => entryPoint.AcceptsArguments = acceptsArguments), + AssignFlag(value, acceptsArguments => entryPoint.AcceptsArguments = acceptsArguments), (EntryPoint entryPoint, "ExitCode") => - TryParseBool(value, out bool returnsExitCode) && Assign(() => entryPoint.ReturnsExitCode = returnsExitCode), + AssignFlag(value, returnsExitCode => entryPoint.ReturnsExitCode = returnsExitCode), (Parameter parameter, "Name") => Assign(() => parameter.Name = OrNull(value)), (Parameter parameter, "Type") => Assign(() => parameter.Type = OrNull(value)), - (Parameter parameter, "Optional") => TryParseBool(value, out bool optional) && Assign(() => parameter.IsOptional = optional), + (Parameter parameter, "Optional") => AssignFlag(value, optional => parameter.IsOptional = optional), (Parameter parameter, "Default") => Assign(() => parameter.DefaultValue = OrNull(value)), (VariableDeclaration varDecl, "Name") => value.Length > 0 && Assign(() => varDecl.Name = value), (VariableDeclaration varDecl, "Type") => Assign(() => varDecl.Type = OrNull(value)), - (VariableDeclaration varDecl, "Constant") => TryParseBool(value, out bool constant) && Assign(() => varDecl.IsConstant = constant), - (VariableDeclaration varDecl, "Inferred") => TryParseBool(value, out bool inferred) && Assign(() => varDecl.IsTypeInferred = inferred), + (VariableDeclaration varDecl, "Constant") => AssignFlag(value, constant => varDecl.IsConstant = constant), + (VariableDeclaration varDecl, "Inferred") => AssignFlag(value, inferred => varDecl.IsTypeInferred = inferred), (VariableDeclaration varDecl, VisibilityField) => - TryParseVisibility(value, out Visibility varVisibility) && Assign(() => varDecl.Visibility = varVisibility), + AssignMember(value, varVisibility => varDecl.Visibility = varVisibility), _ => false, }; @@ -535,21 +535,21 @@ private static bool TryWriteExpression(AstNode node, string fieldName, string va (ConstructionExpression construction, TypeField) => Assign(() => construction.Type = OrNull(value)), (BinaryExpression binary, "Operator") => - Enum.TryParse(value, out BinaryOperator binaryOp) && Assign(() => binary.Operator = binaryOp), + AssignMember(value, binaryOp => binary.Operator = binaryOp), (UnaryExpression unary, "Operator") => - Enum.TryParse(value, out UnaryOperator unaryOp) && Assign(() => unary.Operator = unaryOp), + AssignMember(value, unaryOp => unary.Operator = unaryOp), (AssignmentStatement assignment, "Operator") => - Enum.TryParse(value, out AssignmentOperator assignOp) && Assign(() => assignment.Operator = assignOp), + AssignMember(value, assignOp => assignment.Operator = assignOp), (LiteralExpression literal, ValueField) => Assign(() => literal.Value = value), - (LiteralExpression literal, ValueField) => TryParseInt(value, out int number) && Assign(() => literal.Value = number), - (LiteralExpression literal, ValueField) => TryParseDouble(value, out double number) && Assign(() => literal.Value = number), - (LiteralExpression literal, ValueField) => TryParseBool(value, out bool flag) && Assign(() => literal.Value = flag), + (LiteralExpression literal, ValueField) => AssignInteger(value, number => literal.Value = number), + (LiteralExpression literal, ValueField) => AssignNumber(value, number => literal.Value = number), + (LiteralExpression literal, ValueField) => AssignFlag(value, flag => literal.Value = flag), (AstLeafNode leaf, ValueField) => Assign(() => leaf.Value = value), - (AstLeafNode leaf, ValueField) => TryParseInt(value, out int number) && Assign(() => leaf.Value = number), - (AstLeafNode leaf, ValueField) => TryParseDouble(value, out double number) && Assign(() => leaf.Value = number), - (AstLeafNode leaf, ValueField) => TryParseBool(value, out bool flag) && Assign(() => leaf.Value = flag), + (AstLeafNode leaf, ValueField) => AssignInteger(value, number => leaf.Value = number), + (AstLeafNode leaf, ValueField) => AssignNumber(value, number => leaf.Value = number), + (AstLeafNode leaf, ValueField) => AssignFlag(value, flag => leaf.Value = flag), _ => false, }; @@ -616,19 +616,88 @@ private static bool Assign(Action assign) /// The text, or null when it is empty. private static string? OrNull(string value) => value.Length == 0 ? null : value; - // Culture-invariant throughout: these are source values, not text shown in the user's locale. - private static bool TryParseInt(string value, out int result) => - int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + /// + /// Parses text into a flag and writes it, reporting whether the node changed. + /// + /// The text the user left in the box. + /// The assignment to perform once the text parses. + /// True if the text parsed and the assignment was performed. + /// + /// Parsing and writing are one call rather than a TryParse(…) && Assign(…) pair + /// because the switches over the AST are long enough that forty such pairs put one of them past + /// what an analyzer will accept for one method. Each kind of field has its own name rather than + /// an overload, so the lambda's parameter type is inferred from the one candidate. + /// + private static bool AssignFlag(string value, Action assign) + { + if (!bool.TryParse(value, out bool parsed)) + { + return false; + } + + assign(parsed); + return true; + } + + /// + /// Parses text into a whole number and writes it, reporting whether the node changed. + /// + /// The text the user left in the box. + /// The assignment to perform once the text parses. + /// True if the text parsed and the assignment was performed. + /// Culture-invariant: this is a source value, not text shown in the user's locale. + private static bool AssignInteger(string value, Action assign) + { + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + { + return false; + } + + assign(parsed); + return true; + } - private static bool TryParseDouble(string value, out double result) => - double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + /// + /// Parses text into a number and writes it, reporting whether the node changed. + /// + /// The text the user left in the box. + /// The assignment to perform once the text parses. + /// True if the text parsed and the assignment was performed. + /// Culture-invariant: this is a source value, not text shown in the user's locale. + private static bool AssignNumber(string value, Action assign) + { + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)) + { + return false; + } + + assign(parsed); + return true; + } - private static bool TryParseBool(string value, out bool result) => bool.TryParse(value, out result); + /// + /// Parses text into a member of an enumeration and writes it, reporting whether the node changed. + /// + /// The enumeration the field holds. + /// The text the user left in the box. + /// The assignment to perform once the text parses. + /// True if the text named a member and the assignment was performed. + /// + /// Case-insensitively, so a document hand-edited with "public" reads back the same as the + /// inspector's own "Public". That was already how visibility was read; it is now how every + /// enumeration is, there being no reason for one of them to be the exception. + /// + private static bool AssignMember(string value, Action assign) + where TEnum : struct, Enum + { + if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed)) + { + return false; + } - // Case-insensitively, so a document hand-edited with "public" reads back the same as the - // inspector's own "Public". - private static bool TryParseVisibility(string value, out Visibility result) => - Enum.TryParse(value, ignoreCase: true, out result); + assign(parsed); + return true; + } private static string Spell(bool value) => value ? "true" : "false"; diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 7fd5ba1..5d993f8 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -20,6 +20,12 @@ namespace ktsu.Coder.Languages; /// public class JavaScriptGenerator : StandardLanguageGenerator { + /// + /// The one modifier a class member here can carry, with the space that separates it from what + /// it modifies, since every use of it is followed by a name. + /// + private const string StaticKeyword = "static "; + /// /// Gets the unique identifier for this language generator. /// @@ -58,19 +64,19 @@ protected override void GeneratePropertyDeclaration(PropertyDeclaration declarat if (declaration.IsAutomatic) { - code.WriteLine($"{(declaration.IsStatic ? "static " : string.Empty)}{name};"); + code.WriteLine($"{(declaration.IsStatic ? StaticKeyword : string.Empty)}{name};"); return; } if (declaration.CanRead) { - code.Write($"{(declaration.IsStatic ? "static " : string.Empty)}get {name}() "); + code.Write($"{(declaration.IsStatic ? StaticKeyword : string.Empty)}get {name}() "); WriteAccessorBody(declaration.GetterBody, code); } if (declaration.CanWrite) { - code.Write($"{(declaration.IsStatic ? "static " : string.Empty)}set {name}(value) "); + code.Write($"{(declaration.IsStatic ? StaticKeyword : string.Empty)}set {name}(value) "); WriteAccessorBody(declaration.SetterBody, code); } } @@ -389,7 +395,7 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code) // binding in a scope, and a class body is not one. if (field.IsConstant) { - code.Write("static "); + code.Write(StaticKeyword); } code.Write(MemberName(field.Name, field.Visibility)); @@ -427,7 +433,7 @@ private void GenerateMethod(FunctionDeclaration method, CodeBlocker code) if (method.IsStatic) { - code.Write("static "); + code.Write(StaticKeyword); } code.Write(method.Kind == FunctionKind.Constructor