diff --git a/Coder.Test/Serialization/CultureInvariantDeserializationTests.cs b/Coder.Test/Serialization/CultureInvariantDeserializationTests.cs
new file mode 100644
index 0000000..2d1f8f2
--- /dev/null
+++ b/Coder.Test/Serialization/CultureInvariantDeserializationTests.cs
@@ -0,0 +1,141 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Serialization;
+
+using System.Globalization;
+using ktsu.Coder.Ast;
+using ktsu.Coder.Serialization;
+
+///
+/// Tests that YAML documents round-trip identically regardless of the current culture.
+///
+/// The serializer emits scalars through YamlDotNet, which writes numbers invariantly
+/// (dot-decimal). The deserializer must therefore read them back invariantly too; parsing
+/// against the current culture turns "3.14" into 314 on a culture that groups with "."
+/// (de-DE, fr-FR, es-ES, ...), silently corrupting the loaded AST.
+///
+[TestClass]
+[DoNotParallelize]
+public class CultureInvariantDeserializationTests
+{
+ ///
+ /// A culture whose decimal separator is "," and whose group separator is ".", like de-DE,
+ /// fr-FR, es-ES and the rest of continental Europe. That pairing is what makes a dot-decimal
+ /// literal parse to a wrong value rather than fail outright: "3.14" reads as three thousand
+ /// one hundred and four tenths of nothing — 314.
+ ///
+ /// It is built from the invariant culture rather than named, because the test project runs in
+ /// globalization-invariant mode where new CultureInfo("de-DE") throws. Building it this
+ /// way also keeps the test hermetic: it asserts against a fixed number format rather than
+ /// whatever ICU data the host happens to carry.
+ ///
+ private static CultureInfo DotGroupingCulture()
+ {
+ CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone();
+ culture.NumberFormat.NumberDecimalSeparator = ",";
+ culture.NumberFormat.NumberGroupSeparator = ".";
+ culture.NumberFormat.NegativeSign = "−";
+ return culture;
+ }
+
+ [TestMethod]
+ public void DeserializeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue()
+ {
+ LiteralExpression literal = Literal.DecimalValue(3.14);
+
+ LiteralExpression roundTripped = RoundTripUnder>(DotGroupingCulture(), literal);
+
+ Assert.AreEqual(3.14, roundTripped.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeNegativeFractionLiteral_UnderDotGroupingCulture_RoundTripsValue()
+ {
+ LiteralExpression literal = Literal.DecimalValue(-0.125);
+
+ LiteralExpression roundTripped = RoundTripUnder>(DotGroupingCulture(), literal);
+
+ Assert.AreEqual(-0.125, roundTripped.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeIntegerLiteral_UnderDotGroupingCulture_RoundTripsValue()
+ {
+ LiteralExpression literal = Literal.Number(-42);
+
+ LiteralExpression roundTripped = RoundTripUnder>(DotGroupingCulture(), literal);
+
+ Assert.AreEqual(-42, roundTripped.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeBooleanLiteral_UnderDotGroupingCulture_RoundTripsValue()
+ {
+ LiteralExpression literal = Literal.Bool(true);
+
+ LiteralExpression roundTripped = RoundTripUnder>(DotGroupingCulture(), literal);
+
+ Assert.IsTrue(roundTripped.Value);
+ }
+
+ [TestMethod]
+ public void SerializeFractionLiteral_UnderDotGroupingCulture_WritesDotDecimal()
+ {
+ // The deserializer reads invariantly, so the serializer must write invariantly too;
+ // this pins the half of the contract the fix depends on.
+ string yaml = UnderCulture(DotGroupingCulture(), () => new YamlSerializer().Serialize(Literal.DecimalValue(3.14)));
+
+ Assert.Contains("3.14", yaml);
+ }
+
+ [TestMethod]
+ public void DeserializeFunctionBody_UnderDotGroupingCulture_PreservesReturnedFraction()
+ {
+ FunctionDeclaration function = new("Rate") { ReturnType = "double" };
+ function.Body.Add(new ReturnStatement(Literal.DecimalValue(0.5)));
+
+ FunctionDeclaration roundTripped = RoundTripUnder(DotGroupingCulture(), function);
+
+ ReturnStatement returned = (ReturnStatement)roundTripped.Body[0];
+ LiteralExpression value = (LiteralExpression)returned.Expression!;
+ Assert.AreEqual(0.5, value.Value);
+ }
+
+ ///
+ /// Serializes and deserializes with installed
+ /// as the current culture for both halves of the trip.
+ ///
+ private static T RoundTripUnder(CultureInfo culture, AstNode node) where T : AstNode
+ {
+ AstNode? deserialized = UnderCulture(culture, () =>
+ {
+ string yaml = new YamlSerializer().Serialize(node);
+ return new YamlDeserializer().Deserialize(yaml);
+ });
+
+ Assert.IsNotNull(deserialized);
+ Assert.IsInstanceOfType(deserialized);
+ return (T)deserialized;
+ }
+
+ ///
+ /// Runs on a dedicated thread with as the
+ /// current culture, so the ambient culture of the test host is never mutated.
+ ///
+ private static TResult UnderCulture(CultureInfo culture, Func action)
+ {
+ // LongRunning gets a dedicated thread rather than a pool one, so the culture is discarded
+ // with the thread instead of outliving the test on a thread someone else will be handed.
+ // The task carries any failure back to this thread, so the round trip needs no catch of its own.
+ return Task.Factory.StartNew(
+ () =>
+ {
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.CurrentUICulture = culture;
+ return action();
+ },
+ CancellationToken.None,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Default).GetAwaiter().GetResult();
+ }
+}
diff --git a/Coder/Serialization/YamlDeserializer.cs b/Coder/Serialization/YamlDeserializer.cs
index 883abb0..bf22676 100644
--- a/Coder/Serialization/YamlDeserializer.cs
+++ b/Coder/Serialization/YamlDeserializer.cs
@@ -5,6 +5,7 @@ namespace ktsu.Coder.Serialization;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using ktsu.Coder.Ast;
@@ -118,7 +119,7 @@ _ when nodeType.StartsWith("leaf<", StringComparison.OrdinalIgnoreCase) || nodeT
return valueType switch
{
"String" => new AstLeafNode(nodeData?.ToString() ?? string.Empty),
- "Int32" when int.TryParse(nodeData?.ToString(), out int intValue) => new AstLeafNode(intValue),
+ "Int32" when int.TryParse(nodeData?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int intValue) => new AstLeafNode(intValue),
"Boolean" when bool.TryParse(nodeData?.ToString(), out bool boolValue) => new AstLeafNode(boolValue),
_ => null,
};
@@ -1258,9 +1259,9 @@ private UnaryExpression DeserializeUnaryExpression(object? nodeData)
Expression? result = valueType switch
{
"String" => new LiteralExpression(value?.ToString() ?? string.Empty),
- "Int32" when int.TryParse(value?.ToString(), out int intValue) => new LiteralExpression(intValue),
+ "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 double.TryParse(value?.ToString(), out double doubleValue) => new LiteralExpression(doubleValue),
+ "Double" when double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double doubleValue) => new LiteralExpression(doubleValue),
_ => null,
};