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}";
}
///