From afd9e1ab8575a3f872bf4f856ed8db78786beb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:26:57 +0000 Subject: [PATCH] [patch] Keep string, float and long literals through a YAML round trip A string literal whose text YAML reads as another scalar (null, ~) was written bare and came back as ''. The serializer now quotes any string the reader would take for a non-string. Float and long literals had no serializer case, so they were written as {} and read back as nothing, and every generator threw for them. They now round-trip through the serializer and deserializer, and the shared generator path, C# (f/L suffixes) and Go's type inference know them. Fixes ktsu-dev/Coder#83 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YLYtHwwqdY3Tk9vpkwT6Sw --- .../Serialization/LiteralRoundTripTests.cs | 138 ++++++++++++++++++ Coder/Languages/CSharpGenerator.cs | 6 + Coder/Languages/GoGenerator.cs | 4 + Coder/Languages/LanguageGeneratorBase.cs | 42 +++++- Coder/Serialization/YamlDeserializer.cs | 39 ++++- Coder/Serialization/YamlSerializer.cs | 7 + 6 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 Coder.Test/Serialization/LiteralRoundTripTests.cs diff --git a/Coder.Test/Serialization/LiteralRoundTripTests.cs b/Coder.Test/Serialization/LiteralRoundTripTests.cs new file mode 100644 index 0000000..087319d --- /dev/null +++ b/Coder.Test/Serialization/LiteralRoundTripTests.cs @@ -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; + +/// +/// Tests that every literal the helpers can build survives a YAML round trip. +/// +/// +/// A string literal whose text reads as another YAML scalar used to be written bare, so +/// Literal.Text("null") came back as ''. Float and long literals had no case in the +/// serializer at all, so they were written as {} and read back as nothing (ktsu-dev/Coder#83). +/// +[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 literal = Assert.IsInstanceOfType>(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 literal = Assert.IsInstanceOfType>(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 literal = Assert.IsInstanceOfType>(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(deserialized); + LiteralExpression initialiser = Assert.IsInstanceOfType>(roundTripped.InitialValue); + Assert.AreEqual(5000000000L, initialiser.Value); + } + + /// + /// Every generator writes a float and a long literal rather than throwing + /// . + /// + [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); + } +} diff --git a/Coder/Languages/CSharpGenerator.cs b/Coder/Languages/CSharpGenerator.cs index 7152f6b..d9e3378 100644 --- a/Coder/Languages/CSharpGenerator.cs +++ b/Coder/Languages/CSharpGenerator.cs @@ -105,6 +105,12 @@ private void GenerateExpressionOrLeaf(AstNode node, CodeBlocker code) case LiteralExpression doubleLit: code.Write($"{doubleLit.Value.ToString(CultureInfo.InvariantCulture)}d"); break; + case LiteralExpression floatLit: + code.Write($"{floatLit.Value.ToString(CultureInfo.InvariantCulture)}f"); + break; + case LiteralExpression longLit: + code.Write($"{longLit.Value.ToString(CultureInfo.InvariantCulture)}L"); + break; case SourceFile file: GenerateSourceFile(file, code); break; diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index 2bee170..c5cd575 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -1321,6 +1321,8 @@ value is LiteralExpression or LiteralExpression or LiteralExpression or LiteralExpression + or LiteralExpression + or LiteralExpression or AstLeafNode or AstLeafNode or AstLeafNode; @@ -1663,6 +1665,8 @@ private string BranchType(ConditionalExpression conditional) => LiteralExpression or AstLeafNode => "int", LiteralExpression or AstLeafNode => "bool", LiteralExpression => "float64", + LiteralExpression => "float32", + LiteralExpression => "int64", ConstructionExpression { Type: not null } built => SpellType(built.Type), _ => null, }; diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs index ec3d004..38ac6d7 100644 --- a/Coder/Languages/LanguageGeneratorBase.cs +++ b/Coder/Languages/LanguageGeneratorBase.cs @@ -133,6 +133,14 @@ protected bool TryGenerateCommonNode(AstNode node, CodeBlocker code) code.Write(FormatDouble(doubleLit.Value)); return true; + case LiteralExpression floatLit: + code.Write(FormatSingle(floatLit.Value)); + return true; + + case LiteralExpression longLit: + code.Write(longLit.Value.ToString(CultureInfo.InvariantCulture)); + return true; + // Legacy support for AstLeafNode types case AstLeafNode strLeaf: code.Write($"\"{EscapeString(strLeaf.Value ?? string.Empty)}\""); @@ -700,6 +708,8 @@ protected void GenerateUnaryExpression(UnaryExpression unaryExpr, CodeBlocker co { LiteralExpression { Value: < 0 } => '-', LiteralExpression doubleLit when double.IsNegative(doubleLit.Value) => '-', + LiteralExpression floatLit when float.IsNegative(floatLit.Value) => '-', + LiteralExpression { Value: < 0 } => '-', AstLeafNode { Value: < 0 } => '-', _ => null, }; @@ -736,6 +746,8 @@ or LiteralExpression or LiteralExpression or LiteralExpression or LiteralExpression + or LiteralExpression + or LiteralExpression or VariableDeclaration or AssignmentStatement or AstLeafNode @@ -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. /// - 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)); + + /// + /// Formats a single-precision value so that every target reads it as floating-point. + /// + /// The value to format. + /// The value's shortest round-trip spelling, with .0 added to a whole number. + /// + /// Formatted as a rather than widened to first, which + /// would write 0.1f as 0.10000000149011612. The targets without a single-precision + /// literal read the text as a double, which converts back to the same float. + /// + protected static string FormatSingle(float value) => + WithFraction(value.ToString("R", CultureInfo.InvariantCulture), float.IsFinite(value)); + + /// + /// Adds .0 to a finite number's text when it would otherwise read as an integer. + /// + /// The number's round-trip spelling. + /// Whether the number is finite. + /// The text as a floating-point literal. + private static string WithFraction(string text, bool isFinite) => + isFinite && text.IndexOfAny(['.', 'E', 'e']) < 0 ? text + ".0" : text; /// /// Maps a unary operator to its C-family spelling. diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs index 0a84ec1..1862e2a 100644 --- a/Coder/Serialization/YamlDeserializer.cs +++ b/Coder/Serialization/YamlDeserializer.cs @@ -778,7 +778,39 @@ private FieldDeclaration DeserializeFieldDeclaration(object? nodeData) /// read. Failing here drops the literal, so a declaration initialised to infinity would come back /// with no initialiser at all (ktsu-dev/Coder#78). /// - 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); + + /// + /// Parses a YAML float scalar as a , including the core schema's spellings of + /// the non-finite values. + /// + /// The scalar's text. + /// The parsed value. + /// if is a float. + /// + /// Parsed as a directly rather than through , so the + /// shortest spelling the serializer wrote comes back as the same value without a second rounding. + /// + 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); + } + + /// + /// Reads the YAML core schema's spellings of infinity and NaN. + /// + /// The scalar's text. + /// The value it spells. + /// if is one of those spellings. + private static bool TryParseNonFinite(string? text, out double value) { switch (text) { @@ -792,7 +824,8 @@ private static bool TryParseDouble(string? text, out double value) value = double.NaN; return true; default: - return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + value = 0; + return false; } } @@ -1292,6 +1325,8 @@ private UnaryExpression DeserializeUnaryExpression(object? nodeData) "Int32" when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue) => new LiteralExpression(intValue), "Boolean" when bool.TryParse(value?.ToString(), out bool boolValue) => new LiteralExpression(boolValue), "Double" when TryParseDouble(value?.ToString(), out double doubleValue) => new LiteralExpression(doubleValue), + "Single" when TryParseSingle(value?.ToString(), out float floatValue) => new LiteralExpression(floatValue), + "Int64" when long.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long longValue) => new LiteralExpression(longValue), _ => null, }; diff --git a/Coder/Serialization/YamlSerializer.cs b/Coder/Serialization/YamlSerializer.cs index 7573710..395baa5 100644 --- a/Coder/Serialization/YamlSerializer.cs +++ b/Coder/Serialization/YamlSerializer.cs @@ -24,6 +24,7 @@ public YamlSerializer() _serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .DisableAliases() + .WithQuotingNecessaryStrings() .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) .Build(); } @@ -174,6 +175,12 @@ private static void SerializeOtherNode(AstNode node, Dictionary case LiteralExpression doubleLit: SerializeLiteralExpression(doubleLit, nodeData); break; + case LiteralExpression floatLit: + SerializeLiteralExpression(floatLit, nodeData); + break; + case LiteralExpression longLit: + SerializeLiteralExpression(longLit, nodeData); + break; case VariableReference varRef: SerializeVariableReference(varRef, nodeData); break;