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
138 changes: 138 additions & 0 deletions Coder.Test/Serialization/LiteralRoundTripTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Test.Serialization;

using ktsu.Coder.Ast;
using ktsu.Coder.Languages;
using ktsu.Coder.Serialization;

/// <summary>
/// Tests that every literal the <see cref="Literal"/> helpers can build survives a YAML round trip.
/// </summary>
/// <remarks>
/// A string literal whose text reads as another YAML scalar used to be written bare, so
/// <c>Literal.Text("null")</c> came back as <c>''</c>. Float and long literals had no case in the
/// serializer at all, so they were written as <c>{}</c> and read back as nothing (ktsu-dev/Coder#83).
/// </remarks>
[TestClass]
public class LiteralRoundTripTests
{
[TestMethod]
[DataRow("null")]
[DataRow("Null")]
[DataRow("~")]
[DataRow("true")]
[DataRow("False")]
[DataRow("123")]
[DataRow("-4.5")]
[DataRow("1e3")]
[DataRow(".inf")]
[DataRow(".nan")]
[DataRow("")]
[DataRow("plain text")]
public void RoundTrip_StringLiteral_KeepsText(string text)
{
string yaml = new YamlSerializer().Serialize(Literal.Text(text));

AstNode? deserialized = new YamlDeserializer().Deserialize(yaml);

LiteralExpression<string> literal = Assert.IsInstanceOfType<LiteralExpression<string>>(deserialized);
Assert.AreEqual(text, literal.Value);
}

[TestMethod]
[DataRow(1.5f)]
[DataRow(-0.1f)]
[DataRow(3.0f)]
[DataRow(float.MaxValue)]
[DataRow(float.PositiveInfinity)]
[DataRow(float.NegativeInfinity)]
[DataRow(float.NaN)]
public void RoundTrip_FloatLiteral_KeepsValue(float value)
{
string yaml = new YamlSerializer().Serialize(Literal.SingleValue(value));

AstNode? deserialized = new YamlDeserializer().Deserialize(yaml);

LiteralExpression<float> literal = Assert.IsInstanceOfType<LiteralExpression<float>>(deserialized);
Assert.AreEqual(value, literal.Value);
}

[TestMethod]
[DataRow(5000000000L)]
[DataRow(-5000000000L)]
[DataRow(long.MaxValue)]
[DataRow(long.MinValue)]
public void RoundTrip_LongLiteral_KeepsValue(long value)
{
string yaml = new YamlSerializer().Serialize(Literal.BigNumberValue(value));

AstNode? deserialized = new YamlDeserializer().Deserialize(yaml);

LiteralExpression<long> literal = Assert.IsInstanceOfType<LiteralExpression<long>>(deserialized);
Assert.AreEqual(value, literal.Value);
}

[TestMethod]
public void RoundTrip_VariableInitialisedToLong_KeepsInitialiser()
{
VariableDeclaration declaration = new("big", "long", Literal.BigNumberValue(5000000000L));
string yaml = new YamlSerializer().Serialize(declaration);

AstNode? deserialized = new YamlDeserializer().Deserialize(yaml);

VariableDeclaration roundTripped = Assert.IsInstanceOfType<VariableDeclaration>(deserialized);
LiteralExpression<long> initialiser = Assert.IsInstanceOfType<LiteralExpression<long>>(roundTripped.InitialValue);
Assert.AreEqual(5000000000L, initialiser.Value);
}

/// <summary>
/// Every generator writes a float and a long literal rather than throwing
/// <see cref="NotSupportedException"/>.
/// </summary>
[TestMethod]
public void EveryGenerator_WritesFloatAndLongLiterals()
{
ILanguageGenerator[] generators =
[
new CSharpGenerator(),
new CGenerator(),
new CppGenerator(),
new GoGenerator(),
new RustGenerator(),
new PythonGenerator(),
new JavaScriptGenerator(),
];

foreach (ILanguageGenerator generator in generators)
{
string name = generator.GetType().Name;
Assert.Contains("1.5", generator.Generate(Literal.SingleValue(1.5f)), StringComparison.Ordinal, name);
Assert.Contains("5000000000", generator.Generate(Literal.BigNumberValue(5000000000L)), StringComparison.Ordinal, name);
}
}

[TestMethod]
public void CSharpGenerator_SuffixesFloatAndLongLiterals()
{
CSharpGenerator generator = new();

Assert.AreEqual("1.5f", generator.Generate(Literal.SingleValue(1.5f)).Trim());
Assert.AreEqual("5000000000L", generator.Generate(Literal.BigNumberValue(5000000000L)).Trim());
}

[TestMethod]
public void SharedGenerators_KeepAWholeNumberFloatFloatingPoint()
{
Assert.AreEqual("3.0", new CGenerator().Generate(Literal.SingleValue(3.0f)).Trim());
Assert.AreEqual("0.1", new PythonGenerator().Generate(Literal.SingleValue(0.1f)).Trim());
}

[TestMethod]
public void NegatingANegativeLong_DoesNotFuseTheSigns()
{
UnaryExpression negation = new(UnaryOperator.Negate, Literal.BigNumberValue(-5000000000L));

Assert.DoesNotContain("--", new CGenerator().Generate(negation), StringComparison.Ordinal);
}
}
6 changes: 6 additions & 0 deletions Coder/Languages/CSharpGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code)
case LiteralExpression<double> doubleLit:
code.Write($"{doubleLit.Value.ToString(CultureInfo.InvariantCulture)}d");
break;
case LiteralExpression<float> floatLit:
code.Write($"{floatLit.Value.ToString(CultureInfo.InvariantCulture)}f");
break;
case LiteralExpression<long> longLit:
code.Write($"{longLit.Value.ToString(CultureInfo.InvariantCulture)}L");
break;
case SourceFile file:
GenerateSourceFile(file, code);
break;
Expand Down
4 changes: 4 additions & 0 deletions Coder/Languages/GoGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,8 @@ value is LiteralExpression<string>
or LiteralExpression<int>
or LiteralExpression<bool>
or LiteralExpression<double>
or LiteralExpression<float>
or LiteralExpression<long>
or AstLeafNode<string>
or AstLeafNode<int>
or AstLeafNode<bool>;
Expand Down Expand Up @@ -1663,6 +1665,8 @@ private string BranchType(ConditionalExpression conditional) =>
LiteralExpression<int> or AstLeafNode<int> => "int",
LiteralExpression<bool> or AstLeafNode<bool> => "bool",
LiteralExpression<double> => "float64",
LiteralExpression<float> => "float32",
LiteralExpression<long> => "int64",
ConstructionExpression { Type: not null } built => SpellType(built.Type),
_ => null,
};
Expand Down
42 changes: 36 additions & 6 deletions Coder/Languages/LanguageGeneratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ protected bool TryGenerateCommonNode(AstNode node, CodeBlocker code)
code.Write(FormatDouble(doubleLit.Value));
return true;

case LiteralExpression<float> floatLit:
code.Write(FormatSingle(floatLit.Value));
return true;

case LiteralExpression<long> longLit:
code.Write(longLit.Value.ToString(CultureInfo.InvariantCulture));
return true;

// Legacy support for AstLeafNode types
case AstLeafNode<string> strLeaf:
code.Write($"\"{EscapeString(strLeaf.Value ?? string.Empty)}\"");
Expand Down Expand Up @@ -700,6 +708,8 @@ protected void GenerateUnaryExpression(UnaryExpression unaryExpr, CodeBlocker co
{
LiteralExpression<int> { Value: < 0 } => '-',
LiteralExpression<double> doubleLit when double.IsNegative(doubleLit.Value) => '-',
LiteralExpression<float> floatLit when float.IsNegative(floatLit.Value) => '-',
LiteralExpression<long> { Value: < 0 } => '-',
AstLeafNode<int> { Value: < 0 } => '-',
_ => null,
};
Expand Down Expand Up @@ -736,6 +746,8 @@ or LiteralExpression<string>
or LiteralExpression<int>
or LiteralExpression<bool>
or LiteralExpression<double>
or LiteralExpression<float>
or LiteralExpression<long>
or VariableDeclaration
or AssignmentStatement
or AstLeafNode<string>
Expand Down Expand Up @@ -818,12 +830,30 @@ protected static string EscapeString(string value)
/// and type an inferred local as an integer. A value with a point or an exponent already reads as a
/// float, and one that is not finite is left as it is — there is no number to add a point to.
/// </remarks>
protected static string FormatDouble(double value)
{
string text = value.ToString("R", CultureInfo.InvariantCulture);
bool readsAsInteger = double.IsFinite(value) && text.IndexOfAny(['.', 'E', 'e']) < 0;
return readsAsInteger ? text + ".0" : text;
}
protected static string FormatDouble(double value) =>
WithFraction(value.ToString("R", CultureInfo.InvariantCulture), double.IsFinite(value));

/// <summary>
/// Formats a single-precision value so that every target reads it as floating-point.
/// </summary>
/// <param name="value">The value to format.</param>
/// <returns>The value's shortest round-trip spelling, with <c>.0</c> added to a whole number.</returns>
/// <remarks>
/// Formatted as a <see cref="float"/> rather than widened to <see cref="double"/> first, which
/// would write <c>0.1f</c> as <c>0.10000000149011612</c>. The targets without a single-precision
/// literal read the text as a double, which converts back to the same float.
/// </remarks>
protected static string FormatSingle(float value) =>
WithFraction(value.ToString("R", CultureInfo.InvariantCulture), float.IsFinite(value));

/// <summary>
/// Adds <c>.0</c> to a finite number's text when it would otherwise read as an integer.
/// </summary>
/// <param name="text">The number's round-trip spelling.</param>
/// <param name="isFinite">Whether the number is finite.</param>
/// <returns>The text as a floating-point literal.</returns>
private static string WithFraction(string text, bool isFinite) =>
isFinite && text.IndexOfAny(['.', 'E', 'e']) < 0 ? text + ".0" : text;

/// <summary>
/// Maps a unary operator to its C-family spelling.
Expand Down
39 changes: 37 additions & 2 deletions Coder/Serialization/YamlDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@
}
}

if (dict.TryGetValue("expectedType", out object? typeObj))

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.

Check warning on line 543 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal 'expectedType' 6 times.
{
callExpr.ExpectedType = typeObj?.ToString();
}
Expand Down Expand Up @@ -778,7 +778,39 @@
/// read. Failing here drops the literal, so a declaration initialised to infinity would come back
/// with no initialiser at all (ktsu-dev/Coder#78).
/// </remarks>
private static bool TryParseDouble(string? text, out double value)
private static bool TryParseDouble(string? text, out double value) =>
TryParseNonFinite(text, out value)
|| double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);

/// <summary>
/// Parses a YAML float scalar as a <see cref="float"/>, including the core schema's spellings of
/// the non-finite values.
/// </summary>
/// <param name="text">The scalar's text.</param>
/// <param name="value">The parsed value.</param>
/// <returns><see langword="true"/> if <paramref name="text"/> is a float.</returns>
/// <remarks>
/// Parsed as a <see cref="float"/> directly rather than through <see cref="double"/>, so the
/// shortest spelling the serializer wrote comes back as the same value without a second rounding.
/// </remarks>
private static bool TryParseSingle(string? text, out float value)
{
if (TryParseNonFinite(text, out double nonFinite))
{
value = (float)nonFinite;
return true;
}

return float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}

/// <summary>
/// Reads the YAML core schema's spellings of infinity and NaN.
/// </summary>
/// <param name="text">The scalar's text.</param>
/// <param name="value">The value it spells.</param>
/// <returns><see langword="true"/> if <paramref name="text"/> is one of those spellings.</returns>
private static bool TryParseNonFinite(string? text, out double value)
{
switch (text)
{
Expand All @@ -792,7 +824,8 @@
value = double.NaN;
return true;
default:
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
value = 0;
return false;
}
}

Expand Down Expand Up @@ -1177,7 +1210,7 @@
return returnStmt;
}

private BinaryExpression DeserializeBinaryExpression(object? nodeData)

Check warning on line 1213 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 1213 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
{
BinaryExpression binaryExpr = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -1292,6 +1325,8 @@
"Int32" when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue) => new LiteralExpression<int>(intValue),
"Boolean" when bool.TryParse(value?.ToString(), out bool boolValue) => new LiteralExpression<bool>(boolValue),
"Double" when TryParseDouble(value?.ToString(), out double doubleValue) => new LiteralExpression<double>(doubleValue),
"Single" when TryParseSingle(value?.ToString(), out float floatValue) => new LiteralExpression<float>(floatValue),
"Int64" when long.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long longValue) => new LiteralExpression<long>(longValue),
_ => null,
};

Expand Down Expand Up @@ -1324,7 +1359,7 @@
return varRef;
}

private VariableDeclaration DeserializeVariableDeclaration(object? nodeData)

Check warning on line 1362 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 1362 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
VariableDeclaration varDecl = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down Expand Up @@ -1370,7 +1405,7 @@
return varDecl;
}

private AssignmentStatement DeserializeAssignmentStatement(object? nodeData)

Check warning on line 1408 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 1408 in Coder/Serialization/YamlDeserializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
AssignmentStatement assignment = new();
if (nodeData is Dictionary<object, object> dict)
Expand Down
7 changes: 7 additions & 0 deletions Coder/Serialization/YamlSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_serializer = new SerializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.DisableAliases()
.WithQuotingNecessaryStrings()
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections)
.Build();
}
Expand Down Expand Up @@ -174,6 +175,12 @@
case LiteralExpression<double> doubleLit:
SerializeLiteralExpression(doubleLit, nodeData);
break;
case LiteralExpression<float> floatLit:
SerializeLiteralExpression(floatLit, nodeData);
break;
case LiteralExpression<long> longLit:
SerializeLiteralExpression(longLit, nodeData);
break;
case VariableReference varRef:
SerializeVariableReference(varRef, nodeData);
break;
Expand Down Expand Up @@ -841,7 +848,7 @@

private static void SerializeLiteralExpression<T>(LiteralExpression<T> literal, Dictionary<string, object> nodeData)
{
if (literal.Value != null)

Check warning on line 851 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Use a comparison to 'default(T)' instead or add a constraint to 'T' so that it can't be a value type.

Check warning on line 851 in Coder/Serialization/YamlSerializer.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Use a comparison to 'default(T)' instead or add a constraint to 'T' so that it can't be a value type.
{
nodeData[ValueKey] = literal.Value;
}
Expand Down
Loading