diff --git a/Semantics.SourceGenerators/Generators/GeneratorBase.cs b/Semantics.SourceGenerators/Generators/GeneratorBase.cs index ef0bf2c..e74e16a 100644 --- a/Semantics.SourceGenerators/Generators/GeneratorBase.cs +++ b/Semantics.SourceGenerators/Generators/GeneratorBase.cs @@ -12,7 +12,7 @@ namespace Semantics.SourceGenerators; public abstract class GeneratorBase(string metadataFilename) : IIncrementalGenerator { - public void Initialize(IncrementalGeneratorInitializationContext context) + public virtual void Initialize(IncrementalGeneratorInitializationContext context) { // Find the conversions metadata JSON file IncrementalValuesProvider metadataFiles = context.AdditionalTextsProvider diff --git a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs index 541c1d8..b75c14e 100644 --- a/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs +++ b/Semantics.SourceGenerators/Generators/QuantitiesGenerator.cs @@ -7,6 +7,7 @@ namespace Semantics.SourceGenerators; using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using ktsu.CodeBlocker; using Microsoft.CodeAnalysis; using Semantics.SourceGenerators.Models; @@ -30,13 +31,76 @@ public class QuantitiesGenerator : GeneratorBase public QuantitiesGenerator() : base("dimensions.json") { } + /// + /// Holds the metadata that drives quantity emission. Combined from dimensions.json and + /// units.json so factory methods can apply per-unit conversion factors. + /// + private sealed record CombinedMetadata(DimensionsMetadata Dimensions, UnitsMetadata Units); + + /// + /// Override to load both dimensions.json and units.json. The base class only loads a single + /// metadata file; we need both because per-unit conversion factors are required to emit + /// From{Unit} factories that aren't the SI base unit. + /// + public override void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider dimensionsProvider = LoadJson(context, "dimensions.json"); + IncrementalValueProvider unitsProvider = LoadJson(context, "units.json"); + IncrementalValueProvider combined = dimensionsProvider.Combine(unitsProvider).Select(static (pair, _) => + pair.Left == null ? null : new CombinedMetadata(pair.Left, pair.Right ?? new UnitsMetadata())); + + context.RegisterSourceOutput(combined, (ctx, metadata) => + { + if (metadata == null) + { + return; + } + + using CodeBlocker codeBlocker = CodeBlocker.Create(); + GenerateInner(ctx, metadata.Dimensions, metadata.Units, codeBlocker); + }); + } + + private static IncrementalValueProvider LoadJson(IncrementalGeneratorInitializationContext context, string filename) + where TMeta : class + { + return context.AdditionalTextsProvider + .Where(file => file.Path.EndsWith(filename, StringComparison.InvariantCulture)) + .Select((file, ct) => file.GetText(ct)?.ToString() ?? "") + .Where(content => !string.IsNullOrEmpty(content)) + .Select((content, _) => + { + try + { + return JsonSerializer.Deserialize(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException) + { + return null; + } + }) + .Where(m => m != null) + .Collect() + .Select((arr, _) => arr.FirstOrDefault()); + } + + /// + /// The legacy abstract entry point is unused: the + /// overridden handles registration and calls + /// directly. This shim exists to satisfy the abstract contract. + /// protected override void Generate(SourceProductionContext context, DimensionsMetadata metadata, CodeBlocker codeBlocker) + => GenerateInner(context, metadata, new UnitsMetadata(), codeBlocker); + + private void GenerateInner(SourceProductionContext context, DimensionsMetadata metadata, UnitsMetadata units, CodeBlocker codeBlocker) { if (metadata.PhysicalDimensions == null || metadata.PhysicalDimensions.Count == 0) { return; } + Dictionary unitMap = BuildUnitMap(units); + // Phase A: Build maps and collect operators Dictionary dimensionMap = BuildDimensionMap(metadata); Dictionary typeFormMap = BuildTypeFormMap(metadata); @@ -50,12 +114,12 @@ protected override void Generate(SourceProductionContext context, DimensionsMeta { if (dim.Quantities.Vector0 != null) { - EmitV0BaseType(context, dim, operatorsByOwner, typeFormMap); + EmitV0BaseType(context, dim, operatorsByOwner, typeFormMap, unitMap); } if (dim.Quantities.Vector1 != null) { - EmitV1BaseType(context, dim, operatorsByOwner, typeFormMap); + EmitV1BaseType(context, dim, operatorsByOwner, typeFormMap, unitMap); } int[] vectorDims = [2, 3, 4]; @@ -77,7 +141,7 @@ protected override void Generate(SourceProductionContext context, DimensionsMeta { foreach (OverloadDefinition overload in form.Overloads) { - EmitOverloadType(context, dim, f, form.Base, overload, typeFormMap); + EmitOverloadType(context, dim, f, form.Base, overload, typeFormMap, unitMap); } } } @@ -323,6 +387,107 @@ private static void ReportUnknownReference(SourceProductionContext context, stri fieldPath)); } + private static Dictionary BuildUnitMap(UnitsMetadata units) + { + Dictionary map = []; + if (units.UnitCategories == null) + { + return map; + } + + foreach (UnitCategory cat in units.UnitCategories) + { + foreach (UnitDefinition unit in cat.Units) + { + map[unit.Name] = unit; + } + } + + return map; + } + + /// + /// Emits one From{Unit} static factory per entry in . + /// The first unit is treated as the SI base unit (no conversion). Subsequent units use the + /// conversion factor / magnitude / offset declared in . + /// + private static void AddUnitFactories( + ClassTemplate cls, + List availableUnits, + Dictionary unitMap, + string typeName, + string fullType, + string crefForComment) + { + if (availableUnits == null || availableUnits.Count == 0) + { + return; + } + + string baseUnit = availableUnits[0]; + foreach (string unitName in availableUnits) + { + bool isBase = unitName == baseUnit; + string conversionExpr = isBase + ? "value" + : BuildToBaseExpression(unitName, unitMap); + + cls.Members.Add(new MethodTemplate() + { + Comments = + [ + "/// ", + $"/// Creates a new {crefForComment} from a value in {unitName}.", + "/// ", + $"/// The value in {unitName}.", + $"/// A new {crefForComment} instance.", + ], + Keywords = ["public", "static", fullType], + Name = $"From{unitName}", + Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], + BodyFactory = (body) => body.Write($" => Create({conversionExpr});"), + }); + } + } + + /// + /// Builds the C# expression converting value in to the SI + /// base unit. Honours magnitude (Kilo, Centi, …), conversionFactor (lookup in + /// ), and offset (additive, after scaling). + /// + private static string BuildToBaseExpression(string unitName, Dictionary unitMap) + { + // If we don't have unit metadata, fall back to identity. The dimensions.json author is + // responsible for keeping availableUnits in sync with units.json; if a unit is missing, + // the emitted factory passes the value through unchanged so the build still succeeds. + // (A future SEM00x diagnostic could surface this gap.) + if (!unitMap.TryGetValue(unitName, out UnitDefinition? unit) || unit == null) + { + return "value"; + } + + string scaled = "value"; + bool hasMagnitude = !string.IsNullOrEmpty(unit.Magnitude) && unit.Magnitude != "1"; + bool hasFactor = !string.IsNullOrEmpty(unit.ConversionFactor) && unit.ConversionFactor != "1"; + + if (hasMagnitude) + { + scaled = $"(value * T.CreateChecked(MetricMagnitudes.{unit.Magnitude}))"; + } + else if (hasFactor) + { + scaled = $"(value * T.CreateChecked(Units.ConversionConstants.{unit.ConversionFactor}))"; + } + + bool hasOffset = !string.IsNullOrEmpty(unit.Offset) && unit.Offset != "0"; + if (hasOffset) + { + scaled = $"({scaled} + T.CreateChecked(Units.ConversionConstants.{unit.Offset}))"; + } + + return scaled; + } + private static Dictionary> GroupBy(List items, Func keySelector) { Dictionary> groups = []; @@ -349,7 +514,8 @@ private void EmitV0BaseType( SourceProductionContext context, PhysicalDimension dim, Dictionary> operatorsByOwner, - Dictionary typeFormMap) + Dictionary typeFormMap, + Dictionary unitMap) { VectorFormDefinition v0 = dim.Quantities.Vector0!; string typeName = v0.Base; @@ -389,26 +555,8 @@ private void EmitV0BaseType( Name = "Zero => Create(T.Zero)", }); - // Factory methods from available units - if (dim.AvailableUnits.Count > 0) - { - string firstUnit = dim.AvailableUnits[0]; - cls.Members.Add(new MethodTemplate() - { - Comments = - [ - "/// ", - $"/// Creates a new from a value in {firstUnit}.", - "/// ", - $"/// The value in {firstUnit}.", - $"/// A new instance.", - ], - Keywords = ["public", "static", fullType], - Name = $"From{firstUnit}", - Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], - BodyFactory = (body) => body.Write(" => Create(value);"), - }); - } + // Factory methods for every available unit (one From{Unit} per unit, applying conversion). + AddUnitFactories(cls, dim.AvailableUnits, unitMap, typeName, fullType, ""); // V0 subtraction hiding: returns V1 if V1 exists for this dimension if (v1TypeName != null) @@ -446,7 +594,8 @@ private void EmitV1BaseType( SourceProductionContext context, PhysicalDimension dim, Dictionary> operatorsByOwner, - Dictionary typeFormMap) + Dictionary typeFormMap, + Dictionary unitMap) { VectorFormDefinition v1 = dim.Quantities.Vector1!; string typeName = v1.Base; @@ -486,26 +635,8 @@ private void EmitV1BaseType( Name = "Zero => Create(T.Zero)", }); - // Factory methods - if (dim.AvailableUnits.Count > 0) - { - string firstUnit = dim.AvailableUnits[0]; - cls.Members.Add(new MethodTemplate() - { - Comments = - [ - "/// ", - $"/// Creates a new from a value in {firstUnit}.", - "/// ", - $"/// The value in {firstUnit}.", - $"/// A new instance.", - ], - Keywords = ["public", "static", fullType], - Name = $"From{firstUnit}", - Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], - BodyFactory = (body) => body.Write(" => Create(value);"), - }); - } + // Factory methods for every available unit. + AddUnitFactories(cls, dim.AvailableUnits, unitMap, typeName, fullType, ""); // Magnitude method returning V0 base if (v0TypeName != null) @@ -631,7 +762,8 @@ private void EmitOverloadType( int vectorForm, string baseTypeName, OverloadDefinition overload, - Dictionary typeFormMap) + Dictionary typeFormMap, + Dictionary unitMap) { string typeName = overload.Name; string fullType = $"{typeName}"; @@ -677,19 +809,8 @@ private void EmitOverloadType( Name = "Zero => Create(T.Zero)", }); - // Factory methods - if (dim.AvailableUnits.Count > 0) - { - string firstUnit = dim.AvailableUnits[0]; - cls.Members.Add(new MethodTemplate() - { - Comments = [$"/// Creates a new {typeName} from a value in {firstUnit}."], - Keywords = ["public", "static", fullType], - Name = $"From{firstUnit}", - Parameters = [new ParameterTemplate { Type = "T", Name = "value" }], - BodyFactory = (body) => body.Write(" => Create(value);"), - }); - } + // Factory methods for every available unit (overloads inherit the dimension's units). + AddUnitFactories(cls, dim.AvailableUnits, unitMap, typeName, fullType, typeName); // Implicit widening to base type cls.Members.Add(new MethodTemplate() diff --git a/Semantics.Test/Quantities/MultiUnitFactoryTests.cs b/Semantics.Test/Quantities/MultiUnitFactoryTests.cs new file mode 100644 index 0000000..ac9076a --- /dev/null +++ b/Semantics.Test/Quantities/MultiUnitFactoryTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Quantities; + +using ktsu.Semantics.Quantities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Verifies that QuantitiesGenerator emits one From{Unit} factory for every +/// unit listed in a dimension's availableUnits, applying the conversion factor. +/// Issue #48. +/// +[TestClass] +public sealed class MultiUnitFactoryTests +{ + private const double Tolerance = 1e-9; + + // ---- Length ---- + + [TestMethod] + public void Length_FromMeter_Identity() + { + Length l = Length.FromMeter(1.0); + Assert.AreEqual(1.0, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromKilometer_Scales_By_1000() + { + Length l = Length.FromKilometer(1.0); + Assert.AreEqual(1000.0, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromCentimeter_Scales_By_0_01() + { + Length l = Length.FromCentimeter(1.0); + Assert.AreEqual(0.01, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromMillimeter_Scales_By_0_001() + { + Length l = Length.FromMillimeter(1.0); + Assert.AreEqual(0.001, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromFoot_Uses_FeetToMeters_Constant() + { + Length l = Length.FromFoot(1.0); + Assert.AreEqual(0.3048, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromInch_Uses_InchesToMeters_Constant() + { + Length l = Length.FromInch(1.0); + Assert.AreEqual(0.0254, l.Value, Tolerance); + } + + [TestMethod] + public void Length_FromMile_Uses_MileToMeters_Constant() + { + Length l = Length.FromMile(1.0); + Assert.AreEqual(1609.344, l.Value, Tolerance); + } + + // ---- Mass ---- + + [TestMethod] + public void Mass_FromKilogram_Identity() + { + Mass m = Mass.FromKilogram(1.0); + Assert.AreEqual(1.0, m.Value, Tolerance); + } + + [TestMethod] + public void Mass_FromGram_Scales_By_0_001() + { + Mass m = Mass.FromGram(1.0); + Assert.AreEqual(0.001, m.Value, Tolerance); + } + + [TestMethod] + public void Mass_FromPound_Uses_PoundToKilograms_Constant() + { + Mass m = Mass.FromPound(1.0); + Assert.AreEqual(0.45359237, m.Value, Tolerance); + } + + // ---- Time / Duration ---- + + [TestMethod] + public void Duration_FromMinute_Equals_60_Seconds() + { + Duration d = Duration.FromMinute(1.0); + Assert.AreEqual(60.0, d.Value, Tolerance); + } + + [TestMethod] + public void Duration_FromHour_Equals_3600_Seconds() + { + Duration d = Duration.FromHour(1.0); + Assert.AreEqual(3600.0, d.Value, Tolerance); + } + + // ---- Semantic overloads inherit their dimension's full unit set ---- + + [TestMethod] + public void Distance_FromKilometer_Scales_By_1000() + { + Distance d = Distance.FromKilometer(1.0); + Assert.AreEqual(1000.0, d.Value, Tolerance); + } + + [TestMethod] + public void Diameter_FromMillimeter_Scales_By_0_001() + { + Diameter d = Diameter.FromMillimeter(1.0); + Assert.AreEqual(0.001, d.Value, Tolerance); + } + + [TestMethod] + public void Wavelength_FromNanometer_Uses_Nano_Magnitude() + { + Wavelength w = Wavelength.FromNanometer(550.0); + Assert.AreEqual(550.0e-9, w.Value, 1e-15); + } + + // ---- Storage genericity ---- + + [TestMethod] + public void Length_FromKilometer_Works_With_Float() + { + Length l = Length.FromKilometer(1.0f); + Assert.AreEqual(1000.0f, l.Value, 1e-3f); + } + + [TestMethod] + public void Length_FromFoot_Works_With_Decimal() + { + Length l = Length.FromFoot(1m); + Assert.AreEqual(0.3048m, l.Value); + } +}