From 4dfd6d482c30cce10afc3c3a240e9b0d72a54f70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:32:42 +0000 Subject: [PATCH] What a generated type promises that the type itself cannot say The exemplar's first two sections each end with two static_asserts, and both acceptance tests stripped them. The document recorded that as an open question: they are an assertion about a generated type rather than something the schema asked for, and where they belong was undecided. They belong in the AST, and the decision turns on something already settled. SourceFile.Imports are the one part of the AST that does not translate -- a C++ include path, a C# namespace and a Python module are different kinds of thing that share a position, so each is carried as text for the language the file is for. A compile-time predicate is the same shape of problem: std::is_trivially_copyable_v has no equivalent anywhere else, so there is no shared idea underneath it to model. CompileTimeAssertion.Condition is therefore text, which makes it consistent with an existing rule rather than a new special case. Only C++ has anything checked before the program runs. The other three write a comment saying what was asserted, because a file that quietly loses a guarantee looks exactly like one that still makes it. The message goes on its own line. These are long by nature -- the predicate says what is false and the message says why anyone cared -- and a compiler quoting the whole declaration back is easier to read as two lines than as one very wide one. Both acceptance tests now assert the document's sections in full. That is the point of this change: sections 1 and 2 are byte-identical to the specification rather than byte-identical to it less two lines each. Getting there needed the blank-line rule in one more place. A namespace and a file separated every member unconditionally, which split the two assertions about one type into two paragraphs. They now follow the same rule the members of a type do, hoisted to a virtual on the base: always separate, unless a language says otherwise, and C++ says two of a kind that say nothing about themselves stay together. 485 tests pass, 477 before and 8 new, with 0 warnings across the solution. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AhoPJ5AbxP8QEBNxPQYEPk --- CLAUDE.md | 5 + Coder.Graph/AstFields.cs | 9 ++ Coder.Graph/AstSchema.cs | 3 +- Coder.Test/Ast/CompileTimeAssertionTests.cs | 153 ++++++++++++++++++ Coder.Test/Languages/ExemplarHeaderTests.cs | 12 ++ .../Languages/ExemplarSemanticTypeTests.cs | 30 +++- Coder/Ast/CompileTimeAssertion.cs | 84 ++++++++++ Coder/Languages/CSharpGenerator.cs | 22 +++ Coder/Languages/CppGenerator.cs | 51 +++++- Coder/Languages/JavaScriptGenerator.cs | 13 ++ Coder/Languages/LanguageGeneratorBase.cs | 19 ++- Coder/Languages/PythonGenerator.cs | 13 ++ Coder/Languages/StandardLanguageGenerator.cs | 11 ++ Coder/Serialization/YamlDeserializer.cs | 24 +++ Coder/Serialization/YamlSerializer.cs | 16 ++ 15 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 Coder.Test/Ast/CompileTimeAssertionTests.cs create mode 100644 Coder/Ast/CompileTimeAssertion.cs diff --git a/CLAUDE.md b/CLAUDE.md index efff4a8..68b25c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,11 @@ source in four target languages. The solution uses: one that cannot be assigned at all; a language without an initialiser list assigns at the top of the constructor instead. `ConstructionExpression` is the one expression that needs a type rather than a name, which is why it could not exist before `TypeReference` did. +- `Coder/Ast/CompileTimeAssertion.cs` — what a generated type promises that the type itself cannot + say. Its `Condition` is text for the same reason `SourceFile.Imports` are: a compile-time predicate + is language-specific in a way most of the AST is not, and there is no shared idea underneath + `std::is_trivially_copyable_v` to model. Only C++ has one; the others write a comment, because a + file that quietly loses a guarantee looks like one that still makes it. - `Coder/Languages/LanguageGeneratorBase.cs` — the emitters every generator shares. - `Coder/Languages/StandardLanguageGenerator.cs` — owns the node dispatch, so a derived generator supplies only the syntax its language does not share. `CSharpGenerator` deliberately diff --git a/Coder.Graph/AstFields.cs b/Coder.Graph/AstFields.cs index 149fa56..eb0f0cb 100644 --- a/Coder.Graph/AstFields.cs +++ b/Coder.Graph/AstFields.cs @@ -165,6 +165,12 @@ public static IReadOnlyList Of(AstNode node) new(ValueField, AstFieldKind.Text, enumMember.Value ?? string.Empty), ], + CompileTimeAssertion assertion => + [ + new("Condition", AstFieldKind.Text, assertion.Condition ?? string.Empty), + new("Message", AstFieldKind.Text, assertion.Message ?? string.Empty), + ], + UsingAlias usingAlias => [ new("Name", AstFieldKind.Text, usingAlias.Name ?? string.Empty), @@ -361,6 +367,9 @@ private static bool TryWriteDeclaration(AstNode node, string fieldName, string v (EnumMember enumMember, "Name") => Assign(() => enumMember.Name = OrNull(value)), (EnumMember enumMember, ValueField) => Assign(() => enumMember.Value = OrNull(value)), + (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) => diff --git a/Coder.Graph/AstSchema.cs b/Coder.Graph/AstSchema.cs index 4be230c..28f3c3b 100644 --- a/Coder.Graph/AstSchema.cs +++ b/Coder.Graph/AstSchema.cs @@ -434,7 +434,8 @@ public static bool Accepts(AstSlot slot, AstNode candidate) // running at one, so it belongs to a class or to the document rather than inside a body. AstSlotKind.Statement => candidate is not (Parameter or EntryPoint), AstSlotKind.Member => candidate is FunctionDeclaration or VariableDeclaration or FieldDeclaration - or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias or EntryPoint, + or ClassDeclaration or EnumDeclaration or NamespaceDeclaration or UsingAlias + or CompileTimeAssertion or EntryPoint, AstSlotKind.EnumMember => candidate is EnumMember, _ => false, }; diff --git a/Coder.Test/Ast/CompileTimeAssertionTests.cs b/Coder.Test/Ast/CompileTimeAssertionTests.cs new file mode 100644 index 0000000..481cbd6 --- /dev/null +++ b/Coder.Test/Ast/CompileTimeAssertionTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Ast; + +using ktsu.Coder.Ast; +using ktsu.Coder.Graph; +using ktsu.Coder.Languages; +using ktsu.Coder.Serialization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for : what a generated type promises that the type itself +/// cannot say. +/// +/// +/// Only C++ has anything checked before the program runs, so this is the clearest case of the rule +/// the whole AST follows — say what is true, and let each language say as much of it as it can. The +/// other three write a comment rather than dropping it, because a file that quietly loses a +/// guarantee looks exactly like one that still makes it. +/// +[TestClass] +public class CompileTimeAssertionTests +{ + /// + /// C++ writes the assertion, with the message on its own line. + /// + [TestMethod] + public void Cpp_WritesTheAssertion() + { + CompileTimeAssertion assertion = new( + "std::is_trivially_copyable_v", + "RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes"); + + Assert.AreEqual( + "static_assert(std::is_trivially_copyable_v,\n" + + " \"RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes\");\n", + new CppGenerator().Generate(assertion).ReplaceLineEndings("\n")); + } + + /// + /// An assertion with nothing to say when it fails is still an assertion. + /// + [TestMethod] + public void Cpp_WritesAnAssertionWithNoMessage() + { + Assert.AreEqual( + "static_assert(sizeof(Handle) == 8);\n", + new CppGenerator().Generate(new CompileTimeAssertion("sizeof(Handle) == 8")).ReplaceLineEndings("\n")); + } + + /// + /// A message with a quote in it is escaped rather than ending the string early. + /// + [TestMethod] + public void Cpp_EscapesTheMessage() + { + CompileTimeAssertion assertion = new("sizeof(T) == 8", "a \"T\" is eight bytes"); + + Assert.Contains("\\\"T\\\"", new CppGenerator().Generate(assertion), StringComparison.Ordinal); + } + + /// + /// The other three have nothing checked before the program runs, so they say what was asserted + /// rather than dropping it. + /// + [TestMethod] + public void OtherLanguages_SayWhatWasAsserted() + { + CompileTimeAssertion assertion = new("std::is_standard_layout_v", "layout must be stable"); + + Assert.Contains( + "// asserted at build time: std::is_standard_layout_v", + new CSharpGenerator().Generate(assertion), + StringComparison.Ordinal); + Assert.Contains( + "# asserted at build time: std::is_standard_layout_v", + new PythonGenerator().Generate(assertion), + StringComparison.Ordinal); + Assert.Contains( + "// asserted at build time: std::is_standard_layout_v", + new JavaScriptGenerator().Generate(assertion), + StringComparison.Ordinal); + } + + /// + /// Assertions about one type stay together, and are separated from the declaration they are about. + /// + /// + /// The same rule the members of a type follow. Two of a kind that say nothing about themselves are + /// one block; a struct followed by an assertion is two. + /// + [TestMethod] + public void Cpp_GroupsAssertionsAboutTheSameType() + { + SourceFile file = new("RigidBody.gen.hpp"); + file.Members.Add(new ClassDeclaration("RigidBody") { Kind = TypeDeclarationKind.Struct }); + file.Members.Add(new CompileTimeAssertion("std::is_trivially_copyable_v", "bytes")); + file.Members.Add(new CompileTimeAssertion("std::is_standard_layout_v", "offsets")); + + string code = new CppGenerator().Generate(file).ReplaceLineEndings("\n"); + + Assert.Contains("};\n\nstatic_assert(", code, StringComparison.Ordinal); + Assert.Contains("\"bytes\");\nstatic_assert(", code, StringComparison.Ordinal); + } + + /// + /// An assertion survives a round trip through YAML, and a clone carries it. + /// + [TestMethod] + public void Yaml_RoundTripsTheAssertion() + { + CompileTimeAssertion original = new("sizeof(Handle) == 8", "a handle is eight bytes"); + + string yaml = new YamlSerializer().Serialize(original); + CompileTimeAssertion restored = (CompileTimeAssertion)new YamlDeserializer().Deserialize(yaml)!; + + Assert.AreEqual("sizeof(Handle) == 8", restored.Condition); + Assert.AreEqual("a handle is eight bytes", restored.Message); + + CompileTimeAssertion clone = (CompileTimeAssertion)original.Clone(); + + Assert.AreEqual(original.Condition, clone.Condition); + Assert.AreEqual(original.Message, clone.Message); + Assert.AreNotSame(original, clone); + } + + /// + /// A document says nothing for an assertion that asserts nothing. + /// + [TestMethod] + public void Yaml_WritesNothingForAnEmptyAssertion() + { + string yaml = new YamlSerializer().Serialize(new CompileTimeAssertion()); + + Assert.DoesNotContain("condition", yaml, StringComparison.Ordinal); + Assert.DoesNotContain("message", yaml, StringComparison.Ordinal); + } + + /// + /// The editor can place one beside a declaration and edit both of its halves. + /// + [TestMethod] + public void Graph_AcceptsAndEditsAnAssertion() + { + NamespaceDeclaration components = new("holo::components"); + CompileTimeAssertion assertion = new("sizeof(Handle) == 8"); + + Assert.IsTrue(AstSchema.TryAttach(components, AstSchema.SlotsOf(components)[0], assertion)); + Assert.IsTrue(AstFields.TryWrite(assertion, "Message", "a handle is eight bytes")); + + Assert.AreEqual("a handle is eight bytes", assertion.Message); + } +} diff --git a/Coder.Test/Languages/ExemplarHeaderTests.cs b/Coder.Test/Languages/ExemplarHeaderTests.cs index 0c04380..8e43f49 100644 --- a/Coder.Test/Languages/ExemplarHeaderTests.cs +++ b/Coder.Test/Languages/ExemplarHeaderTests.cs @@ -81,6 +81,11 @@ enum class BodyKind : std::uint8_t BodyKind body_kind = BodyKind::Dynamic; }; + static_assert(std::is_trivially_copyable_v, + "RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes"); + static_assert(std::is_standard_layout_v, + "RigidBody must be standard layout for its field offsets to be stable"); + } // namespace holo::components """; @@ -136,6 +141,13 @@ private static SourceFile RigidBodyHeader() NamespaceDeclaration components = new("holo::components"); components.Members.Add(rigidBody); + components.Members.Add(new CompileTimeAssertion( + "std::is_trivially_copyable_v", + "RigidBody must be trivially copyable: it crosses the C# boundary and the wire as bytes")); + components.Members.Add(new CompileTimeAssertion( + "std::is_standard_layout_v", + "RigidBody must be standard layout for its field offsets to be stable")); + SourceFile file = new("RigidBody.gen.hpp") { IsHeader = true }; file.HeaderComment.Add("Generated by holo_schemac. Do not edit."); file.HeaderComment.Add(""); diff --git a/Coder.Test/Languages/ExemplarSemanticTypeTests.cs b/Coder.Test/Languages/ExemplarSemanticTypeTests.cs index a1865d5..6de81fe 100644 --- a/Coder.Test/Languages/ExemplarSemanticTypeTests.cs +++ b/Coder.Test/Languages/ExemplarSemanticTypeTests.cs @@ -60,6 +60,11 @@ [[nodiscard]] constexpr underlying value() const noexcept private: underlying value_{}; }; + + static_assert(std::is_trivially_copyable_v, + "EntityId must be trivially copyable: it appears in components"); + static_assert(std::is_standard_layout_v, + "EntityId must be standard layout for its field offsets to be stable"); """; /// @@ -114,6 +119,29 @@ private static ClassDeclaration EntityId() return entity; } + /// + /// The semantic type together with what is asserted about it. + /// + /// The file. + /// + /// A file with no banner and no imports, because the document's section is the declaration and + /// the assertions beside it rather than a whole header. What the assertions say is the reason the + /// type can appear in a component at all. + /// + private static SourceFile EntityIdWithAssertions() + { + SourceFile file = new("EntityId.gen.hpp"); + file.Members.Add(EntityId()); + file.Members.Add(new CompileTimeAssertion( + "std::is_trivially_copyable_v", + "EntityId must be trivially copyable: it appears in components")); + file.Members.Add(new CompileTimeAssertion( + "std::is_standard_layout_v", + "EntityId must be standard layout for its field offsets to be stable")); + + return file; + } + /// /// Builds one of the comparison operators, which are symmetric and so belong beside the type /// rather than to either operand. @@ -146,7 +174,7 @@ private static FunctionDeclaration Comparison(string symbol, string returnType) public void Cpp_GeneratesTheSemanticTypeTheDocumentSpecifies() => Assert.AreEqual( Expected.ReplaceLineEndings("\n").TrimEnd(), - new CppGenerator().Generate(EntityId()).ReplaceLineEndings("\n").TrimEnd()); + new CppGenerator().Generate(EntityIdWithAssertions()).ReplaceLineEndings("\n").TrimEnd()); /// /// A member is initialised rather than assigned, which is the only way to start one that cannot be diff --git a/Coder/Ast/CompileTimeAssertion.cs b/Coder/Ast/CompileTimeAssertion.cs new file mode 100644 index 0000000..775a032 --- /dev/null +++ b/Coder/Ast/CompileTimeAssertion.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Ast; + +/// +/// Something that must be true when the program is built rather than when it runs. +/// +/// +/// What a generated type promises is often not something the type itself can say. A struct that +/// crosses a language boundary and the wire as raw bytes has to be trivially copyable and standard +/// layout, and the moment that stops being true is the moment a saved file starts being wrong — so +/// it is asserted where the type is declared, and the build fails rather than the save file. +/// +/// is text, and deliberately. A compile-time predicate is language-specific +/// in a way most of the AST is not: std::is_trivially_copyable_v<T> has no equivalent +/// anywhere else, so there is no shared idea underneath to model. It is carried the same way +/// are, and for the same reason — an assertion, like an import, is +/// written for the language the file is for. +/// +/// +/// Only C++ has this. Every other target here writes a comment saying what was asserted, because a +/// generated file that silently drops a guarantee looks like one that still makes it. +/// +/// +public class CompileTimeAssertion : AstNode +{ + /// + /// Initializes a new instance of the class. + /// + public CompileTimeAssertion() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The predicate that must hold, as the target language writes it. + /// What to say when it does not. + public CompileTimeAssertion(string condition, string? message = null) + { + Condition = condition; + Message = message; + } + + /// + /// Gets or sets the predicate that must hold, as the target language writes it. + /// + public string? Condition { get; set; } + + /// + /// Gets or sets what to say when the predicate does not hold. + /// + /// + /// Worth writing rather than leaving to the compiler: the predicate says what is false and the + /// message says why anyone cared, and only the second tells whoever hits it what to do. + /// + public string? Message { get; set; } + + /// + /// Gets the type name of this node for serialization purposes. + /// + /// The name of the node type. + public override string GetNodeTypeName() => "CompileTimeAssertion"; + + /// + /// Creates a deep clone of this assertion. + /// + /// A new instance with the same properties. + public override AstNode Clone() + { + CompileTimeAssertion clone = new() + { + Condition = Condition, + Message = Message, + }; + + foreach ((string key, object? value) in Metadata) + { + clone.Metadata[key] = value; + } + + return clone; + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 5c9d3f6..3bce503 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -61,6 +61,25 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code) case UnaryExpression unaryExpr: GenerateUnaryExpression(unaryExpr, code, GetUnaryOperator(unaryExpr.Operator)); break; + default: + GenerateExpressionOrLeaf(node, code); + break; + } + } + + /// + /// Emits an expression or a leaf. + /// + /// The node to emit. + /// The writer to emit into. + /// + /// Split from the declarations only because one switch over every node the AST has is more + /// branches than the analyzer accepts. The line is the same one the AST already draws. + /// + private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code) + { + switch (node) + { case VariableReference varRef: code.Write(varRef.Name); break; @@ -82,6 +101,9 @@ protected override void GenerateInternal(AstNode node, CodeBlocker code) case NamespaceDeclaration namespaceDecl: GenerateNamespace(namespaceDecl, code); break; + case CompileTimeAssertion assertion: + WriteInexpressible(code, $"asserted at build time: {assertion.Condition}"); + break; case UsingAlias usingAlias: GenerateUsingAlias(usingAlias, code); break; diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index f0bbcc3..ace62f0 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -299,15 +299,18 @@ protected override void GenerateNamespaceDeclaration(NamespaceDeclaration namesp code.WriteLine("{"); code.NewLine(); - bool first = true; + // The same rule the members of a type follow: two of a kind that say nothing about themselves + // stay together, so a run of assertions about one type reads as one block rather than as a + // paragraph each. + AstNode? previous = null; foreach (AstNode member in namespaceDecl.Members) { - if (!first) + if (previous is not null && NeedsSeparation(previous, member)) { code.NewLine(); } - first = false; + previous = member; GenerateInternal(member, code); } @@ -404,6 +407,31 @@ protected override void GenerateClassDeclaration(ClassDeclaration classDecl, Cod } } + /// + /// + /// The message goes on its own line. These are long by nature — the predicate says what is false + /// and the message says why anyone cared — and a compiler quoting the whole declaration back is + /// easier to read when it is two lines rather than one very wide one. + /// + protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assertion, CodeBlocker code) + { + Ensure.NotNull(assertion); + Ensure.NotNull(code); + + code.Write($"static_assert({assertion.Condition}"); + + if (assertion.Message is not null) + { + code.WriteLine(","); + code.Indent(); + code.Write($"\"{EscapeString(assertion.Message)}\""); + code.Outdent(); + } + + code.Write(")"); + EndStatement(code); + } + /// protected override void GenerateUsingAlias(UsingAlias usingAlias, CodeBlocker code) { @@ -532,10 +560,19 @@ protected override void GenerateFieldDeclaration(FieldDeclaration field, CodeBlo /// The member already written. /// The member about to be written. /// True when a blank line belongs between them. - private static bool NeedsSeparation(AstNode previous, AstNode member) => - previous.GetType() != member.GetType() - || IsDocumented(previous) - || IsDocumented(member); + /// + /// Two of a kind that say nothing about themselves stay together, which is what keeps a run of + /// aliases, of defaulted declarations, or of assertions about one type reading as one block. + /// + protected override bool NeedsSeparation(AstNode previous, AstNode member) + { + Ensure.NotNull(previous); + Ensure.NotNull(member); + + return previous.GetType() != member.GetType() + || IsDocumented(previous) + || IsDocumented(member); + } /// /// Reports whether a member carries documentation. diff --git a/Coder/Languages/JavaScriptGenerator.cs b/Coder/Languages/JavaScriptGenerator.cs index 0145088..a57a12d 100644 --- a/Coder/Languages/JavaScriptGenerator.cs +++ b/Coder/Languages/JavaScriptGenerator.cs @@ -82,6 +82,19 @@ private static void WriteEnumMembers(EnumDeclaration enumDecl, CodeBlocker code) } } + /// + /// + /// JavaScript has nothing that is checked before the program runs, so what was asserted is written as + /// a comment. Dropping it would leave a file that looks like one still making the guarantee. + /// + protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assertion, CodeBlocker code) + { + Ensure.NotNull(assertion); + Ensure.NotNull(code); + + WriteInexpressible(code, $"asserted at build time: {assertion.Condition}"); + } + /// /// /// JavaScript has no types to alias. The name is bound to whatever the alias named, which is a diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index 12ec77f..6f39a5c 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -251,19 +251,31 @@ protected void GenerateSourceFile(SourceFile file, CodeBlocker code) code.NewLine(); } - bool first = true; + AstNode? previous = null; foreach (AstNode member in file.Members) { - if (!first) + if (previous is not null && NeedsSeparation(previous, member)) { code.NewLine(); } - first = false; + previous = member; GenerateInternal(member, code); } } + /// + /// Reports whether two adjacent declarations want a blank line between them. + /// + /// The declaration already written. + /// The declaration about to be written. + /// True when a blank line belongs between them. + /// + /// Always, unless a language says otherwise. A language whose declarations are dense enough to + /// want grouping overrides this with the rule it wants. + /// + protected virtual bool NeedsSeparation(AstNode previous, AstNode member) => true; + /// /// Emits a declaration's documentation, one comment per line. /// @@ -394,6 +406,7 @@ protected static bool CanGenerateStandardNodes(AstNode astNode) { // No null check: a type pattern never matches null. return astNode is FunctionDeclaration + or CompileTimeAssertion or UsingAlias or MemberInitialiser or ConstructionExpression diff --git a/Coder/Languages/PythonGenerator.cs b/Coder/Languages/PythonGenerator.cs index 89dd745..29ed7f5 100644 --- a/Coder/Languages/PythonGenerator.cs +++ b/Coder/Languages/PythonGenerator.cs @@ -67,6 +67,19 @@ protected override void EndStatement(CodeBlocker code) /// protected override string? SpellImport(string import) => $"import {import}"; + /// + /// + /// Python has nothing that is checked before the program runs, so what was asserted is written as + /// a comment. Dropping it would leave a file that looks like one still making the guarantee. + /// + protected override void GenerateCompileTimeAssertion(CompileTimeAssertion assertion, CodeBlocker code) + { + Ensure.NotNull(assertion); + Ensure.NotNull(code); + + WriteInexpressible(code, $"asserted at build time: {assertion.Condition}"); + } + /// /// /// An alias is an ordinary assignment in Python, which is what a type alias is there. diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index 1ec3aee..9c24c9c 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -63,6 +63,10 @@ protected sealed override void GenerateInternal(AstNode node, CodeBlocker code) GenerateNamespaceDeclaration(namespaceDecl, code); break; + case CompileTimeAssertion assertion: + GenerateCompileTimeAssertion(assertion, code); + break; + case UsingAlias usingAlias: GenerateUsingAlias(usingAlias, code); break; @@ -160,6 +164,13 @@ protected virtual void GenerateNamespaceDeclaration(NamespaceDeclaration namespa } } + /// + /// Emits something that must be true when the program is built. + /// + /// The assertion to emit. + /// The writer to emit into. + protected abstract void GenerateCompileTimeAssertion(CompileTimeAssertion assertion, CodeBlocker code); + /// /// Emits an alias giving a type a second name. /// diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index aaf2438..e96e870 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -57,6 +57,8 @@ public YamlDeserializer() "SourceFile" => DeserializeSourceFile(nodeData), "namespaceDeclaration" => DeserializeNamespaceDeclaration(nodeData), "NamespaceDeclaration" => DeserializeNamespaceDeclaration(nodeData), + "compileTimeAssertion" => DeserializeCompileTimeAssertion(nodeData), + "CompileTimeAssertion" => DeserializeCompileTimeAssertion(nodeData), "usingAlias" => DeserializeUsingAlias(nodeData), "UsingAlias" => DeserializeUsingAlias(nodeData), "memberInitialiser" => DeserializeMemberInitialiser(nodeData), @@ -374,6 +376,28 @@ private NamespaceDeclaration DeserializeNamespaceDeclaration(object? nodeData) return namespaceDecl; } + private static CompileTimeAssertion DeserializeCompileTimeAssertion(object? nodeData) + { + CompileTimeAssertion assertion = new(); + if (nodeData is not Dictionary dict) + { + return assertion; + } + + if (dict.TryGetValue("condition", out object? conditionObj)) + { + assertion.Condition = conditionObj?.ToString(); + } + + if (dict.TryGetValue("message", out object? messageObj)) + { + assertion.Message = messageObj?.ToString(); + } + + DeserializeMetadata(assertion, dict); + return assertion; + } + private static UsingAlias DeserializeUsingAlias(object? nodeData) { UsingAlias usingAlias = new(); diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index c4964eb..235103c 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -127,6 +127,9 @@ private static void SerializeOtherNode(AstNode node, Dictionary { switch (node) { + case CompileTimeAssertion assertion: + SerializeCompileTimeAssertion(assertion, nodeData); + break; case UsingAlias usingAlias: SerializeUsingAlias(usingAlias, nodeData); break; @@ -310,6 +313,19 @@ private static void SerializeNamespaceDeclaration(NamespaceDeclaration namespace /// The key a node's members are written under. private const string MembersKey = "members"; + private static void SerializeCompileTimeAssertion(CompileTimeAssertion assertion, Dictionary nodeData) + { + if (assertion.Condition != null) + { + nodeData["condition"] = assertion.Condition; + } + + if (assertion.Message != null) + { + nodeData["message"] = assertion.Message; + } + } + private static void SerializeUsingAlias(UsingAlias usingAlias, Dictionary nodeData) { if (usingAlias.Name != null)