diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs index 4e0eb29..259cb4a 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityExpressionAnalyserTests.cs @@ -90,8 +90,7 @@ public static class Consumer [InlineData("a * 1m")] [InlineData("a * \"text\"")] [InlineData("a + b")] - [InlineData("a / a")] - public void DiscoverySkipsUnsupportedOrDimensionlessExpressions(string expression) + public void DiscoverySkipsUnsupportedExpressions(string expression) { string source = $$""" public static class Consumer @@ -265,7 +264,6 @@ public static object Test(Current current) [InlineData("var value = other * current; var other = value * current;")] [InlineData("object value = current * current;")] [InlineData("Missing value = current * current;")] - [InlineData("var value = current / current;")] public void DoesNotInferUnsupportedOrCyclicLocals(string declaration) { string source = $$""" @@ -460,6 +458,89 @@ public static object Test(Wrapper wrapper, Time time) Assert.Equal(expected, Assert.Single(result)); } + [Theory] + [InlineData("(length / length) * current * time")] + [InlineData("current * (length / length) * time")] + [InlineData("current / (length / length) * time")] + [InlineData("(-(length / length) + 2) * current * time")] + public void RatiosActAsScalarsInLaterArithmetic(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Length length, 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("ratio * length * time")] + [InlineData("length / ratio * time")] + [InlineData("(ratio + 2) * length * time")] + public void RatiosOfInferredLocalsPreserveChildOperationsAndSupplyScalars(string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Length length, Time time) + { + var squared = current * current; + var ratio = squared / squared; + return {{expression}}; + } + } + """; + ImmutableArray result = AnalyseReturn(source); + Dimension current = new(ElectricCurrent: 1); + Dimension length = new(Length: 1); + Dimension time = new(Time: 1); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.Multiply, current, current, new(ElectricCurrent: 2)), + new(ArithmeticOperationKind.Multiply, length, time, new(Length: 1, Time: 1)) + ]; + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("var cancelled = current * inverse;", "cancelled * length * time")] + [InlineData("var cancelled = current * inverse; var copy = cancelled;", "copy * length * time")] + [InlineData("var cancelled = current * inverse;", "(cancelled - 2) * length * time")] + public void InverseProductsProduceScalarsForLaterArithmetic(string declaration, string expression) + { + string source = $$""" + public static class Consumer + { + public static object Test(Current current, Length length, Time time) + { + var inverse = 2 / current; + {{declaration}} + return {{expression}}; + } + } + """; + ImmutableArray result = AnalyseReturn(source); + Dimension current = new(ElectricCurrent: 1); + Dimension inverse = new(ElectricCurrent: -1); + Dimension length = new(Length: 1); + Dimension time = new(Time: 1); + ArithmeticOperation[] expected = + [ + new(ArithmeticOperationKind.ScalarDivide, default, current, inverse), + new(ArithmeticOperationKind.Multiply, current, inverse, default), + new(ArithmeticOperationKind.Multiply, length, time, new(Length: 1, Time: 1)) + ]; + + Assert.Equal(expected, 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/ArithmeticSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs index f9d492c..ef8a100 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs @@ -52,6 +52,30 @@ public static class Consumer () => Assert.Equal(4, divided)); } + [Theory] + [InlineData("Current.FromAmps(3) * InverseCurrent.FromSiBaseUnit(2)", false)] + [InlineData("InverseCurrent.FromSiBaseUnit(2) * Current.FromAmps(3)", true)] + public void CancellingProductEmitsAnOperatorReturningDouble(string expression, bool inverseFirst) + { + Dimension current = new(ElectricCurrent: 1); + Dimension inverse = new(ElectricCurrent: -1); + ArithmeticOperation operation = inverseFirst + ? new(ArithmeticOperationKind.Multiply, inverse, current, default) + : new(ArithmeticOperationKind.Multiply, current, inverse, default); + QuantityGenerationPlan plan = QuantityDiscovery.Discover([operation]); + string source = $$""" + public static class Consumer + { + public static double Value() => {{expression}}; + } + """; + Assembly assembly = CompileAndEmit(source, plan); + double result = (double)assembly.GetType("Consumer")!.GetMethod("Value")!.Invoke(null, null)!; + double expected = 6; + + Assert.Equal(expected, result); + } + private static Assembly CompileAndEmit(string source, QuantityGenerationPlan plan) { string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs index d7bdedf..06b8ddc 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs @@ -30,6 +30,20 @@ public void PlanKeepsCatalogueNameAndDeduplicatesOrderedOperations() () => Assert.Single(plan.AdditionalQuantities)); } + [Fact] + public void CancellingProductPlansOperandsWithoutADimensionlessQuantity() + { + Dimension current = new(ElectricCurrent: 1); + Dimension inverse = new(ElectricCurrent: -1); + ArithmeticOperation operation = new(ArithmeticOperationKind.Multiply, current, inverse, default); + QuantityGenerationPlan result = QuantityDiscovery.Discover([operation, operation]); + + Assert.Multiple( + () => Assert.Equal(operation, Assert.Single(result.Operations)), + () => Assert.Equal(inverse, Assert.Single(result.AdditionalQuantities).Dimension), + () => Assert.DoesNotContain(result.KnownQuantities, quantity => quantity.Dimension == default)); + } + [Fact] public void UnknownQuantityUsesSiComponentPowersAndBasePrefixes() { diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index c858faf..818b557 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -258,14 +258,13 @@ public static class Consumer } [Fact] - public void GeneratorIgnoresUnrelatedIncompleteAndDimensionlessArithmetic() + public void GeneratorIgnoresUnrelatedAndIncompleteArithmetic() { const string source = """ public static class Consumer { public static int Number(int left, int right) => left * right; public static object Incomplete(Length length) => length * Missing; - public static object Dimensionless(Length length) => length / length; } """; CSharpCompilation input = CreateInputCompilation(source); @@ -502,6 +501,47 @@ public static class Consumer () => Assert.Equal("CS0019", Assert.Single(errors).Id)); } + [Theory] + [InlineData("Length.FromMetres(3) / Length.FromMilliMetres(500)", 6)] + [InlineData("Energy.FromJoules(6) / Torque.FromNewtonMetres(2)", 3)] + [InlineData("Current.FromAmps(3) * (2 / Current.FromAmps(3))", 2)] + [InlineData("(2 / Current.FromAmps(3)) * Current.FromAmps(3)", 2)] + [InlineData("(Current.FromAmps(3) * Current.FromAmps(3)) / (Current.FromAmps(2) * Current.FromAmps(2))", 2.25)] + [InlineData("(Length.FromMetres(3) / Length.FromMetres(1)) + (Current.FromAmps(4) / Current.FromAmps(2))", 5)] + public void CancelledDimensionsCompileAndExecuteAsDouble(string expression, double expected) + { + double result = Evaluate(expression); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("var ratio = first / second;", "(ratio * time).AsSeconds()", 6)] + [InlineData("var firstSquared = first * first; var secondSquared = second * second; var ratio = firstSquared / secondSquared;", "(ratio * time).AsSeconds()", 9)] + [InlineData("var inverse = 2 / first; var ratio = first * inverse; var copy = ratio;", "(copy * time).AsSeconds()", 8)] + [InlineData("var inverse = 2 / first; var ratio = first * inverse;", "(time / ratio).AsSeconds()", 2)] + [InlineData("var inverse = 2 / first; var ratio = first * inverse; var scaled = (ratio + 1) * first;", "(scaled * time).AsSiBaseUnit()", 36)] + [InlineData("var ratio = first / second; var inverseTime = ratio / time;", "inverseTime.AsSiBaseUnit()", 0.375)] + public void CancelledLocalsCanBeUsedAsScalarOperands(string declarations, string expression, double expected) + { + string source = $$""" + public static class Consumer + { + public static double Value() + { + Current first = Current.FromAmps(3); + Current second = Current.FromAmps(2); + Time time = Time.FromSeconds(4); + {{declarations}} + return {{expression}}; + } + } + """; + double result = EvaluateSource(source); + + Assert.Equal(expected, result); + } + 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 a5e6efe..62ed5ae 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/QuantityExpressionAnalyser.cs @@ -174,11 +174,18 @@ private Operand CombineProduct(BinaryExpressionSyntax binary, Operand left, Oper { ArithmeticOperationKind kind = binary.IsKind(SyntaxKind.MultiplyExpression) ? ArithmeticOperationKind.Multiply : ArithmeticOperationKind.Divide; Dimension result = kind == ArithmeticOperationKind.Multiply ? left.Dimension * right.Dimension : left.Dimension / right.Dimension; - if (result != default) + if (result == default && kind == ArithmeticOperationKind.Divide) { - _operations.Add(new(kind, left.Dimension, right.Dimension, result)); - return new(OperandKind.Quantity, result); + return new(OperandKind.Scalar); + } + + _operations.Add(new(kind, left.Dimension, right.Dimension, result)); + if (result == default) + { + return new(OperandKind.Scalar); } + + return new(OperandKind.Quantity, result); } return default; diff --git a/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs index 27731d5..313d0cb 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/ArithmeticSourceEmitter.cs @@ -21,6 +21,18 @@ internal static class ArithmeticExtensions private static string RenderOperator(ArithmeticOperation operation, QuantityGenerationPlan plan) { string right = plan.GetQuantityName(operation.Right); + if (operation.Kind == ArithmeticOperationKind.Multiply && operation.Result == default) + { + string cancellingLeft = plan.GetQuantityName(operation.Left); + return $$""" + extension({{cancellingLeft}}) + { + public static double operator *({{cancellingLeft}} left, {{right}} right) => + left.AsSiBaseUnit() * right.AsSiBaseUnit(); + } + """; + } + string result = plan.GetQuantityName(operation.Result); if (operation.Kind == ArithmeticOperationKind.ScalarDivide) { diff --git a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs index b5e97b3..06ded5e 100644 --- a/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs +++ b/src/DimensionalQuantities.Tests/TypeSystem/QuantityTests.cs @@ -44,6 +44,19 @@ namespace DimensionalQuantities.Tests.TypeSystem; public sealed class QuantityTests { + [Theory] + [InlineData(6, 2, 3)] + [InlineData(-6, 2, -3)] + [InlineData(0, 2, 0)] + public void DividingMatchingDimensionsReturnsDouble(double numerator, double denominator, double expected) + { + SquareRootLength original = SquareRootLength.FromSiBaseUnit(numerator); + SquareRootLength divisor = SquareRootLength.FromSiBaseUnit(denominator); + double result = original / divisor; + + Assert.Equal(expected, result); + } + [Theory] [InlineData(3, 2, 5)] [InlineData(-3, 2, -1)] diff --git a/src/DimensionalQuantities/TypeSystem/Quantity.cs b/src/DimensionalQuantities/TypeSystem/Quantity.cs index ef490ea..058b867 100644 --- a/src/DimensionalQuantities/TypeSystem/Quantity.cs +++ b/src/DimensionalQuantities/TypeSystem/Quantity.cs @@ -15,6 +15,7 @@ public readonly record struct Quantity 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); + public static double operator /(Quantity left, Quantity right) => left.ValueInSiBaseUnit / right.ValueInSiBaseUnit; // Each closed quantity type intentionally caches its own decoded dimension. // ReSharper disable once StaticMemberInGenericType