diff --git a/CLAUDE.md b/CLAUDE.md index 5e905a2..355a039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,71 @@ 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` + 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 + 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 + 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 @@ -178,6 +243,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..ed40ead 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -108,6 +108,17 @@ 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"; + + /// 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())), @@ -168,6 +179,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), @@ -190,12 +206,26 @@ 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), 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(ReadOnlyField, AstFieldKind.Flag, Spell(classDecl.IsReadOnly)), ], _ => OfCallable(node), @@ -226,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)), ], @@ -280,6 +310,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()), @@ -365,50 +403,71 @@ 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)), + (MemberInitialiser initialiser, "Name") => Assign(() => initialiser.Name = OrNull(value)), + (CompileTimeAssertion assertion, "Condition") => Assign(() => assertion.Condition = OrNull(value)), (CompileTimeAssertion assertion, "Message") => Assign(() => assertion.Message = OrNull(value)), (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) => + AssignMember(value, propertyVisibility => property.Visibility = propertyVisibility), + (PropertyDeclaration property, "Static") => + AssignFlag(value, propertyIsStatic => property.IsStatic = propertyIsStatic), + (PropertyDeclaration property, "Readable") => + AssignFlag(value, readable => property.HasGetter = readable), + (PropertyDeclaration property, "Writable") => + AssignFlag(value, writable => property.HasSetter = writable), + (PropertyDeclaration property, "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") => + AssignFlag(value, isRecord => classDecl.IsRecord = isRecord), + (ClassDeclaration classDecl, "Partial") => + AssignFlag(value, isPartial => classDecl.IsPartial = isPartial), + (ClassDeclaration classDecl, ReadOnlyField) => + 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), }; } @@ -425,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), - (FunctionDeclaration function, "ReadOnly") => - TryParseBool(value, out bool isReadOnly) && Assign(() => function.IsReadOnly = isReadOnly), + AssignFlag(value, isAbstract => function.IsAbstract = isAbstract), + (FunctionDeclaration function, ReadOnlyField) => + 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, }; @@ -473,22 +532,24 @@ 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), + 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, }; @@ -555,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; + } - private static bool TryParseDouble(string value, out double result) => - double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + assign(parsed); + return true; + } - private static bool TryParseBool(string value, out bool result) => bool.TryParse(value, out result); + /// + /// 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; + } - // 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); + /// + /// 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; + } + + /// + /// 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; + } + + assign(parsed); + return true; + } private static string Spell(bool value) => value ? "true" : "false"; 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/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; +} 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/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 new file mode 100644 index 0000000..24840bd --- /dev/null +++ b/Coder.Test/Languages/TypeDeclarationShapeTests.cs @@ -0,0 +1,853 @@ +// 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"); + } + + // ------------------------------------------------------------------ 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.HasCount(3, TypeParameter.Parse(written).Constraints); + 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.HasCount(2, restored.Annotations); + 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); + } + + // ------------------------------------------------------------------ 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 + + /// + /// 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/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 b68b27c..de569f2 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,79 @@ 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. + /// + /// + /// 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. + /// + /// + /// 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 +183,9 @@ public override AstNode Clone() Name = Name, Kind = Kind, BaseType = BaseType?.Clone(), + IsRecord = IsRecord, + IsPartial = IsPartial, + IsReadOnly = IsReadOnly, Visibility = Visibility }; @@ -115,6 +194,21 @@ public override AstNode Clone() clone.SpecialisationArguments.Add(argument.Clone()); } + foreach (TypeReference contract in Interfaces) + { + clone.Interfaces.Add(contract.Clone()); + } + + foreach (TypeParameter parameter in TypeParameters) + { + 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 1b0840b..900f009 100644 --- a/Coder/Ast/FunctionDeclaration.cs +++ b/Coder/Ast/FunctionDeclaration.cs @@ -167,6 +167,21 @@ 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. + /// + /// + /// 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 +235,16 @@ public override AstNode Clone() } // Clone parameters + foreach (TypeParameter typeParameter in TypeParameters) + { + 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/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/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 901e367..c04b36c 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*" }, @@ -134,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 @@ -364,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. /// @@ -457,6 +525,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; @@ -484,6 +554,9 @@ .. classDecl.Members.Where(member => } GenerateDocumentation(classDecl, code); + WriteAnnotations(classDecl.Annotations, code); + WriteTypePromises(classDecl, code); + WriteTypeParametersDown(classDecl.TypeParameters, code); code.WriteLine($"typedef struct {name}"); code.WriteLine("{"); @@ -491,18 +564,7 @@ .. classDecl.Members.Where(member => insideType++; - if (classDecl.BaseType is TypeReference baseType) - { - // 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)};"); - - if (fields.Count > 0) - { - code.NewLine(); - } - } + WriteEmbeddedBases(classDecl, fields.Count, code); AstNode? previous = null; foreach (AstNode member in fields) diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index a4a3928..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; @@ -162,6 +166,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 @@ -178,15 +183,37 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) _ => "class", }; - code.Write($"{SpellVisibility(classDecl.Visibility) ?? DefaultVisibility} {keyword} {classDecl.Name ?? "UnnamedClass"}"); + // 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"}{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. + string[] inherited = + [ + .. classDecl.BaseType is TypeReference baseType ? (string[])[MapToCSType(baseType)] : [], + .. classDecl.Interfaces.Select(MapToCSType), + ]; - if (classDecl.BaseType is TypeReference baseType) + if (inherited.Length > 0) { - code.Write($" : {MapToCSType(baseType)}"); + code.Write($" : {string.Join(", ", inherited)}"); } // 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) @@ -202,6 +229,66 @@ private void GenerateClass(ClassDeclaration classDecl, CodeBlocker code) } } + /// + /// 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, + }); + + /// + /// + /// 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};"; @@ -404,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. /// @@ -417,6 +569,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} "); @@ -485,10 +638,12 @@ private void GenerateFunction(FunctionDeclaration function, CodeBlocker code, st return; } + WriteAnnotations(function.Annotations, code); WriteFunctionAttributes(function, code); WriteFunctionModifiers(function, code); code.Write(SpellFunctionName(function, enclosingType)); + code.Write(SpellTypeParameters(function.TypeParameters)); code.Write("("); for (int i = 0; i < function.Parameters.Count; i++) @@ -506,11 +661,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 d50a568..aaca629 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -72,6 +72,40 @@ 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)}>"); + } + } + + /// + /// + /// 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 + /// 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 @@ -91,6 +125,9 @@ 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); // Purity earns [[nodiscard]] on its own: a call that does nothing else and whose result is // thrown away did nothing at all. @@ -298,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 @@ -314,6 +353,18 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod code.WriteLine("template <>"); } + 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 + // 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) @@ -322,9 +373,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(); @@ -507,6 +568,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 b4a0280..ee5034c 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. /// @@ -176,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 @@ -475,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. @@ -501,6 +512,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 +527,44 @@ 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) + { + // 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; + } + + 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 +593,15 @@ 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 + // 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)]; @@ -627,11 +686,24 @@ 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); 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 +713,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; @@ -731,6 +803,7 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st return; } + WriteAnnotations(funcDecl.Annotations, code); WriteExportNote(name, funcDecl.Visibility, code); code.Write("func "); @@ -761,7 +834,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(")"); @@ -771,6 +844,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 8e37d3c..5d993f8 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -2,7 +2,9 @@ namespace ktsu.Coder.Languages; +using System.Collections.ObjectModel; using System.Globalization; +using System.Linq; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -18,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. /// @@ -33,6 +41,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 ? StaticKeyword : string.Empty)}{name};"); + return; + } + + if (declaration.CanRead) + { + code.Write($"{(declaration.IsStatic ? StaticKeyword : string.Empty)}get {name}() "); + WriteAccessorBody(declaration.GetterBody, code); + } + + if (declaration.CanWrite) + { + code.Write($"{(declaration.IsStatic ? StaticKeyword : 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 @@ -272,6 +335,18 @@ 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); + + // 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) @@ -320,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)); @@ -358,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 diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index fbfeb1f..a0df1f6 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,152 @@ 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. + /// + /// 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. + /// + /// 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..74d505e 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -2,7 +2,9 @@ namespace ktsu.Coder.Languages; +using System.Collections.ObjectModel; using System.Globalization; +using System.Linq; using ktsu.Coder.Ast; using ktsu.CodeBlocker; @@ -85,6 +87,85 @@ 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}"; + + /// + /// + /// 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}"; @@ -311,11 +392,31 @@ 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. + WriteAnnotations(classDecl.Annotations, code); + 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"}"); - 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..6892a04 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. /// @@ -209,6 +275,14 @@ 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}]"; /// /// /// use, which is what Rust writes where the others write an include or an import. A path @@ -291,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)) @@ -311,6 +387,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 +408,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 +427,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 +435,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; } /// @@ -409,7 +493,36 @@ private static bool IsTypeDeclaration(AstNode member) => private void GenerateStruct(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); - code.Write($"{SpellVisibilityOf(classDecl)}struct {name} "); + 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 + // 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"); + } + + 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. 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)]"); + } + + code.Write($"{SpellVisibilityOf(classDecl)}struct {name}{SpellParameterDeclarations(classDecl.TypeParameters)} "); using Scope body = new(code); @@ -466,12 +579,28 @@ private static void WriteStructMember(string? name, TypeReference? type, Visibil private void GenerateTrait(ClassDeclaration classDecl, string name, CodeBlocker code) { GenerateDocumentation(classDecl, code); - code.Write($"{SpellVisibilityOf(classDecl)}trait {name}"); + WriteAnnotations(classDecl.Annotations, code); + WriteTypePromises(classDecl, code); + 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 + // 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), + ]; - // A base type is a supertrait: something every implementation of this one must also be. - if (classDecl.BaseType is TypeReference baseType) + if (supertraits.Length > 0) { - code.Write($": {SpellType(baseType)}"); + code.Write($": {string.Join(" + ", supertraits)}"); } code.Write(" "); @@ -531,7 +660,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); @@ -592,7 +721,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} "); @@ -615,7 +744,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) "); @@ -670,6 +799,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)) @@ -687,7 +818,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(")"); @@ -899,6 +1036,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/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 0300283..883abb0 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), @@ -179,6 +181,8 @@ private void DeserializeFunctionBasicProperties(FunctionDeclaration funcDecl, Di DeserializeVisibility(funcDecl, dict); ReadStrings(dict, DocumentationKey, funcDecl.Documentation); + DeserializeAnnotations(dict, funcDecl.Annotations); + DeserializeTypeParameters(dict, funcDecl.TypeParameters); } /// @@ -656,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(); @@ -679,6 +746,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) @@ -777,9 +845,16 @@ 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); + DeserializeAnnotations(dict, classDecl.Annotations); + DeserializeTypeParameters(dict, classDecl.TypeParameters); + DeserializeTypeList(dict, "interfaces", classDecl.Interfaces); + DeserializeTypeList(dict, "specialisationArguments", classDecl.SpecialisationArguments); DeserializeClassMembers(classDecl, dict); DeserializeMetadata(classDecl, dict); @@ -787,23 +862,170 @@ private ClassDeclaration DeserializeClassDeclaration(object? nodeData) return classDecl; } - private static void DeserializeSpecialisationArguments(ClassDeclaration classDecl, Dictionary dict) + /// + /// 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()); + + IEnumerable arguments = SplitArguments(written[(opened + 1)..^1]) + .Select(argument => argument.Trim()) + .Where(argument => argument.Length > 0); + + foreach (string argument in arguments) + { + annotation.Arguments.Add(argument); + } + + 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. + /// + /// 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. + /// + /// 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..7573710 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; @@ -94,6 +95,9 @@ private static void SerializeCompositeNode(AstNode node, Dictionary 0) + { + nodeData["typeParameters"] = + funcDecl.TypeParameters.Select(parameter => parameter.ToString()).ToList(); + } + if (funcDecl.Parameters.Count > 0) { nodeData["parameters"] = SerializeParameters(funcDecl.Parameters); @@ -466,6 +477,63 @@ private static void SerializeEnumMember(EnumMember member, 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) @@ -490,6 +558,7 @@ private static void SerializeFieldDeclaration(FieldDeclaration field, Dictionary SerializeVisibility(field, nodeData); SerializeDocumentation(field, nodeData); + SerializeAnnotations(field.Annotations, nodeData); if (field.InitialValue != null) { @@ -580,6 +649,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) @@ -597,6 +684,21 @@ 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 + // 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,8 +707,26 @@ 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); + SerializeAnnotations(classDecl.Annotations, nodeData); if (classDecl.Members.Count > 0) {