diff --git a/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs index 35ebe6c..5429bc3 100644 --- a/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs +++ b/Coder.Test/Languages/GoGeneratedSourceCompilesTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Test.Languages; @@ -229,6 +229,59 @@ public void ConditionalExpressions_InferParameterTypesAndLowerNestedReturns() }); } + /// + /// Tests that mixing an integer and a floating operand types the whole as the floating one, so + /// the function literal a conditional argument becomes returns float64, and a local + /// inferred from 1 + 0.5 is known to be one where a later conditional uses it (issue #152). + /// + [TestMethod] + public void MixedIntegerAndFloatingOperands_PromoteToFloating() + { + if (ToolchainHarness.FindOnPath("version", "go") is null) + { + Assert.Inconclusive("No Go toolchain on the path, so nothing was compiled."); + return; + } + + SourceFile file = new("promotion"); + + FunctionDeclaration half = new("half") { ReturnType = new TypeReference("double") }; + half.Parameters.Add(new Parameter("v", "double")); + half.Body.Add(new ReturnStatement(new BinaryExpression(new VariableReference("v"), BinaryOperator.Divide, Literal.DecimalValue(2.0)))); + file.Members.Add(half); + + FunctionDeclaration argument = new("argument") { ReturnType = new TypeReference("double") }; + argument.Parameters.Add(new Parameter("ready", "bool")); + CallExpression halfOfChoice = new("half"); + halfOfChoice.Arguments.Add(new ConditionalExpression(new VariableReference("ready"), Literal.Number(1), Literal.DecimalValue(2.5))); + argument.Body.Add(new ReturnStatement(halfOfChoice)); + file.Members.Add(argument); + + FunctionDeclaration inferred = new("inferred") { ReturnType = new TypeReference("double") }; + inferred.Parameters.Add(new Parameter("ready", "bool")); + inferred.Body.Add(new VariableDeclaration("x", initialValue: new BinaryExpression(Literal.Number(1), BinaryOperator.Add, Literal.DecimalValue(0.5)))); + CallExpression halfOfLocal = new("half"); + halfOfLocal.Arguments.Add(new ConditionalExpression(new VariableReference("ready"), new VariableReference("x"), Literal.Number(1))); + inferred.Body.Add(new ReturnStatement(halfOfLocal)); + file.Members.Add(inferred); + + string source = new GoGenerator().Generate(file); + Assert.Contains("func() float64 {", source, StringComparison.Ordinal); + Assert.DoesNotContain("func() int {", source, StringComparison.Ordinal); + + ToolchainHarness.InTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "go.mod"), Module); + File.WriteAllText(Path.Combine(directory, "promotion.go"), source); + (int exitCode, string output) = ToolchainHarness.Run("go", "build ./...", directory); + Assert.AreEqual(0, exitCode, $"Go rejected the generated source:{Environment.NewLine}{output}{Environment.NewLine}{source}"); + + (int formatted, string differs) = ToolchainHarness.Run("gofmt", "-l promotion.go", directory); + Assert.AreEqual(0, formatted, $"gofmt did not run:{Environment.NewLine}{differs}"); + Assert.AreEqual(string.Empty, differs.Trim(), "gofmt would rewrite the generated source."); + }); + } + /// /// Tests that a condition comparing against a struct literal compiles in every position a /// conditional is lowered to an if, and is still what gofmt writes. diff --git a/Coder/Languages/GoGenerator.cs b/Coder/Languages/GoGenerator.cs index 075e21d..f1058d4 100644 --- a/Coder/Languages/GoGenerator.cs +++ b/Coder/Languages/GoGenerator.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.Coder.Languages; @@ -1763,15 +1763,17 @@ private void WriteConditionOperand(AstNode operand, CodeBlocker code) /// Where the statement around it could not take the lowering — nested inside another expression, /// or passed as an argument — what is left is a function literal called where it stands, which is /// the only expression Go has that can choose. It has to name what it answers with and the AST - /// does not say, so the type is read off whichever branch says what it is, and is any when - /// neither does — which is what this generator writes wherever a type was never given. + /// may not say, so the type is the conditional's own expected type if it has one, and otherwise is + /// read off its branches — the wider of the two when both are numbers, since 1 fits a + /// float64 and 2.5 does not fit an int — and is any when neither says, + /// which is what this generator writes wherever a type was never given. /// protected override void GenerateConditionalExpression(ConditionalExpression conditional, CodeBlocker code) { Ensure.NotNull(conditional); Ensure.NotNull(code); - TypeReference? branchType = StaticType(conditional.WhenTrue) ?? StaticType(conditional.WhenFalse); + TypeReference? branchType = StaticType(conditional); code.WriteLine($"func() {(branchType is null ? TypeMappings[UnknownTypeName] : SpellType(branchType))} {{"); code.Indent(); if (branchType is null) diff --git a/Coder/Languages/StandardLanguageGenerator.cs b/Coder/Languages/StandardLanguageGenerator.cs index 311d3c7..2a711a9 100644 --- a/Coder/Languages/StandardLanguageGenerator.cs +++ b/Coder/Languages/StandardLanguageGenerator.cs @@ -183,7 +183,7 @@ protected void RegisterStaticType(VariableDeclaration declaration) case ConstructionExpression { Type: not null } construction: return construction.Type; case ConditionalExpression conditional: - return StaticType(conditional.WhenTrue) ?? StaticType(conditional.WhenFalse); + return Promoted(StaticType(conditional.WhenTrue), StaticType(conditional.WhenFalse)); case BinaryExpression binary when binary.Operator is BinaryOperator.Equal or BinaryOperator.NotEqual or BinaryOperator.LessThan @@ -194,7 +194,7 @@ or BinaryOperator.LogicalAnd or BinaryOperator.LogicalOr: return new TypeReference("bool"); case BinaryExpression binary: - return StaticType(binary.Left) ?? StaticType(binary.Right); + return Promoted(StaticType(binary.Left), StaticType(binary.Right)); case VariableReference reference: foreach (StaticTypeScope scope in staticTypeScopes) { @@ -221,6 +221,36 @@ or BinaryOperator.LogicalAnd return staticTypeScopes.TryPeek(out StaticTypeScope? scope) ? scope.ReturnType : null; } + /// + /// The numeric types in the order a mixed operation widens to, narrowest first. + /// + private static readonly string[] NumericPromotionOrder = ["int", "long", "float", "double"]; + + /// + /// Combines the types of two operands, or of a conditional's two branches, into the type of the + /// whole. + /// + /// The first operand's type, if known. + /// The second operand's type, if known. + /// + /// The wider of two numeric types, so 1 + 0.5 and ready ? 1 : 2.5 are a + /// double as they are in every C-family language; otherwise the first known type. + /// + protected static TypeReference? Promoted(TypeReference? first, TypeReference? second) + { + int firstRank = NumericRank(first); + int secondRank = NumericRank(second); + return firstRank >= 0 && secondRank > firstRank ? second : first ?? second; + } + + /// + /// Reports where a type stands in . + /// + /// The type to place. + /// Its position, or -1 when it is not a plain numeric type. + private static int NumericRank(TypeReference? type) => + type is null ? -1 : Array.IndexOf(NumericPromotionOrder, type.ToString()); + private sealed record StaticTypeScope(Dictionary Types, TypeReference? ReturnType); private sealed class StaticTypeScopeGuard(StandardLanguageGenerator generator) : IDisposable