Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions Coder.Test/Languages/StringConcatenationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests that <see cref="BinaryOperator.Add"/> on strings is written as something each target
/// accepts, in the two targets where <c>+</c> is not concatenation.
/// </summary>
/// <remarks>
/// Rust implements <c>+</c> only as <c>String + &amp;str</c>, so a literal on the left or a
/// <c>String</c> on the right does not compile; C's strings are pointers, and <c>+</c> on two of them
/// is a constraint violation. Both used to come out as a bare <c>+</c> with nothing saying why.
/// </remarks>
[TestClass]
public class StringConcatenationTests
{
/// <summary>
/// Tests that Rust writes a join as one <c>format!</c> call, with one placeholder per operand.
/// </summary>
[TestMethod]
public void Rust_WritesAJoinAsFormat()
{
string code = new RustGenerator().Generate(Joins());

StringAssert.Contains(code, "return format!(\"{}{}\", \"Hello, \", name);");

Check warning on line 29 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFM&open=AaFYCyMQSRSdUndRjxFM&pullRequest=323
StringAssert.Contains(code, "return format!(\"{}{}\", name, \"!\");");

Check warning on line 30 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFN&open=AaFYCyMQSRSdUndRjxFN&pullRequest=323
StringAssert.Contains(code, "return format!(\"{}{}\", a, b);");

Check warning on line 31 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFO&open=AaFYCyMQSRSdUndRjxFO&pullRequest=323
StringAssert.Contains(code, "return format!(\"{}{}{}\", a, \" \", b);");

Check warning on line 32 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFP&open=AaFYCyMQSRSdUndRjxFP&pullRequest=323
}

/// <summary>
/// Tests that Rust leaves an addition of numbers as <c>+</c>.
/// </summary>
[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)");

Check warning on line 49 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFQ&open=AaFYCyMQSRSdUndRjxFQ&pullRequest=323
Assert.IsFalse(code.Contains("format!", StringComparison.Ordinal), code);
}

/// <summary>
/// Tests that every mix of literal and <c>String</c> operands compiles in Rust: literal and
/// <c>String</c>, <c>String</c> and literal, two <c>String</c>s, and a chain of three.
/// </summary>
[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}");
});
}

/// <summary>
/// Tests that C writes a note in front of a join, saying why the compiler rejects it.
/// </summary>
[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);");

Check warning on line 94 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFL&open=AaFYCyMQSRSdUndRjxFL&pullRequest=323
}

/// <summary>
/// Tests that C writes no note in front of an addition of numbers.
/// </summary>
[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);");

Check warning on line 111 in Coder.Test/Languages/StringConcatenationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Coder&issues=AaFYCyMQSRSdUndRjxFR&open=AaFYCyMQSRSdUndRjxFR&pullRequest=323
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;
}
}
30 changes: 30 additions & 0 deletions Coder/Languages/CGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
/// The <c>&lt;math.h&gt;</c> macros, which is why a file holding one includes it.
/// </remarks>
protected override string SpellNonFiniteDouble(double value) =>
double.IsNaN(value) ? "NAN" : value > 0 ? "INFINITY" : "-INFINITY";

Check warning on line 180 in Coder/Languages/CGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
protected override IEnumerable<string> RequiredImports(SourceFile file)
Expand Down Expand Up @@ -595,7 +595,7 @@
/// outright; the placeholder also gives a constructor's <c>{0}</c> something to zero.
/// </para>
/// </remarks>
protected override void GenerateClassDeclaration(ClassDeclaration classDecl, CodeBlocker code)

Check warning on line 598 in Coder/Languages/CGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
Ensure.NotNull(classDecl);
Ensure.NotNull(code);
Expand Down Expand Up @@ -1156,6 +1156,36 @@
}
}

/// <inheritdoc/>
/// <remarks>
/// Joining strings is written down rather than translated. A string is a <c>const char*</c>, and
/// <c>+</c> 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
/// <c>+</c> 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 <c>invalid operands to binary +</c> alone.
/// </remarks>
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);
}

/// <summary>
/// Reports whether a type is spelled as C's string, a pointer to constant characters.
/// </summary>
/// <param name="type">The type, or null where none is known.</param>
/// <returns>True for <c>str</c> and <c>string</c> as a plain value.</returns>
private static bool IsCString(TypeReference? type) =>
type is not null && MapToCType(type) == TypeMappings["string"];

/// <inheritdoc/>
/// <remarks>
/// A parameter's default value is written as a comment beside it. C has no default arguments, so
Expand Down
60 changes: 59 additions & 1 deletion Coder/Languages/RustGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@
/// The associated constants of <c>f64</c>, which need nothing imported.
/// </remarks>
protected override string SpellNonFiniteDouble(double value) =>
double.IsNaN(value) ? "f64::NAN" : value > 0 ? "f64::INFINITY" : "f64::NEG_INFINITY";

Check warning on line 350 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 350 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
/// <remarks>
Expand All @@ -355,7 +355,7 @@
/// <c>f32</c> implicitly.
/// </remarks>
protected override string SpellNonFiniteSingle(float value) =>
float.IsNaN(value) ? "f32::NAN" : value > 0 ? "f32::INFINITY" : "f32::NEG_INFINITY";

Check warning on line 358 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 358 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

/// <inheritdoc/>
/// <remarks>
Expand Down Expand Up @@ -813,7 +813,7 @@
/// <param name="funcDecl">The declaration to emit.</param>
/// <param name="typeName">The type it belongs to.</param>
/// <param name="code">The writer to emit into.</param>
private void GenerateTraitImplementation(FunctionDeclaration funcDecl, string typeName, CodeBlocker code)

Check warning on line 816 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

Check warning on line 816 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.
{
if (funcDecl.Kind == FunctionKind.ConversionOperator)
{
Expand Down Expand Up @@ -865,7 +865,7 @@
.. operands.Select(parameter => new KeyValuePair<string, TypeReference?>(OperandName(parameter), parameter.Type)),
]);
bool rebind = receiverName != SelfName;
string self = op.ReturnsBool ? "&self" : reassigned.Contains(receiverName) && !rebind ? $"mut {SelfName}" : SelfName;

Check warning on line 868 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
code.Write($"fn {op.Method}({self}");

foreach (Parameter parameter in operands)
Expand Down Expand Up @@ -1057,7 +1057,8 @@

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)
Expand Down Expand Up @@ -1641,7 +1642,7 @@

bool inferred = varDecl.IsTypeInferred || varDecl.Type is null;
TypeReference? annotation = !inferred ? varDecl.Type
: varDecl.InitialValue is null ? new TypeReference(UnknownTypeName)

Check warning on line 1645 in Coder/Languages/RustGenerator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.
: InferredLiteralWidth(varDecl.InitialValue);

if (annotation is not null)
Expand Down Expand Up @@ -1864,6 +1865,63 @@
code.Write(" })");
}

/// <inheritdoc/>
/// <remarks>
/// Joining strings is written <c>format!</c> rather than <c>+</c>. Rust implements <c>+</c> only
/// as <c>String + &amp;str</c>, so a literal on the left, or a <c>String</c> on the right, does
/// not compile; <c>format!</c> takes any mix of the two by reference and moves neither. A chain
/// of joins is one call with one placeholder per operand.
/// </remarks>
protected override void GenerateBinary(BinaryExpression binary, CodeBlocker code)
{
Ensure.NotNull(binary);
Ensure.NotNull(code);

if (!IsStringJoin(binary))
{
base.GenerateBinary(binary, code);
return;
}

List<Expression> 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(")");
}

/// <summary>
/// Reports whether a binary expression joins two strings.
/// </summary>
/// <param name="binary">The expression to inspect.</param>
/// <returns>True for an addition either of whose operands is known to be a string.</returns>
private bool IsStringJoin(BinaryExpression binary) =>
binary.Operator == BinaryOperator.Add
&& (IsOwnedString(StaticType(binary.Left)) || IsOwnedString(StaticType(binary.Right)));

/// <summary>
/// Flattens a chain of string joins into its operands, in order.
/// </summary>
/// <param name="operand">The join, or one of its operands.</param>
/// <param name="operands">Receives the operands.</param>
private void CollectJoined(Expression operand, List<Expression> operands)
{
if (operand is BinaryExpression binary && IsStringJoin(binary))
{
CollectJoined(binary.Left, operands);
CollectJoined(binary.Right, operands);
return;
}

operands.Add(operand);
}

/// <inheritdoc/>
/// <remarks>
/// Three shapes, and which one is written depends on what the expression is rather than on where
Expand Down
Loading