diff --git a/CLAUDE.md b/CLAUDE.md index 355a039..d6d59e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,7 +195,13 @@ source in seven target languages. The solution uses: cannot overload one and so have to call it something. - `Coder/Languages/CFamilyGenerator.cs` — what C and C++ share beyond what every generator shares, and all of it is about C: the preprocessor (`#pragma once`, `#include`), the braced list with its - designated initialisers, and the declarator that puts an array's brackets after the name. The type + designated initialisers, and the declarator that puts an array's brackets after the name. Every + position in either language that declares a name goes through that declarator, which is the whole + of why it exists: a type spelled on its own carries no brackets, because the positions that spell + one without a name — a return type, a base type, an enumeration's underlying type — are ones + neither language lets an array stand in at all. `CppGeneratedSourceCompilesTests` is what holds + C++ to it, and is why the rule is checked rather than asserted; before it there was no C++ compile + test and an array-typed parameter came out as `int[] steps`. The type mappings deliberately stay with each generator, because `str` is a `std::string` in one language and a `const char*` in the other and the whole of what a mapping is is the spelling. diff --git a/Coder.Test/Languages/CppGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/CppGeneratedSourceCompilesTests.cs new file mode 100644 index 0000000..3de0193 --- /dev/null +++ b/Coder.Test/Languages/CppGeneratedSourceCompilesTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Compiles what writes, with a real C++ compiler. +/// +/// +/// C, Rust and Go were each given a compile test because their rules are not visible in the text. +/// C++ was not, and the gap had a cost: an array-typed parameter came out as int[] steps in +/// every release until this test existed. Every pinned spelling around it passed, because a spelling +/// can be pinned and still be one a compiler refuses. +/// +/// Where an array's brackets go is exactly that kind of rule. C++ has one position for them — after +/// the name being declared — and no position at all in the places that spell a type without a name, +/// so the same is written two ways depending on where it stands. Only a +/// compiler can say whether the generator picked the right one each time. +/// +/// +/// The test is inconclusive rather than failing where no compiler is on the path, which is the +/// honest result: nothing was checked. +/// +/// +[TestClass] +public class CppGeneratedSourceCompilesTests +{ + /// + /// The compilers to look for, in the order a C++ project would. + /// + private static readonly string[] Compilers = ["c++", "g++", "clang++"]; + + /// + /// The consumer of the generated header, which calls each declaration so that a declaration that + /// compiles but cannot be used fails here rather than passing quietly. + /// + private const string Driver = """ + #include "arrays.h" + + int main() + { + int steps[3] = { 1, 2, 3 }; + return (Table::sum(steps, 3) + STRIDES[1]) * 0; + } + + """; + + /// + /// Tests that the two positions an unbounded array may stand in compile. + /// + /// + /// A parameter, and a namespace-scope constant table whose initialiser supplies the bound — which + /// is the pair exists for, an array with no bound having + /// nowhere else to stand: a data member and a local both need a bound C++ can see, and + /// IsArray deliberately carries none. + /// + /// Both are declarators, so both take the brackets after the name. The function's return type is + /// the position with no name to put them after, and the generator has to leave them off there + /// rather than write a type C++ cannot form. + /// + /// + [TestMethod] + public void AnArrayDeclarationCompilesWhereverAnUnboundedOneMayStand() + { + string? compiler = ToolchainHarness.FindOnPath("--version", Compilers); + if (compiler is null) + { + Assert.Inconclusive("No C++ compiler on the path, so nothing was compiled."); + return; + } + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText( + Path.Combine(directory, "arrays.h"), + new CppGenerator().Generate(ArrayExemplar())); + File.WriteAllText(Path.Combine(directory, "driver.cpp"), Driver); + + (int exitCode, string output) = ToolchainHarness.Run( + compiler, + "-std=c++20 -Wall -Wextra -pedantic -c driver.cpp -o driver.o", + directory); + + Assert.AreEqual(0, exitCode, $"{compiler} rejected the generated header:{Environment.NewLine}{output}"); + }); + } + + /// + /// Builds a header declaring an array in each position one with no bound may stand in. + /// + /// The file to generate. + private static SourceFile ArrayExemplar() + { + SourceFile file = new("arrays") { IsHeader = true }; + file.HeaderComment.Add("Generated by Coder. Do not edit."); + + // A constant table, which is what IsArray was added for. The initialiser is what gives the + // bound C++ needs, which is why this is a position an unbounded array may stand in at all. + ConstructionExpression strides = new(type: null); + strides.Arguments.Add(new LiteralExpression(1)); + strides.Arguments.Add(new LiteralExpression(2)); + + FieldDeclaration table = new() + { + Name = "STRIDES", + Type = new TypeReference("int") { IsArray = true, IsReadOnly = true }, + IsConstant = true, + InitialValue = strides, + }; + table.Documentation.Add("How far each step goes."); + file.Members.Add(table); + + // The free function taking the array, declared before what calls it. + file.Members.Add(Walk("walk_impl")); + + ClassDeclaration holder = new("Table") { Kind = TypeDeclarationKind.Struct }; + holder.Documentation.Add("Something taking an array through a member."); + + FunctionDeclaration sum = new("sum") { ReturnType = "int", IsStatic = true }; + sum.Parameters.Add(new Parameter("steps") { Type = new TypeReference("int") { IsArray = true } }); + sum.Parameters.Add(new Parameter("count", "int")); + sum.Body.Add(new ReturnStatement( + new CallExpression("walk_impl") + { + Arguments = { new VariableReference("steps"), new VariableReference("count") }, + })); + holder.Members.Add(sum); + + file.Members.Add(holder); + + return file; + } + + /// + /// Builds a function taking an array by parameter and reading through it. + /// + /// What to call it. + /// The declaration. + /// + /// The body subscripts the parameter, so the parameter's spelling is load-bearing rather than + /// merely present: a signature nobody reads through would compile with the brackets anywhere. + /// + private static FunctionDeclaration Walk(string name) + { + FunctionDeclaration walk = new(name) { ReturnType = "int", IsStatic = true }; + walk.Parameters.Add(new Parameter("steps") { Type = new TypeReference("int") { IsArray = true } }); + walk.Parameters.Add(new Parameter("count", "int")); + walk.Body.Add(new ReturnStatement( + new BinaryExpression( + new VariableReference("steps[0]"), + BinaryOperator.Add, + new VariableReference("count")))); + + return walk; + } +} diff --git a/Coder.Test/Languages/CppGeneratorTests.cs b/Coder.Test/Languages/CppGeneratorTests.cs index 10b3ac1..260cde0 100644 --- a/Coder.Test/Languages/CppGeneratorTests.cs +++ b/Coder.Test/Languages/CppGeneratorTests.cs @@ -136,6 +136,59 @@ public void BooleanLiteral_IsLowercase() Assert.AreEqual("false", Generator.Generate(Literal.Bool(false))); } + /// + /// Tests that an array-typed parameter puts its brackets on the declarator. + /// + /// + /// int steps[] is the parameter; int[] steps is a compile error. C++ has no + /// position for an array's brackets other than after the name it declares. + /// + [TestMethod] + public void ArrayParameter_PutsTheBracketsOnTheDeclarator() + { + FunctionDeclaration function = new("walk"); + function.Parameters.Add(new Parameter("steps") { Type = new TypeReference("int") { IsArray = true } }); + + string code = Generator.Generate(function); + + StringAssert.Contains(code, "void walk(int steps[])"); + } + + /// + /// Tests that an array-typed local puts its brackets on the declarator. + /// + [TestMethod] + public void ArrayVariable_PutsTheBracketsOnTheDeclarator() + { + VariableDeclaration local = new("steps") { Type = new TypeReference("int") { IsArray = true } }; + + string code = Generator.Generate(local); + + Assert.AreEqual($"int steps[];{CodeBlocker.DefaultNewLineString}", code); + } + + /// + /// Tests that a type spelled on its own carries no brackets, there being no declarator to put + /// them on. + /// + /// + /// The positions that spell a type without a name — a return type, a base type, an enumeration's + /// underlying type — are ones C++ does not let an array stand in at all, so brackets there would + /// be a compile error wearing the shape of a feature. + /// + [TestMethod] + public void ArrayReturnType_DoesNotSpellBracketsInTypePosition() + { + FunctionDeclaration function = new("collect") + { + ReturnType = new TypeReference("int") { IsArray = true }, + }; + + string code = Generator.Generate(function); + + Assert.IsFalse(code.Contains("[]", StringComparison.Ordinal), $"Expected no brackets in type position, got: {code}"); + } + /// /// Tests that a node the generator does not handle is refused rather than silently mis-generated. /// diff --git a/Coder/Languages/CFamilyGenerator.cs b/Coder/Languages/CFamilyGenerator.cs index 55fd356..a603981 100644 --- a/Coder/Languages/CFamilyGenerator.cs +++ b/Coder/Languages/CFamilyGenerator.cs @@ -120,10 +120,16 @@ protected override bool WriteFileDirectives(SourceFile file, CodeBlocker code) /// T name[], never T[] name — so a declaration cannot be built by writing the type /// and the name in that order, which is what every other language here does. This is the one /// place that difference lives. + /// + /// An empty name is a declarator with nothing to declare, which is what an unnamed parameter is: + /// the brackets still belong after it, so T[] comes out with no space in front of the + /// nothing. + /// /// protected string SpellDeclarator(TypeReference type, string name) { Ensure.NotNull(type); + Ensure.NotNull(name); TypeReference element = type.IsArray ? type.Clone() : type; if (type.IsArray) @@ -131,7 +137,9 @@ protected string SpellDeclarator(TypeReference type, string name) element.IsArray = false; } - return $"{SpellType(element)} {name}{(type.IsArray ? "[]" : string.Empty)}"; + string declared = name.Length == 0 ? string.Empty : $" {name}"; + + return $"{SpellType(element)}{declared}{(type.IsArray ? "[]" : string.Empty)}"; } /// diff --git a/Coder/Languages/CppGenerator.cs b/Coder/Languages/CppGenerator.cs index aaca629..1fb8d7e 100644 --- a/Coder/Languages/CppGenerator.cs +++ b/Coder/Languages/CppGenerator.cs @@ -620,7 +620,7 @@ private void GenerateField(VariableDeclaration field, CodeBlocker code) code.Write("const "); } - code.Write($"{GetDeclaredType(field)} {field.Name}"); + code.Write(SpellVariableDeclarator(field)); if (field.InitialValue is not null) { @@ -665,15 +665,12 @@ protected override void GenerateParameter(Parameter parameter, CodeBlocker code, Ensure.NotNull(parameter); Ensure.NotNull(code); - code.Write(MapToCppType(parameter.Type ?? new TypeReference(UnknownTypeName))); - // An empty name means deliberately unnamed, which C++ allows and a deleted copy constructor // wants: the parameter exists to make the signature, and naming it would only invite someone // to look for where it is used. A null name means nobody said, so one is invented. - if (parameter.Name is not "") - { - code.Write($" {parameter.Name ?? $"param{position}"}"); - } + string name = parameter.Name is "" ? string.Empty : parameter.Name ?? $"param{position}"; + + code.Write(SpellDeclarator(parameter.Type ?? new TypeReference(UnknownTypeName), name)); AppendDefaultValue(parameter, code); } @@ -689,7 +686,7 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, code.Write("const "); } - code.Write($"{GetDeclaredType(varDecl)} {varDecl.Name}"); + code.Write(SpellVariableDeclarator(varDecl)); if (varDecl.InitialValue is not null) { @@ -701,22 +698,26 @@ protected override void GenerateVariableDeclaration(VariableDeclaration varDecl, } /// - /// Spells the type a declaration is introduced with. + /// Spells a local declaration up to its name. /// /// The declaration being emitted. - /// The C++ type name, or a deduced placeholder. + /// The type and the name, with an array's brackets where C++ puts them. /// + /// The type and the name are spelled together rather than one after the other, because an array + /// separates them: is the one place that knows it. + /// /// auto needs an initializer to deduce from, so an inferred declaration without one falls - /// back to std::any. + /// back to std::any. Neither deduces an array, so neither goes through the declarator. + /// /// - private static string GetDeclaredType(VariableDeclaration varDecl) + private string SpellVariableDeclarator(VariableDeclaration varDecl) { if (!varDecl.IsTypeInferred && varDecl.Type is TypeReference declared) { - return MapToCppType(declared); + return SpellDeclarator(declared, varDecl.Name); } - return varDecl.InitialValue is not null ? "auto" : "std::any"; + return $"{(varDecl.InitialValue is not null ? "auto" : "std::any")} {varDecl.Name}"; } /// @@ -742,6 +743,14 @@ private static string GetDeclaredType(VariableDeclaration varDecl) /// Only the name is mapped; the shape around it — arguments, const, & and /// * — is C++'s own spelling of what the type says, which is what the string form could /// not express. + /// + /// An array's brackets are not among them, because they belong to the declarator rather than to + /// the type. Everything that declares a name goes through + /// to get them; the positions that spell a type + /// with no name to put them after — a return type, a base type, an enumeration's underlying type + /// — are ones C++ does not let an array stand in at all, so writing them there would produce a + /// compile error wearing the shape of a feature. + /// /// private static string MapToCppType(TypeReference type) { @@ -756,9 +765,7 @@ private static string MapToCppType(TypeReference type) _ => string.Empty, }; - string array = type.IsArray ? "[]" : string.Empty; - - return $"{(type.IsReadOnly ? "const " : string.Empty)}{name}{arguments}{array}{indirection}"; + return $"{(type.IsReadOnly ? "const " : string.Empty)}{name}{arguments}{indirection}"; } ///