diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs index 6a5de18..4e0eb29 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs @@ -332,6 +332,134 @@ public static object Test(Current current, Resistance resistance, Length length) Assert.Equal(expected, result); } + [Theory] + [InlineData("var squared = current * current; var combined = squared + squared;", "combined * resistance")] + [InlineData("var squared = current * current; var combined = squared - squared;", "combined * resistance")] + [InlineData("var squared = current * current; var combined = -squared;", "combined * resistance")] + [InlineData("var squared = current * current;", "(-(squared + squared)) * resistance")] + public void SameDimensionArithmeticPropagatesInferredLocalDimensions(string declarations, string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Resistance resistance) + { + {{declarations}} + return {{expression}}; + } + } + """; + ImmutableArray result = AnalyseReturn(source); + Dimension current = new(ElectricCurrent: 1); + Dimension squared = new(ElectricCurrent: 2); + Dimension resistance = new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2); + Dimension power = new(Length: 2, Mass: 1, Time: -3); + ArithmeticOperation product = new(ArithmeticOperationKind.Multiply, current, current, squared); + ArithmeticOperation heating = new(ArithmeticOperationKind.Multiply, squared, resistance, power); + ArithmeticOperation[] expected = [product, heating]; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("current + length")] + [InlineData("current - length")] + [InlineData("current + 2")] + [InlineData("2 + current")] + [InlineData("current - 2")] + [InlineData("2 - current")] + [InlineData("current + missing")] + public void InvalidSumsDoNotSupplyDimensionsToEnclosingArithmetic(string sum) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Length length, Time time) => ({{sum}}) * time; + } + """; + ImmutableArray result = Analyse(Compile(source)); + + Assert.Empty(result); + } + + [Theory] + [InlineData("squared + area")] + [InlineData("squared - area")] + public void InvalidInferredSumsRetainChildOperationsWithoutInferringAResult(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Length length, Time time) + { + var squared = current * current; + var area = length * length; + var invalid = {{expression}}; + return invalid * time; + } + } + """; + ImmutableArray result = AnalyseReturn(source); + Dimension current = new(ElectricCurrent: 1); + Dimension length = new(Length: 1); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.Multiply, current, current, new(ElectricCurrent: 2)), + new(ArithmeticOperationKind.Multiply, length, length, new(Length: 2)) + ]; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("(2 - 3) * current * time")] + [InlineData("-(2 + 3) * current * time")] + [InlineData("(-current) * time")] + public void ScalarSumsAndNegationPreserveDimensions(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Time time) => {{expression}}; + } + """; + ImmutableArray result = Analyse(Compile(source)); + Dimension current = new(ElectricCurrent: 1); + Dimension time = new(Time: 1); + ArithmeticOperation expected = new(ArithmeticOperationKind.Multiply, current, time, new(Time: 1, ElectricCurrent: 1)); + + Assert.Equal(expected, Assert.Single(result)); + } + + [Theory] + [InlineData("(wrapper + wrapper) * time")] + [InlineData("(wrapper - wrapper) * time")] + [InlineData("(-wrapper) * time")] + public void BoundOperatorReturnTypesRemainAuthoritative(string expression) + { + string source = $$""" + public sealed class Wrapper + { + public static Current operator +(Wrapper left, Wrapper right) => Current.FromAmps(2); + public static Current operator -(Wrapper left, Wrapper right) => Current.FromAmps(1); + public static Current operator -(Wrapper value) => Current.FromAmps(-1); + } + public static class Consumer + { + public static object Test(Wrapper wrapper, Time time) + { + return {{expression}}; + } + } + """; + ImmutableArray result = AnalyseReturn(source); + Dimension current = new(ElectricCurrent: 1); + Dimension time = new(Time: 1); + ArithmeticOperation expected = new(ArithmeticOperationKind.Multiply, current, time, new(Time: 1, ElectricCurrent: 1)); + + Assert.Equal(expected, Assert.Single(result)); + } + private static ImmutableArray Analyse(CSharpCompilation compilation) { SyntaxTree tree = compilation.SyntaxTrees.Single(candidate => candidate.FilePath == "Consumer.cs"); diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 8ea7e15..c858faf 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -420,6 +420,88 @@ public static object Value(Current current) () => Assert.Null(output.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions"))); } + [Theory] + [InlineData("Current.FromAmps(2) + Current.FromMilliAmps(500)", "AsAmps", 2.5)] + [InlineData("Current.FromAmps(2) - Current.FromMilliAmps(500)", "AsAmps", 1.5)] + [InlineData("-Current.FromMilliAmps(500)", "AsAmps", -0.5)] + [InlineData("Energy.FromJoules(2) + Torque.FromNewtonMetres(3)", "AsJoules", 5)] + public void SameDimensionArithmeticUsesCoreOperatorsWithoutGeneratedOverloads(string expression, string accessor, double expected) + { + string source = $$""" + public static class Consumer + { + public static double Value() => ({{expression}}).{{accessor}}(); + } + """; + double result = EvaluateSource(source); + CSharpCompilation compilation = Compile(source); + + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.Null(compilation.GetTypeByMetadataName("DimensionalQuantities.Generated.ArithmeticExtensions"))); + } + + [Theory] + [InlineData("firstSquared + secondSquared", 52)] + [InlineData("firstSquared - secondSquared", 20)] + [InlineData("-firstSquared", -36)] + public void SameDimensionArithmeticThroughInferredLocalsCompilesAndExecutes(string expression, double expected) + { + string source = $$""" + public static class Consumer + { + public static double Value() + { + Current first = Current.FromAmps(3); + Current second = Current.FromAmps(2); + Resistance resistance = Resistance.FromOhms(4); + var firstSquared = first * first; + var secondSquared = second * second; + var combined = {{expression}}; + var heating = combined * resistance; + return ((Power)heating).AsWatts(); + } + } + """; + double result = EvaluateSource(source); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("((Current.FromAmps(3) * Current.FromAmps(3) + Current.FromAmps(2) * Current.FromAmps(2)) * Resistance.FromOhms(4)).AsWatts()", 52)] + [InlineData("((Current.FromAmps(3) * Current.FromAmps(3) - Current.FromAmps(2) * Current.FromAmps(2)) * Resistance.FromOhms(4)).AsWatts()", 20)] + [InlineData("(-(Current.FromAmps(3) * Current.FromAmps(3))).AsSiBaseUnit()", -9)] + public void SameDimensionArithmeticAroundNestedProductsCompilesAndExecutes(string expression, double expected) + { + double result = Evaluate(expression); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("current + length")] + [InlineData("current - length")] + [InlineData("current + 2")] + [InlineData("2 - current")] + public void InvalidSameDimensionArithmeticRemainsACompilerError(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Value(Current current, Length length) => {{expression}}; + } + """; + CSharpCompilation input = CreateInputCompilation(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: _parseOptions); + driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray diagnostics, TestContext.Current.CancellationToken); + Diagnostic[] errors = output.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Equal("CS0019", Assert.Single(errors).Id)); + } + private static T Evaluate(string expression) { string source = $$""" diff --git a/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs b/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs index d020aa9..a5e6efe 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs @@ -56,7 +56,19 @@ private Operand AnalyseExpression(ExpressionSyntax expression) { Operand left = AnalyseExpression(binary.Left); Operand right = AnalyseExpression(binary.Right); - return Combine(binary, left, right); + if (binary.IsKind(SyntaxKind.AddExpression) || binary.IsKind(SyntaxKind.SubtractExpression)) + { + return AnalyseSum(binary, left, right); + } + + return CombineProduct(binary, left, right); + } + + if (expression is PrefixUnaryExpressionSyntax unary && unary.IsKind(SyntaxKind.UnaryMinusExpression)) + { + Operand operand = AnalyseExpression(unary.Operand); + Operand bound = ClassifyLeaf(unary); + return bound.Kind != OperandKind.Unsupported ? bound : operand; } return ClassifyLeaf(expression); @@ -117,7 +129,24 @@ private Operand ResolveLocal(ILocalSymbol local) } } - private Operand Combine(BinaryExpressionSyntax binary, Operand left, Operand right) + private Operand AnalyseSum(BinaryExpressionSyntax binary, Operand left, Operand right) + { + Operand bound = ClassifyLeaf(binary); + if (bound.Kind != OperandKind.Unsupported) + { + return bound; + } + + if (left.Kind == OperandKind.Scalar && right.Kind == OperandKind.Scalar) + { + return new(OperandKind.Scalar); + } + + return left.Kind == OperandKind.Quantity && right.Kind == OperandKind.Quantity && left.Dimension == right.Dimension + ? left : default; + } + + private Operand CombineProduct(BinaryExpressionSyntax binary, Operand left, Operand right) { if (left.Kind == OperandKind.Scalar && right.Kind == OperandKind.Scalar) { @@ -155,7 +184,8 @@ private Operand Combine(BinaryExpressionSyntax binary, Operand left, Operand rig return default; } - private static bool IsSupported(BinaryExpressionSyntax binary) => binary.IsKind(SyntaxKind.MultiplyExpression) || binary.IsKind(SyntaxKind.DivideExpression); + private static bool IsSupported(BinaryExpressionSyntax binary) => binary.IsKind(SyntaxKind.MultiplyExpression) || binary.IsKind(SyntaxKind.DivideExpression) + || binary.IsKind(SyntaxKind.AddExpression) || binary.IsKind(SyntaxKind.SubtractExpression); private enum OperandKind { diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index fd27a52..b5e97b3 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -44,6 +44,47 @@ namespace DimensionalQuantities.Tests.TypeSystem; public sealed class QuantityTests { + [Theory] + [InlineData(3, 2, 5)] + [InlineData(-3, 2, -1)] + [InlineData(3, -3, 0)] + public void AdditionPreservesDimensionAndAddsSiMagnitudes(double first, double second, double expectedValue) + { + Current original = Current.FromSiBaseUnit(first); + Current other = Current.FromSiBaseUnit(second); + Current result = original + other; + Current expected = Current.FromSiBaseUnit(expectedValue); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(3, 2, 1)] + [InlineData(2, 3, -1)] + [InlineData(-3, -3, 0)] + public void SubtractionPreservesDimensionAndSubtractsSiMagnitudes(double first, double second, double expectedValue) + { + Current original = Current.FromSiBaseUnit(first); + Current other = Current.FromSiBaseUnit(second); + Current result = original - other; + Current expected = Current.FromSiBaseUnit(expectedValue); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(3, -3)] + [InlineData(-3, 3)] + [InlineData(0, 0)] + public void NegationPreservesDimensionAndReversesSign(double value, double expectedValue) + { + SquareRootLength original = SquareRootLength.FromSiBaseUnit(value); + SquareRootLength result = -original; + SquareRootLength expected = SquareRootLength.FromSiBaseUnit(expectedValue); + + Assert.Equal(expected, result); + } + [Fact] public void MultiplyingAndDividingByScalarsPreservesQuantityTypeAndSiMagnitude() { diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index 0428dcb..8625408 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -9,6 +9,9 @@ namespace DimensionalQuantities.TypeSystem; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public readonly record struct Quantity { + public static Quantity operator +(Quantity left, Quantity right) => new(left.ValueInSiBaseUnit + right.ValueInSiBaseUnit); + public static Quantity operator -(Quantity left, Quantity right) => new(left.ValueInSiBaseUnit - right.ValueInSiBaseUnit); + public static Quantity operator -(Quantity quantity) => new(-quantity.ValueInSiBaseUnit); public static Quantity operator *(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit * scalar); public static Quantity operator *(double scalar, Quantity quantity) => new(scalar * quantity.ValueInSiBaseUnit); public static Quantity operator /(Quantity quantity, double scalar) => new(quantity.ValueInSiBaseUnit / scalar);