-
Notifications
You must be signed in to change notification settings - Fork 384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Generate operators from unit relations defined in JSON #1329
Changes from 6 commits
d93e058
ce1a6ff
cfefd26
c7918a3
4de9789
7046318
59e8f7f
049eaf9
7a52a91
5c9abed
2b9d456
f4231c1
5802fb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
// Licensed under MIT No Attribution, see LICENSE file at the root. | ||
// Copyright 2013 Andreas Gullberg Larsen ([email protected]). Maintained at https://github.com/angularsen/UnitsNet. | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.IO; | ||
using System.Linq; | ||
using CodeGen.JsonTypes; | ||
using Newtonsoft.Json; | ||
|
||
namespace CodeGen.Generators | ||
{ | ||
/// <summary> | ||
/// Parses the JSON file that defines the relationships (operators) between quantities | ||
/// and applies them to the parsed quantity objects. | ||
/// </summary> | ||
internal static class QuantityRelationsParser | ||
{ | ||
/// <summary> | ||
/// Parse and apply relations to quantities. | ||
/// Each defined relation can be applied multiple times to one or two quantities depending on the operator and the operands. | ||
/// | ||
/// The format of a relation definition is "Quantity.Unit operator Quantity.Unit = Quantity.Unit". | ||
/// "double" can be used as a unitless operand. | ||
/// "1" can be used as the left operand to define inverse relations. | ||
/// </summary> | ||
/// <param name="rootDir">Repository root directory.</param> | ||
/// <param name="quantities">List of previously parsed Quantity objects.</param> | ||
public static void ParseAndApplyRelations(string rootDir, Quantity[] quantities) | ||
{ | ||
var quantityDictionary = quantities.ToDictionary(q => q.Name, q => q); | ||
|
||
// Add double and 1 as pseudo-quantities to validate relations that use them. | ||
var pseudoQuantity = new Quantity { Name = null!, Units = [new Unit { SingularName = null! }] }; | ||
quantityDictionary["double"] = pseudoQuantity with { Name = "double" }; | ||
quantityDictionary["1"] = pseudoQuantity with { Name = "1" }; | ||
|
||
var relations = ParseRelations(rootDir, quantityDictionary); | ||
|
||
// Because multiplication is commutative, we can infer the other operand order. | ||
relations.AddRange(relations | ||
.Where(r => r.Operator is "*" or "inverse" && r.LeftQuantity != r.RightQuantity) | ||
.Select(r => r with | ||
{ | ||
LeftQuantity = r.RightQuantity, | ||
LeftUnit = r.RightUnit, | ||
RightQuantity = r.LeftQuantity, | ||
RightUnit = r.LeftUnit, | ||
}) | ||
.ToList()); | ||
|
||
// We can infer TimeSpan relations from Duration relations. | ||
var timeSpanQuantity = pseudoQuantity with { Name = "TimeSpan" }; | ||
relations.AddRange(relations | ||
.Where(r => r.LeftQuantity.Name is "Duration") | ||
.Select(r => r with { LeftQuantity = timeSpanQuantity }) | ||
.ToList()); | ||
relations.AddRange(relations | ||
.Where(r => r.RightQuantity.Name is "Duration") | ||
.Select(r => r with { RightQuantity = timeSpanQuantity }) | ||
.ToList()); | ||
|
||
// Sort all relations to keep generated operators in a consistent order. | ||
relations.Sort(); | ||
|
||
foreach (var quantity in quantities) | ||
{ | ||
var quantityRelations = new List<QuantityRelation>(); | ||
|
||
foreach (var relation in relations) | ||
{ | ||
if (relation.LeftQuantity == quantity) | ||
{ | ||
// The left operand of a relation is responsible for generating the operator. | ||
quantityRelations.Add(relation); | ||
} | ||
else if (relation.RightQuantity == quantity && relation.LeftQuantity.Name is "double" or "TimeSpan") | ||
{ | ||
// Because we cannot add generated operators to double or TimeSpan, we make the right operand responsible in this case. | ||
quantityRelations.Add(relation); | ||
} | ||
} | ||
|
||
quantity.Relations = quantityRelations.ToArray(); | ||
} | ||
} | ||
|
||
private static List<QuantityRelation> ParseRelations(string rootDir, IReadOnlyDictionary<string, Quantity> quantities) | ||
{ | ||
var relationsFileName = Path.Combine(rootDir, "Common/UnitRelations.json"); | ||
|
||
try | ||
{ | ||
var text = File.ReadAllText(relationsFileName); | ||
var relationStrings = JsonConvert.DeserializeObject<List<string>>(text) ?? []; | ||
relationStrings.Sort(); | ||
|
||
var parsedRelations = relationStrings.Select(relationString => ParseRelation(relationString, quantities)).ToList(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we throw on duplicates here, with a helpful explanation? It probably gives compile errors in the generated code anyway, but it would be helpful if codegen failed early on invalid input to make it easier for contributors to find out what they did wrong. If we eventually do #1354 , then duplicates could also occur implicitly by one explicit definition conflicting with the implicit definition for division operators. Another option is to remove duplicates with a HashSet or similar, then fix the document when writing it back. But it may be complicated to know what to remove, in particular with the magic division operators, so it is probably better to just throw and have the author fix their mistake. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea, I added a check after all relations are inferred. Duplicate definitions in |
||
|
||
// File parsed successfully, save it back to disk in the sorted state. | ||
File.WriteAllText(relationsFileName, JsonConvert.SerializeObject(relationStrings, Formatting.Indented)); | ||
|
||
return parsedRelations; | ||
} | ||
catch (Exception e) | ||
{ | ||
throw new Exception($"Error parsing relations file: {relationsFileName}", e); | ||
} | ||
} | ||
|
||
private static QuantityRelation ParseRelation(string relationString, IReadOnlyDictionary<string, Quantity> quantities) | ||
{ | ||
var segments = relationString.Split(' '); | ||
|
||
if (segments is not [_, "=", _, "*" or "/", _]) | ||
{ | ||
throw new Exception($"Invalid relation string: {relationString}"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exception can give an example of a valid format. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could, but isn't There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Absolutely, but I still think it improves the developer experience a bit. |
||
} | ||
|
||
var @operator = segments[3]; | ||
var left = segments[2].Split('.'); | ||
var right = segments[4].Split('.'); | ||
var result = segments[0].Split('.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is fine, I was just thinking maybe regex was a good fit for this parsing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried that but it wasn't a big improvement, the regex is quite ugly and less comprehensible than the list pattern: ^(\w+)\.?(\w*) = (\w+)\.?(\w*) (\*|\/) (\w+)\.?(\w*)$
[_, "=", _, "*" or "/", _] |
||
|
||
var leftQuantity = GetQuantity(left[0]); | ||
var rightQuantity = GetQuantity(right[0]); | ||
var resultQuantity = GetQuantity(result[0]); | ||
|
||
var leftUnit = GetUnit(leftQuantity, left.ElementAtOrDefault(1)); | ||
var rightUnit = GetUnit(rightQuantity, right.ElementAtOrDefault(1)); | ||
var resultUnit = GetUnit(resultQuantity, result.ElementAtOrDefault(1)); | ||
|
||
if (leftQuantity.Name == "1") | ||
{ | ||
@operator = "inverse"; | ||
leftQuantity = resultQuantity; | ||
leftUnit = resultUnit; | ||
} | ||
|
||
return new QuantityRelation | ||
{ | ||
Operator = @operator, | ||
LeftQuantity = leftQuantity, | ||
LeftUnit = leftUnit, | ||
RightQuantity = rightQuantity, | ||
RightUnit = rightUnit, | ||
ResultQuantity = resultQuantity, | ||
ResultUnit = resultUnit | ||
}; | ||
|
||
Quantity GetQuantity(string quantityName) | ||
{ | ||
if (!quantities.TryGetValue(quantityName, out var quantity)) | ||
{ | ||
throw new Exception($"Undefined quantity {quantityName} in relation string: {relationString}"); | ||
} | ||
|
||
return quantity; | ||
} | ||
|
||
Unit GetUnit(Quantity quantity, string? unitName) | ||
{ | ||
try | ||
{ | ||
return quantity.Units.First(u => u.SingularName == unitName); | ||
} | ||
catch (InvalidOperationException) | ||
{ | ||
throw new Exception($"Undefined unit {unitName} in relation string: {relationString}"); | ||
} | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,8 @@ internal class QuantityGenerator : GeneratorBase | |
private readonly string _valueType; | ||
private readonly Unit _baseUnit; | ||
|
||
private readonly string[] _decimalTypes = { "BitRate", "Information", "Power" }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it is not too much hassle, I would rather take a list of all quantities in the ctor to determine the value type from their We are not likely to get more There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This wasn't needed anymore since I reworked the codegen, so I removed it 👍 |
||
|
||
public QuantityGenerator(Quantity quantity) | ||
{ | ||
_quantity = quantity ?? throw new ArgumentNullException(nameof(quantity)); | ||
|
@@ -39,8 +41,12 @@ public string Generate() | |
using System; | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Globalization; | ||
using System.Linq; | ||
using System.Runtime.Serialization; | ||
using System.Linq;"); | ||
if (_quantity.Relations.Any(r => r.Operator is "*" or "/")) | ||
Writer.WL(@"#if NET7_0_OR_GREATER | ||
using System.Numerics; | ||
#endif"); | ||
Writer.WL(@"using System.Runtime.Serialization; | ||
using UnitsNet.InternalHelpers; | ||
using UnitsNet.Units; | ||
|
||
|
@@ -67,6 +73,35 @@ namespace UnitsNet | |
public readonly partial struct {_quantity.Name} : | ||
{(_quantity.GenerateArithmetic ? "IArithmeticQuantity" : "IQuantity")}<{_quantity.Name}, {_unitEnumName}, {_quantity.ValueType}>,"); | ||
|
||
if (_quantity.Relations.Any(r => r.Operator is "*" or "/")) | ||
{ | ||
Writer.WL(@$" | ||
#if NET7_0_OR_GREATER"); | ||
foreach (var relation in _quantity.Relations) | ||
{ | ||
if (relation.LeftQuantity == _quantity) | ||
{ | ||
switch (relation.Operator) | ||
{ | ||
case "*": | ||
Writer.W(@" | ||
IMultiplyOperators"); | ||
break; | ||
case "/": | ||
Writer.W(@" | ||
IDivisionOperators"); | ||
break; | ||
default: | ||
continue; | ||
} | ||
Writer.WL($"<{relation.LeftQuantity.Name}, {relation.RightQuantity.Name}, {relation.ResultQuantity.Name}>,"); | ||
} | ||
} | ||
|
||
Writer.WL(@$" | ||
#endif"); | ||
} | ||
|
||
if (_quantity.ValueType == "decimal") Writer.WL(@$" | ||
IDecimalQuantity,"); | ||
|
||
|
@@ -100,6 +135,7 @@ namespace UnitsNet | |
GenerateStaticFactoryMethods(); | ||
GenerateStaticParseMethods(); | ||
GenerateArithmeticOperators(); | ||
GenerateRelationalOperators(); | ||
GenerateEqualityAndComparison(); | ||
GenerateConversionMethods(); | ||
GenerateToString(); | ||
|
@@ -696,6 +732,94 @@ private void GenerateLogarithmicArithmeticOperators() | |
" ); | ||
} | ||
|
||
/// <summary> | ||
/// Generates operators that express relations between quantities as applied by <see cref="QuantityRelationsParser" />. | ||
/// </summary> | ||
private void GenerateRelationalOperators() | ||
angularsen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
if (!_quantity.Relations.Any()) return; | ||
|
||
Writer.WL($@" | ||
#region Relational Operators | ||
"); | ||
|
||
foreach (QuantityRelation relation in _quantity.Relations) | ||
{ | ||
if (relation.Operator == "inverse") | ||
{ | ||
Writer.WL($@" | ||
/// <summary>Calculates the inverse of this quantity.</summary> | ||
/// <returns>The corresponding inverse quantity, <see cref=""{relation.RightQuantity.Name}""/>.</returns> | ||
public {relation.RightQuantity.Name} Inverse() | ||
{{ | ||
return {relation.LeftUnit.PluralName} == 0.0 ? {relation.RightQuantity.Name}.Zero : {relation.RightQuantity.Name}.From{relation.RightUnit.PluralName}(1 / {relation.LeftUnit.PluralName}); | ||
}} | ||
"); | ||
} | ||
else | ||
{ | ||
var leftParameter = relation.LeftQuantity.Name.ToCamelCase(); | ||
var leftConversionProperty = relation.LeftUnit.PluralName; | ||
var rightParameter = relation.RightQuantity.Name.ToCamelCase(); | ||
var rightConversionProperty = relation.RightUnit.PluralName; | ||
|
||
if (relation.LeftQuantity.Name == "TimeSpan") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe reuse some constants for all these repeating strings There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to tidy up a bit, but code generation is always a bit messy and throwing around consts called |
||
{ | ||
leftConversionProperty = "Total" + leftConversionProperty; | ||
} | ||
|
||
if (relation.RightQuantity.Name == "TimeSpan") | ||
{ | ||
rightConversionProperty = "Total" + rightConversionProperty; | ||
} | ||
|
||
if (leftParameter == rightParameter) | ||
{ | ||
leftParameter = "left"; | ||
rightParameter = "right"; | ||
} | ||
|
||
var leftPart = $"{leftParameter}.{leftConversionProperty}"; | ||
var rightPart = $"{rightParameter}.{rightConversionProperty}"; | ||
|
||
if (leftParameter == "double") | ||
{ | ||
leftParameter = "value"; | ||
leftPart = "value"; | ||
} | ||
|
||
if (rightParameter == "double") | ||
{ | ||
rightParameter = "value"; | ||
rightPart = "value"; | ||
} | ||
|
||
var leftCast = _decimalTypes.Contains(relation.LeftQuantity.Name) ? "(double)" : ""; | ||
var rightCast = _decimalTypes.Contains(relation.RightQuantity.Name) ? "(double)" : ""; | ||
|
||
var expression = $"{leftCast}{leftPart} {relation.Operator} {rightCast}{rightPart}"; | ||
|
||
if (relation.ResultQuantity.Name is not ("double" or "decimal")) | ||
{ | ||
expression = $"{relation.ResultQuantity.Name}.From{relation.ResultUnit.PluralName}({expression})"; | ||
} | ||
|
||
Writer.WL($@" | ||
/// <summary>Get <see cref=""{relation.ResultQuantity.Name}""/> from <see cref=""{relation.LeftQuantity.Name}""/> {relation.Operator} <see cref=""{relation.RightQuantity.Name}""/>.</summary> | ||
public static {relation.ResultQuantity.Name} operator {relation.Operator}({relation.LeftQuantity.Name} {leftParameter}, {relation.RightQuantity.Name} {rightParameter}) | ||
{{ | ||
return {expression}; | ||
}} | ||
"); | ||
} | ||
} | ||
|
||
Writer.WL($@" | ||
|
||
#endregion | ||
"); | ||
} | ||
|
||
private void GenerateEqualityAndComparison() | ||
{ | ||
Writer.WL($@" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
// Licensed under MIT No Attribution, see LICENSE file at the root. | ||
// Copyright 2013 Andreas Gullberg Larsen ([email protected]). Maintained at https://github.com/angularsen/UnitsNet. | ||
|
||
using System; | ||
|
||
namespace CodeGen.JsonTypes | ||
{ | ||
internal record QuantityRelation : IComparable<QuantityRelation> | ||
{ | ||
public string Operator = null!; | ||
|
||
public Quantity LeftQuantity = null!; | ||
public Unit LeftUnit = null!; | ||
|
||
public Quantity RightQuantity = null!; | ||
public Unit RightUnit = null!; | ||
|
||
public Quantity ResultQuantity = null!; | ||
public Unit ResultUnit = null!; | ||
|
||
private string SortString => ResultQuantity.Name | ||
+ ResultUnit.SingularName | ||
+ LeftQuantity.Name | ||
+ LeftUnit.SingularName | ||
+ Operator | ||
+ RightQuantity.Name | ||
+ RightUnit.SingularName; | ||
|
||
public int CompareTo(QuantityRelation? other) | ||
{ | ||
return string.Compare(SortString, other?.SortString, StringComparison.Ordinal); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few examples with some actual quantities and units in a
<example></example>
tag could be helpful for the readerThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done 👍