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