diff --git a/Coder.Test/Languages/StringConcatenationTests.cs b/Coder.Test/Languages/StringConcatenationTests.cs
new file mode 100644
index 0000000..dfa470a
--- /dev/null
+++ b/Coder.Test/Languages/StringConcatenationTests.cs
@@ -0,0 +1,152 @@
+// 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;
+
+///
+/// Tests that on strings is written as something each target
+/// accepts, in the two targets where + is not concatenation.
+///
+///
+/// Rust implements + only as String + &str, so a literal on the left or a
+/// String on the right does not compile; C's strings are pointers, and + on two of them
+/// is a constraint violation. Both used to come out as a bare + with nothing saying why.
+///
+[TestClass]
+public class StringConcatenationTests
+{
+ ///
+ /// Tests that Rust writes a join as one format! call, with one placeholder per operand.
+ ///
+ [TestMethod]
+ public void Rust_WritesAJoinAsFormat()
+ {
+ string code = new RustGenerator().Generate(Joins());
+
+ StringAssert.Contains(code, "return format!(\"{}{}\", \"Hello, \", name);");
+ StringAssert.Contains(code, "return format!(\"{}{}\", name, \"!\");");
+ StringAssert.Contains(code, "return format!(\"{}{}\", a, b);");
+ StringAssert.Contains(code, "return format!(\"{}{}{}\", a, \" \", b);");
+ }
+
+ ///
+ /// Tests that Rust leaves an addition of numbers as +.
+ ///
+ [TestMethod]
+ public void Rust_LeavesNumericAdditionAlone()
+ {
+ FunctionDeclaration sum = new("sum") { ReturnType = "int" };
+ sum.Parameters.Add(new Parameter("a", "int"));
+ sum.Parameters.Add(new Parameter("b", "int"));
+ sum.Body.Add(new ReturnStatement(new BinaryExpression(
+ new VariableReference("a"), BinaryOperator.Add, new VariableReference("b"))));
+
+ string code = new RustGenerator().Generate(sum);
+
+ StringAssert.Contains(code, "(a + b)");
+ Assert.IsFalse(code.Contains("format!", StringComparison.Ordinal), code);
+ }
+
+ ///
+ /// Tests that every mix of literal and String operands compiles in Rust: literal and
+ /// String, String and literal, two Strings, and a chain of three.
+ ///
+ [TestMethod]
+ public void Rust_JoinsCompile()
+ {
+ if (ToolchainHarness.FindOnPath("--version", "rustc") is null)
+ {
+ Assert.Inconclusive("No Rust compiler on the path, so nothing was compiled.");
+ return;
+ }
+
+ ToolchainHarness.InTemporaryDirectory(directory =>
+ {
+ string source = new RustGenerator().Generate(Joins());
+ string allowed = "#![allow(unused_parens, dead_code, unused_variables, unused_mut)]";
+
+ File.WriteAllText(
+ Path.Combine(directory, "joins.rs"),
+ $"{allowed}{Environment.NewLine}{source}");
+
+ (int exitCode, string output) = ToolchainHarness.Run(
+ "rustc",
+ "--crate-type lib --edition 2021 -o joins.rlib joins.rs",
+ directory);
+
+ Assert.AreEqual(0, exitCode, $"rustc rejected the generated source:{Environment.NewLine}{source}{Environment.NewLine}{output}");
+ });
+ }
+
+ ///
+ /// Tests that C writes a note in front of a join, saying why the compiler rejects it.
+ ///
+ [TestMethod]
+ public void C_NotesThatAJoinHasNoExpression()
+ {
+ string code = new CGenerator().Generate(Greet());
+
+ StringAssert.Contains(
+ code,
+ "return /* C has no string concatenation: join these into a buffer with snprintf or strcat */ (\"Hello, \" + name);");
+ }
+
+ ///
+ /// Tests that C writes no note in front of an addition of numbers.
+ ///
+ [TestMethod]
+ public void C_LeavesNumericAdditionAlone()
+ {
+ FunctionDeclaration sum = new("sum") { ReturnType = "int" };
+ sum.Parameters.Add(new Parameter("a", "int"));
+ sum.Parameters.Add(new Parameter("b", "int"));
+ sum.Body.Add(new ReturnStatement(new BinaryExpression(
+ new VariableReference("a"), BinaryOperator.Add, new VariableReference("b"))));
+
+ string code = new CGenerator().Generate(sum);
+
+ StringAssert.Contains(code, "return (a + b);");
+ Assert.IsFalse(code.Contains("/*", StringComparison.Ordinal), code);
+ }
+
+ private static FunctionDeclaration Greet()
+ {
+ FunctionDeclaration greet = new("greet") { ReturnType = "string", IsStatic = true };
+ greet.Parameters.Add(new Parameter("name", "string"));
+ greet.Body.Add(new ReturnStatement(new BinaryExpression(
+ Literal.Text("Hello, "), BinaryOperator.Add, new VariableReference("name"))));
+ return greet;
+ }
+
+ private static SourceFile Joins()
+ {
+ FunctionDeclaration exclaim = new("exclaim") { ReturnType = "string" };
+ exclaim.Parameters.Add(new Parameter("name", "string"));
+ exclaim.Body.Add(new ReturnStatement(new BinaryExpression(
+ new VariableReference("name"), BinaryOperator.Add, Literal.Text("!"))));
+
+ FunctionDeclaration join = new("join") { ReturnType = "string" };
+ join.Parameters.Add(new Parameter("a", "string"));
+ join.Parameters.Add(new Parameter("b", "string"));
+ join.Body.Add(new ReturnStatement(new BinaryExpression(
+ new VariableReference("a"), BinaryOperator.Add, new VariableReference("b"))));
+
+ FunctionDeclaration spaced = new("spaced") { ReturnType = "string" };
+ spaced.Parameters.Add(new Parameter("a", "string"));
+ spaced.Parameters.Add(new Parameter("b", "string"));
+ spaced.Body.Add(new ReturnStatement(new BinaryExpression(
+ new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, Literal.Text(" ")),
+ BinaryOperator.Add,
+ new VariableReference("b"))));
+
+ SourceFile file = new("joins");
+ file.Members.Add(Greet());
+ file.Members.Add(exclaim);
+ file.Members.Add(join);
+ file.Members.Add(spaced);
+ return file;
+ }
+}
diff --git a/Coder/Languages/CGenerator.cs b/Coder/Languages/CGenerator.cs
index ec21c86..f1f4f38 100644
--- a/Coder/Languages/CGenerator.cs
+++ b/Coder/Languages/CGenerator.cs
@@ -1156,6 +1156,36 @@ protected override void GenerateEntryPoint(EntryPoint entryPoint, CodeBlocker co
}
}
+ ///
+ ///
+ /// Joining strings is written down rather than translated. A string is a const char*, and
+ /// + on two pointers does not compile; joining them needs a buffer somebody allocates and
+ /// owns, which is a decision about the program rather than a spelling of the expression. So the
+ /// + stays, and a note in front of it says why the compiler rejects it and what to write
+ /// instead, rather than leaving the reader with invalid operands to binary + alone.
+ ///
+ protected override void GenerateBinary(BinaryExpression binary, CodeBlocker code)
+ {
+ Ensure.NotNull(binary);
+ Ensure.NotNull(code);
+
+ if (binary.Operator == BinaryOperator.Add
+ && (IsCString(StaticType(binary.Left)) || IsCString(StaticType(binary.Right))))
+ {
+ code.Write("/* C has no string concatenation: join these into a buffer with snprintf or strcat */ ");
+ }
+
+ base.GenerateBinary(binary, code);
+ }
+
+ ///
+ /// Reports whether a type is spelled as C's string, a pointer to constant characters.
+ ///
+ /// The type, or null where none is known.
+ /// True for str and string as a plain value.
+ private static bool IsCString(TypeReference? type) =>
+ type is not null && MapToCType(type) == TypeMappings["string"];
+
///
///
/// A parameter's default value is written as a comment beside it. C has no default arguments, so
diff --git a/Coder/Languages/RustGenerator.cs b/Coder/Languages/RustGenerator.cs
index 83cde19..3d4eb5c 100644
--- a/Coder/Languages/RustGenerator.cs
+++ b/Coder/Languages/RustGenerator.cs
@@ -1057,7 +1057,8 @@ private void GenerateFunction(FunctionDeclaration funcDecl, CodeBlocker code, st
using Scope body = new(code);
- // The parameters' and locals' types are what says where a number has to be widened.
+ // The parameters' and locals' types are what tells a join of strings from an addition of
+ // numbers, and what says where a number has to be widened.
using IDisposable types = OpenStaticTypeScope(funcDecl);
if (funcDecl.Kind == FunctionKind.Constructor)
@@ -1864,6 +1865,63 @@ protected override void GenerateConditionalExpression(ConditionalExpression cond
code.Write(" })");
}
+ ///
+ ///
+ /// Joining strings is written format! rather than +. Rust implements + only
+ /// as String + &str, so a literal on the left, or a String on the right, does
+ /// not compile; format! takes any mix of the two by reference and moves neither. A chain
+ /// of joins is one call with one placeholder per operand.
+ ///
+ protected override void GenerateBinary(BinaryExpression binary, CodeBlocker code)
+ {
+ Ensure.NotNull(binary);
+ Ensure.NotNull(code);
+
+ if (!IsStringJoin(binary))
+ {
+ base.GenerateBinary(binary, code);
+ return;
+ }
+
+ List operands = [];
+ CollectJoined(binary, operands);
+
+ code.Write($"format!(\"{string.Concat(Enumerable.Repeat("{}", operands.Count))}\"");
+ foreach (Expression operand in operands)
+ {
+ code.Write(", ");
+ GenerateInternal(operand, code);
+ }
+
+ code.Write(")");
+ }
+
+ ///
+ /// Reports whether a binary expression joins two strings.
+ ///
+ /// The expression to inspect.
+ /// True for an addition either of whose operands is known to be a string.
+ private bool IsStringJoin(BinaryExpression binary) =>
+ binary.Operator == BinaryOperator.Add
+ && (IsOwnedString(StaticType(binary.Left)) || IsOwnedString(StaticType(binary.Right)));
+
+ ///
+ /// Flattens a chain of string joins into its operands, in order.
+ ///
+ /// The join, or one of its operands.
+ /// Receives the operands.
+ private void CollectJoined(Expression operand, List operands)
+ {
+ if (operand is BinaryExpression binary && IsStringJoin(binary))
+ {
+ CollectJoined(binary.Left, operands);
+ CollectJoined(binary.Right, operands);
+ return;
+ }
+
+ operands.Add(operand);
+ }
+
///
///
/// Three shapes, and which one is written depends on what the expression is rather than on where