diff --git a/Coder.Test/Languages/FloatingPointLiteralTests.cs b/Coder.Test/Languages/FloatingPointLiteralTests.cs
new file mode 100644
index 0000000..562bfee
--- /dev/null
+++ b/Coder.Test/Languages/FloatingPointLiteralTests.cs
@@ -0,0 +1,68 @@
+// 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 a floating-point literal is written so that each language reads it as one.
+///
+///
+/// A whole number written without its fraction is an integer in every target, so 1.0 / 2.0
+/// written as 1 / 2 is integer division — zero — in C, C++ and Go, and a type error in Rust.
+///
+[TestClass]
+public class FloatingPointLiteralTests
+{
+ ///
+ /// The generators that spell a double through the shared literal formatting.
+ ///
+ private static readonly ILanguageGenerator[] Generators =
+ [
+ new CGenerator(),
+ new CppGenerator(),
+ new GoGenerator(),
+ new RustGenerator(),
+ new PythonGenerator(),
+ new JavaScriptGenerator(),
+ ];
+
+ ///
+ /// Tests that dividing two whole-number doubles is written as a floating-point division.
+ ///
+ [TestMethod]
+ public void AWholeNumberDoubleKeepsItsFraction()
+ {
+ BinaryExpression half = new(
+ new LiteralExpression(1.0),
+ BinaryOperator.Divide,
+ new LiteralExpression(2.0));
+
+ foreach (ILanguageGenerator generator in Generators)
+ {
+ string code = generator.Generate(half);
+
+ Assert.Contains("1.0 / 2.0", code, StringComparison.Ordinal, $"{generator.GetType().Name} wrote {code}");
+ }
+ }
+
+ ///
+ /// Tests that a value that already reads as a float is written as it round-trips, unchanged.
+ ///
+ /// The value to write.
+ /// What each generator should write.
+ [TestMethod]
+ [DataRow(0.5, "0.5")]
+ [DataRow(-3.25, "-3.25")]
+ [DataRow(1e300, "1E+300")]
+ [DataRow(0.1, "0.1")]
+ public void AFractionalOrExponentDoubleIsWrittenAsItRoundTrips(double value, string expected)
+ {
+ foreach (ILanguageGenerator generator in Generators)
+ {
+ Assert.AreEqual(expected, generator.Generate(new LiteralExpression(value)).Trim(), generator.GetType().Name);
+ }
+ }
+}
diff --git a/Coder/Languages/LanguageGeneratorBase.cs b/Coder/Languages/LanguageGeneratorBase.cs
index a0df1f6..527e445 100644
--- a/Coder/Languages/LanguageGeneratorBase.cs
+++ b/Coder/Languages/LanguageGeneratorBase.cs
@@ -130,7 +130,7 @@ protected bool TryGenerateCommonNode(AstNode node, CodeBlocker code)
return true;
case LiteralExpression doubleLit:
- code.Write(doubleLit.Value.ToString(CultureInfo.InvariantCulture));
+ code.Write(FormatDouble(doubleLit.Value));
return true;
// Legacy support for AstLeafNode types
@@ -772,6 +772,24 @@ protected static string EscapeString(string value)
///
protected static string GetBinaryOperator(BinaryOperator op) => OperatorSymbols.GetSymbol(op);
+ ///
+ /// Spells a floating-point value so that it reads as one.
+ ///
+ /// The value to spell.
+ /// The value's round-trip text, with .0 added when it would otherwise be an integer.
+ ///
+ /// Round-trip formatting drops the fraction of a whole number, and 2 is an integer in every
+ /// target: 1.0 / 2.0 would become integer division in C, C++ and Go, fail to compile in Rust,
+ /// 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;
+ }
+
///
/// Maps a unary operator to its C-family spelling.
///