From cdc4b342c175c580c14bde770f6349e4da19e19c Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 22:22:50 +0100 Subject: [PATCH 1/6] refactor!: extract optional metric conversions Move catalogue conversions into DimensionalQuantities.Extensions.Metric while retaining quantity identities and SI bases in core. Generate compiled extensions at build time and release both packages with symbols and Source Link. BREAKING CHANGE: Catalogue conversions now require DimensionalQuantities.Extensions.Metric and its namespace. Core retains the aliases, arithmetic, and generic SI conversion APIs. --- .github/scripts/check-package.py | 92 ++++++- .github/workflows/cd.yml | 11 +- .github/workflows/ci.yml | 6 +- README.md | 10 + .../DimensionalQuantities.Build.csproj | 11 + src/DimensionalQuantities.Build/Program.cs | 31 +++ .../Units/EngineeringAtomicUnits.cs | 19 ++ .../Units/EngineeringCatalogue.cs | 43 +-- ...lQuantities.Extensions.Metric.Tests.csproj | 19 ++ .../MetricConversionsTests.cs | 80 ++++++ ...nsionalQuantities.Extensions.Metric.csproj | 38 +++ .../PackagedGeneratorTests.cs | 259 ++++++++++-------- .../Packaging/PackageTestSupport.cs | 53 ++++ .../Packaging/SymbolPackageTests.cs | 166 +++++++++++ .../QuantityDiscoveryCoordinatorTests.cs | 6 +- .../ArithmeticSourceEmitterTests.cs | 6 +- .../Generators/CoreConversionBoundaryTests.cs | 52 ++++ .../Generators/QuantityGeneratorTests.cs | 52 ++-- .../Generators/QuantitySourceEmitterTests.cs | 109 +------- .../Analyzers/DimensionNames.cs | 4 +- .../DimensionalQuantities.Roslyn.csproj | 1 + .../Discovery/PlannedQuantity.cs | 10 +- .../Generators/QuantityDiscovery.cs | 4 +- .../Generators/QuantitySourceEmitter.cs | 155 +---------- .../Generators/QuantityTypeNameFormatter.cs | 23 ++ .../Generators/UnitConversionEmitter.cs | 134 +++++++++ .../Generators/UnitConversionSpec.cs | 6 + .../Units/UnitExpressionTests.cs | 11 +- src/DimensionalQuantities.slnx | 5 + .../DimensionalQuantities.csproj | 7 + .../Units/AtomicUnits.cs | 12 - .../Units/EngineeringQuantityAliases.cs | 32 +++ src/Directory.Build.props | 13 + 33 files changed, 1019 insertions(+), 461 deletions(-) create mode 100644 src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj create mode 100644 src/DimensionalQuantities.Build/Program.cs create mode 100644 src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs rename src/{DimensionalQuantities => DimensionalQuantities.Build}/Units/EngineeringCatalogue.cs (58%) create mode 100644 src/DimensionalQuantities.Extensions.Metric.Tests/DimensionalQuantities.Extensions.Metric.Tests.csproj create mode 100644 src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs create mode 100644 src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj create mode 100644 src/DimensionalQuantities.IntegrationTests/Packaging/PackageTestSupport.cs create mode 100644 src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/CoreConversionBoundaryTests.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/QuantityTypeNameFormatter.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs create mode 100644 src/DimensionalQuantities/Units/EngineeringQuantityAliases.cs diff --git a/.github/scripts/check-package.py b/.github/scripts/check-package.py index a4c2d14..aa62e43 100644 --- a/.github/scripts/check-package.py +++ b/.github/scripts/check-package.py @@ -1,19 +1,89 @@ from pathlib import Path +import os import sys import xml.etree.ElementTree as ET import zipfile + version = sys.argv[1] -packages = list(Path("artifacts/packages").glob("*.nupkg")) -assert len(packages) == 1, packages -with zipfile.ZipFile(packages[0]) as package: - names = package.namelist() - assert "lib/netstandard2.0/DimensionalQuantities.dll" in names - assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.dll" in names - assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.CodeFixes.dll" in names +package_directory = Path("artifacts/packages") +core_id = "DimensionalQuantities" +metric_id = "DimensionalQuantities.Extensions.Metric" +expected_packages = { + f"{core_id}.{version}.nupkg", + f"{metric_id}.{version}.nupkg", +} +expected_symbols = { + f"{core_id}.{version}.snupkg", + f"{metric_id}.{version}.snupkg", +} +actual_packages = {package.name for package in package_directory.glob("*.nupkg")} +actual_symbols = {package.name for package in package_directory.glob("*.snupkg")} +assert actual_packages == expected_packages, (actual_packages, expected_packages) +assert actual_symbols == expected_symbols, (actual_symbols, expected_symbols) + + +def read_package(package_id, extension): + path = package_directory / f"{package_id}.{version}.{extension}" + with zipfile.ZipFile(path) as package: + names = set(package.namelist()) + specification = ET.fromstring(package.read(f"{package_id}.nuspec")) + metadata = specification.find("{*}metadata") + assert metadata is not None + assert metadata.find("{*}id").text == package_id + assert metadata.find("{*}version").text == version + return names, metadata + + +core_files, core_metadata = read_package(core_id, "nupkg") +metric_files, metric_metadata = read_package(metric_id, "nupkg") +core_symbols, _ = read_package(core_id, "snupkg") +metric_symbols, _ = read_package(metric_id, "snupkg") + +core_runtime = "lib/netstandard2.0/DimensionalQuantities.dll" +metric_runtime = "lib/netstandard2.0/DimensionalQuantities.Extensions.Metric.dll" +analyzer_directory = "analyzers/dotnet/cs/" +tooling = { + f"{analyzer_directory}DimensionalQuantities.dll", + f"{analyzer_directory}DimensionalQuantities.Roslyn.dll", + f"{analyzer_directory}DimensionalQuantities.Roslyn.CodeFixes.dll", +} +assert core_runtime in core_files +assert tooling <= core_files, tooling - core_files +assert metric_runtime in metric_files +assert core_runtime not in metric_files +assert not any(name.startswith(analyzer_directory) for name in metric_files) + +for names in (core_files, metric_files): assert not any("Microsoft.CodeAnalysis" in name for name in names) - specification = ET.fromstring(package.read("DimensionalQuantities.nuspec")) - assert specification.find(".//{*}version").text == version - dependencies = specification.findall(".//{*}dependency") + assert not any("DimensionalQuantities.Build" in name for name in names) + assert not any(".Tests" in name for name in names) + +for metadata in (core_metadata, metric_metadata): + repository = metadata.find("{*}repository") + assert repository is not None + assert repository.attrib["type"] == "git" + assert repository.attrib["url"].removesuffix(".git") == "https://github.com/GoSimulate/DimensionalQuantities" + commit = repository.attrib["commit"] + assert len(commit) == 40 and all(character in "0123456789abcdef" for character in commit) + if "GITHUB_SHA" in os.environ: + assert commit == os.environ["GITHUB_SHA"], (commit, os.environ["GITHUB_SHA"]) + dependencies = metadata.findall(".//{*}dependency") assert not any("Roslyn" in item.attrib["id"] or "CodeAnalysis" in item.attrib["id"] for item in dependencies) -print(f"Verified {packages[0]}: runtime plus compiler tooling, version {version}") + assert not any("DimensionalQuantities.Build" in item.attrib["id"] for item in dependencies) + +core_dependencies = core_metadata.findall(".//{*}dependency") +assert not any(item.attrib["id"] == metric_id for item in core_dependencies) +metric_dependencies = metric_metadata.findall(".//{*}dependency") +core_dependency = [item for item in metric_dependencies if item.attrib["id"] == core_id] +assert len(core_dependency) == 1, core_dependency +assert core_dependency[0].attrib["version"] == f"[{version}]", core_dependency[0].attrib +assert core_dependency[0].attrib.get("include", "All").lower() == "all", core_dependency[0].attrib +assert "analyzers" not in core_dependency[0].attrib.get("exclude", "").lower(), core_dependency[0].attrib + +assert "lib/netstandard2.0/DimensionalQuantities.pdb" in core_symbols +assert "lib/netstandard2.0/DimensionalQuantities.Extensions.Metric.pdb" in metric_symbols +for assembly in tooling: + assert assembly.removesuffix(".dll") + ".pdb" in core_symbols + +print(f"Verified {core_id} and {metric_id} packages and symbols at {version}") diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0b1490d..bf6ab77 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,6 +6,7 @@ on: - 'src/**' - '!src/*Tests/**' - '.github/workflows/cd.yml' + - '.github/scripts/check-package.py' workflow_dispatch: permissions: contents: read @@ -61,10 +62,10 @@ jobs: run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --results-directory artifacts/test-results - name: Pack run: >- - dotnet pack src/DimensionalQuantities/DimensionalQuantities.csproj - -c Release --no-build -o artifacts/packages + dotnet pack src/DimensionalQuantities.slnx + -c Release --no-build --no-restore -o artifacts/packages -p:Version="$VERSION" -p:ContinuousIntegrationBuild=true -p:RepositoryUrl="$REPOSITORY_URL" - - name: Check NuGet package + - name: Check NuGet packages run: python3 .github/scripts/check-package.py "$VERSION" - name: Generate release notes id: notes @@ -96,7 +97,7 @@ jobs: run: >- dotnet nuget push 'artifacts/packages/*.nupkg' --source "$PACKAGE_SOURCE" - --api-key "$GITHUB_TOKEN" --skip-duplicate + --api-key "$GITHUB_TOKEN" --skip-duplicate --no-symbols - name: Create GitHub release uses: ncipollo/release-action@v1.21.0 with: @@ -104,7 +105,7 @@ jobs: tag: ${{ needs.version.outputs.next }} commit: ${{ github.sha }} body: ${{ steps.notes.outputs.changes }} - artifacts: artifacts/packages/*.nupkg + artifacts: artifacts/packages/*.nupkg,artifacts/packages/*.snupkg artifactErrorsFailBuild: true makeLatest: true - uses: actions/upload-artifact@v7.0.2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f90290..c30bb93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,10 @@ jobs: run: dotnet test --solution src/DimensionalQuantities.slnx -c Release --no-build --no-restore --report-trx --results-directory artifacts/test-results - name: Pack run: >- - dotnet pack src/DimensionalQuantities/DimensionalQuantities.csproj - -c Release --no-build -o artifacts/packages + dotnet pack src/DimensionalQuantities.slnx + -c Release --no-build --no-restore -o artifacts/packages -p:Version="$VERSION" -p:ContinuousIntegrationBuild=true -p:RepositoryUrl="$REPOSITORY_URL" - - name: Check NuGet package + - name: Check NuGet packages run: python3 .github/scripts/check-package.py "$VERSION" - uses: actions/upload-artifact@v7.0.2 if: always() diff --git a/README.md b/README.md index 28af484..65e6439 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ A C# library for dimensional quantities, with generated type aliases, SI unit co The runtime library targets .NET Standard 2.0. Consumers need a C# 14 compiler for the generated extension blocks. The `DimensionalQuantities` NuGet package includes the runtime library, source generator, analysers and an alias quick fix. +Core includes the seven SI base quantities' conversion helpers. Add `DimensionalQuantities.Extensions.Metric` for catalogue conversions such as volts, watts and pascals, and import its namespace: + +```csharp +using DimensionalQuantities.Extensions.Metric; +``` + +Metric depends on the matching core version, so referencing Metric also supplies core and its compiler tooling. Quantity names and dimensional arithmetic are available with core alone. + ```csharp Voltage voltage = Voltage.FromVolts(12); Current current = Current.FromAmps(3); @@ -169,6 +177,8 @@ Alias collision warnings do not suppress or rename generated aliases. Use qualif ### Current scope +When upgrading from the combined package, add the Metric package and namespace wherever you use catalogue conversions, including their `FromPrefixedUnits` and `AsPrefixedUnits` helpers. Their names and conversion behaviour are unchanged. Core-only consumers can use `FromSiBaseUnit`, `AsSiBaseUnit`, `FromArbitrarySiComponents` and `AsArbitrarySiComponents` for every quantity. + Discovery uses the types and operations that the compiler can resolve as generated quantities become available. It supports async methods and iterators with quantity return or element types, but does not infer a new variable type from arbitrary reassignment chains. The library is under active development; the API and supported expression patterns are still evolving. diff --git a/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj b/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj new file mode 100644 index 0000000..69036bf --- /dev/null +++ b/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj @@ -0,0 +1,11 @@ + + + Exe + net11.0 + false + + + + + + diff --git a/src/DimensionalQuantities.Build/Program.cs b/src/DimensionalQuantities.Build/Program.cs new file mode 100644 index 0000000..50f4bb9 --- /dev/null +++ b/src/DimensionalQuantities.Build/Program.cs @@ -0,0 +1,31 @@ +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.Build.Units; +using DimensionalQuantities.Units; + +if (args.Length != 1) +{ + throw new ArgumentException("Expected the generated source path."); +} + +List quantities = []; +foreach (KeyValuePair entry in EngineeringCatalogue.Entries) +{ + if (!EngineeringQuantityAliases.Entries.TryGetValue(entry.Key, out string[]? aliases) || + !aliases.SequenceEqual(entry.Value.Select(unit => unit.QuantityName))) + { + throw new InvalidOperationException($"Metric catalogue aliases differ from core for {entry.Key}."); + } + + quantities.Add(new(entry.Value[0].QuantityName, QuantityTypeNameFormatter.Format(entry.Key), + [.. entry.Value], true)); +} + +string source = "// \n" + UnitConversionEmitter.Emit(quantities, + "DimensionalQuantities.Extensions.Metric", "public"); +string path = Path.GetFullPath(args[0]); +Directory.CreateDirectory(Path.GetDirectoryName(path)!); +if (!File.Exists(path) || File.ReadAllText(path) != source) +{ + File.WriteAllText(path, source); +} diff --git a/src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs b/src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs new file mode 100644 index 0000000..d091db3 --- /dev/null +++ b/src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs @@ -0,0 +1,19 @@ +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Build.Units; + +internal static class EngineeringAtomicUnits +{ + public static readonly AtomicUnit Radian = new("Radian", "Radians", "rad"); + public static readonly AtomicUnit Hertz = new("Hertz", "Hertz", "Hz"); + public static readonly AtomicUnit Pascal = new("Pascal", "Pascals", "Pa"); + public static readonly AtomicUnit Joule = new("Joule", "Joules", "J"); + public static readonly AtomicUnit Newton = new("Newton", "Newtons", "N"); + public static readonly AtomicUnit Watt = new("Watt", "Watts", "W"); + public static readonly AtomicUnit Volt = new("Volt", "Volts", "V"); + public static readonly AtomicUnit Ohm = new("Ohm", "Ohms", "Ω"); + public static readonly AtomicUnit Henry = new("Henry", "Henries", "H"); + public static readonly AtomicUnit Weber = new("Weber", "Webers", "Wb"); + public static readonly AtomicUnit Tesla = new("Tesla", "Teslas", "T"); + public static readonly AtomicUnit Siemens = new("Siemens", "Siemens", "S"); +} diff --git a/src/DimensionalQuantities/Units/EngineeringCatalogue.cs b/src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs similarity index 58% rename from src/DimensionalQuantities/Units/EngineeringCatalogue.cs rename to src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs index 5133e39..a6b7fbc 100644 --- a/src/DimensionalQuantities/Units/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs @@ -1,13 +1,14 @@ using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Units; -namespace DimensionalQuantities.Units; +namespace DimensionalQuantities.Build.Units; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue { private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, prefixExponent: SiPrefixes.Kilo), new(AtomicUnits.Metre, -3)); - private static readonly UnitExpression _specificPower = new(new(AtomicUnits.Watt), new(AtomicUnits.Gram, -1, SiPrefixes.Kilo)); - private static readonly UnitExpression _powerDensity = new(new(AtomicUnits.Watt), new(AtomicUnits.Metre, -3)); + private static readonly UnitExpression _specificPower = new(new(EngineeringAtomicUnits.Watt), new(AtomicUnits.Gram, -1, SiPrefixes.Kilo)); + private static readonly UnitExpression _powerDensity = new(new(EngineeringAtomicUnits.Watt), new(AtomicUnits.Metre, -3)); private static readonly UnitExpression _magneticFieldStrength = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -1)); private static readonly UnitExpression _currentDensity = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -2)); @@ -15,39 +16,39 @@ internal static class EngineeringCatalogue { { new(Length: 2), [new("Area", [new(AtomicUnits.Metre, 2)])] }, { new(Length: 3), [new("Volume", [new(AtomicUnits.Metre, 3)])] }, - { new(Time: -1), [new("Frequency", [new(AtomicUnits.Hertz)]), new("AngularVelocity", [new(AtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, + { new(Time: -1), [new("Frequency", [new(EngineeringAtomicUnits.Hertz)]), new("AngularVelocity", [new(EngineeringAtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, { new(Length: -3, Mass: 1), [new("Density", _density, [new(new(AtomicUnits.Gram), new(AtomicUnits.Metre, power: -3, prefixExponent: SiPrefixes.Centi))])] }, { new(Length: -1, Mass: 1, Time: -2), [ - new("Pressure", [new(AtomicUnits.Pascal)]), - new("Stress", [new(AtomicUnits.Pascal)]), - new("YoungsModulus", [new(AtomicUnits.Pascal)]) + new("Pressure", [new(EngineeringAtomicUnits.Pascal)]), + new("Stress", [new(EngineeringAtomicUnits.Pascal)]), + new("YoungsModulus", [new(EngineeringAtomicUnits.Pascal)]) ] }, - { new(Length: 2, Mass: 1, Time: -2), [new("Energy", [new(AtomicUnits.Joule)]), new("Torque", [new(AtomicUnits.Newton), new(AtomicUnits.Metre)])] }, - { new(Length: 2, Mass: 1, Time: -3), [new("Power", [new(AtomicUnits.Watt)])] }, + { new(Length: 2, Mass: 1, Time: -2), [new("Energy", [new(EngineeringAtomicUnits.Joule)]), new("Torque", [new(EngineeringAtomicUnits.Newton), new(AtomicUnits.Metre)])] }, + { new(Length: 2, Mass: 1, Time: -3), [new("Power", [new(EngineeringAtomicUnits.Watt)])] }, { new(Length: 2, Time: -3), [new("SpecificPower", _specificPower), new("SpecificPowerLoss", _specificPower)] }, { new(Temperature: -1), [new("TemperatureCoefficient", [new(AtomicUnits.Kelvin, -1)])] }, { new(Length: -1, Mass: 1, Time: -3), [new("PowerDensity", _powerDensity), new("VolumetricPowerLoss", _powerDensity)] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", [new(AtomicUnits.Volt)])] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", [new(AtomicUnits.Ohm)])] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", [new(AtomicUnits.Henry)])] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", [new(AtomicUnits.Weber)]), new("FluxLinkage", [new(AtomicUnits.Weber)])] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", [new(EngineeringAtomicUnits.Volt)])] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", [new(EngineeringAtomicUnits.Ohm)])] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", [new(EngineeringAtomicUnits.Henry)])] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", [new(EngineeringAtomicUnits.Weber)]), new("FluxLinkage", [new(EngineeringAtomicUnits.Weber)])] }, { new(Mass: 1, Time: -2, ElectricCurrent: -1), [ - new("MagneticFluxDensity", [new(AtomicUnits.Tesla)]), - new("MagneticPolarisation", [new(AtomicUnits.Tesla)]), - new("RemanentFluxDensity", [new(AtomicUnits.Tesla)]) + new("MagneticFluxDensity", [new(EngineeringAtomicUnits.Tesla)]), + new("MagneticPolarisation", [new(EngineeringAtomicUnits.Tesla)]), + new("RemanentFluxDensity", [new(EngineeringAtomicUnits.Tesla)]) ] }, { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", _magneticFieldStrength), new("CoerciveFieldStrength", _magneticFieldStrength)] }, { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, power: -2, prefixExponent: SiPrefixes.Milli))])] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(AtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, - { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(AtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, - { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(AtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, - { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", [new(AtomicUnits.Metre), new(AtomicUnits.Henry, -1)])] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", [new(AtomicUnits.Weber), new(AtomicUnits.Metre, -1)])] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(EngineeringAtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, + { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(EngineeringAtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, + { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(EngineeringAtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, + { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", [new(AtomicUnits.Metre), new(EngineeringAtomicUnits.Henry, -1)])] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", [new(EngineeringAtomicUnits.Weber), new(AtomicUnits.Metre, -1)])] }, }; } diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/DimensionalQuantities.Extensions.Metric.Tests.csproj b/src/DimensionalQuantities.Extensions.Metric.Tests/DimensionalQuantities.Extensions.Metric.Tests.csproj new file mode 100644 index 0000000..f365342 --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/DimensionalQuantities.Extensions.Metric.Tests.csproj @@ -0,0 +1,19 @@ + + + net11.0 + false + true + Exe + true + + + + + + + + + + + diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs new file mode 100644 index 0000000..8f16769 --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -0,0 +1,80 @@ +using DimensionalQuantities.Extensions.Metric; +using Xunit; + +namespace DimensionalQuantities.Extensions.Metric.Tests; + +public sealed class MetricConversionsTests +{ + [Fact] + public void CatalogueFactoriesAndAccessorsCoverEngineeringDimensions() + { + double[] result = + [ + Area.FromSquareMetres(1).AsSquareMetres(), + Volume.FromCubicMetres(1).AsCubicMetres(), + Frequency.FromHertz(1).AsHertz(), + AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond(), + Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre(), + Pressure.FromPascals(1).AsPascals(), + Energy.FromJoules(1).AsJoules(), + Torque.FromNewtonMetres(1).AsNewtonMetres(), + Power.FromWatts(1).AsWatts(), + SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram(), + TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(), + PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(), + Voltage.FromVolts(1).AsVolts(), + Resistance.FromOhms(1).AsOhms(), + Inductance.FromHenries(1).AsHenries(), + MagneticFlux.FromWebers(1).AsWebers(), + MagneticFluxDensity.FromTeslas(1).AsTeslas(), + MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre(), + CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre(), + Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre(), + Resistivity.FromOhmMetres(1).AsOhmMetres(), + Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre(), + MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry(), + MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(), + ]; + + Assert.Equal(Enumerable.Repeat(1d, 24), result); + } + + [Fact] + public void AliasesShareNamedConversionsWithoutDuplicateHelpers() + { + Pressure pressure = Pressure.FromPascals(1); + Stress stress = Stress.FromPascals(1); + YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); + double[] result = [pressure.AsPascals(), stress.AsPascals(), modulus.AsKiloPascals()]; + Assert.Equal([1d, 1d, 1d], result); + } + + [Fact] + public void NamedConversionsApplyMetricScale() + { + Assert.Multiple( + () => Assert.Equal(0.0025, Voltage.FromSiBaseUnit(2.5).AsKiloVolts()), + () => Assert.Equal(1000, Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()), + () => Assert.Equal(0.002, Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()), + () => Assert.Equal(1000000, CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()), + () => Assert.Equal(1000, SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()), + () => Assert.Equal(1000, TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()), + () => Assert.Equal(2, Torque.FromPrefixedUnits(2).AsSiBaseUnit()), + () => Assert.Equal(2, Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)), + () => Assert.Equal(1, CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)), + () => Assert.Equal(2, Torque.FromSiBaseUnit(2).AsPrefixedUnits())); + } + + [Fact] + public void PrefixesOnlyApplyToSupportedFactorPlacements() + { + string[] names = typeof(TemperatureCoefficientExtensions).Assembly.GetExportedTypes() + .Where(type => type.Namespace == "DimensionalQuantities.Extensions.Metric") + .SelectMany(type => type.GetMethods()).Select(method => method.Name).ToArray(); + Assert.Multiple( + () => Assert.DoesNotContain("FromInverseMilliKelvin", names), + () => Assert.DoesNotContain("AsInverseMilliKelvin", names), + () => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names), + () => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names)); + } +} diff --git a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj new file mode 100644 index 0000000..265f5ea --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj @@ -0,0 +1,38 @@ + + + netstandard2.0 + GoSim + MIT + README.md + https://github.com/GoSimulate/DimensionalQuantities + Named metric conversions for dimensional quantities. + git + $(DotNetHostPath) + dotnet + + + + + + + + + + $(IntermediateOutputPath)MetricConversions.g.cs + + + + + + + + + <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)" + Condition="'%(_ProjectReferencesWithVersions.Filename)' == 'DimensionalQuantities'"> + [$(PackageVersion)] + + + + diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index b3b1cee..a684491 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -1,6 +1,5 @@ using System.Reflection; -using Microsoft.Build.Evaluation; -using Microsoft.Build.Execution; +using DimensionalQuantities.IntegrationTests.Packaging; using Microsoft.Build.Locator; using Microsoft.Build.Utilities.ProjectCreation; using Xunit; @@ -15,134 +14,153 @@ public sealed class PackagedGeneratorTests : IDisposable static PackagedGeneratorTests() { // MSBuild must be located before any Microsoft.Build type is loaded. - MSBuildLocator.RegisterDefaults(); + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } } [Fact] - public void PackagedGeneratorConvertsVoltageInConsumer() + public void CorePackageProvidesBaseUnitsCatalogueAliasesAndArithmetic() { - string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); - FileInfo nupkg = PackLibrary(sourceProject, _scratch); using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); - repository.Package(nupkg, out Package package); + Package core = PackCore(repository); + ProjectCreator consumer = CreateProject("CoreConsumer", """ + public static class Consumer + { + public static double ConvertLength() => Length.FromMetres(2.5).AsMetres(); + public static double ConvertCurrent() => Current.FromAmps(3).AsAmps(); + public static double CalculateArea() => (Length.FromMetres(2) * Length.FromMetres(3)).AsSiBaseUnit(); + public static double NamedArea() => Area.FromSiBaseUnit(6).AsSiBaseUnit(); + } + """, core); + Build(consumer); + + using PackageAssemblyContext context = new(OutputDirectory("CoreConsumer")); + Type type = context.LoadFromAssemblyPath(Path.Combine(OutputDirectory("CoreConsumer"), "CoreConsumer.dll")) + .GetType("Consumer", throwOnError: true)!; + Assert.Multiple( + () => Assert.Equal(2.5, InvokeDouble(type, "ConvertLength")), + () => Assert.Equal(3, InvokeDouble(type, "ConvertCurrent")), + () => Assert.Equal(6, InvokeDouble(type, "CalculateArea")), + () => Assert.Equal(6, InvokeDouble(type, "NamedArea"))); + } - string consumerDirectory = Path.Combine(_scratch, "consumer"); - Directory.CreateDirectory(consumerDirectory); - string consumerProject = Path.Combine(consumerDirectory, "Consumer.csproj"); - File.WriteAllText(Path.Combine(consumerDirectory, "Consumer.cs"), """ - namespace ConsumerProject; + [Fact] + public void MetricPackageProvidesNamedConversionsAndArithmeticThroughCoreDependency() + { + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); + PackCore(repository); + Package metric = PackMetric(repository); + ProjectCreator consumer = CreateProject("MetricConsumer", """ + using DimensionalQuantities.Extensions.Metric; public static class Consumer { - public static double ConvertVoltage(double volts) - { - Voltage voltage = Voltage.FromVolts(volts); - return voltage.AsVolts(); - } - + public static double ConvertVoltage(double volts) => Voltage.FromVolts(volts).AsVolts(); public static double MultiplyVoltageCurrent(double volts, double amps) { - Voltage voltage = Voltage.FromVolts(volts); - Current current = Current.FromAmps(amps); - Power power = voltage * current; + Power power = Voltage.FromVolts(volts) * Current.FromAmps(amps); return power.AsWatts(); } } - """); - ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "netstandard2.0") - .Property("LangVersion", "preview") - .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") - .ItemPackageReference(package) - .Save(); + """, metric); + Build(consumer); - creator.TryBuild(restore: true, out bool built, out BuildOutput output); + using PackageAssemblyContext context = new(OutputDirectory("MetricConsumer")); + Type type = context.LoadFromAssemblyPath(Path.Combine(OutputDirectory("MetricConsumer"), "MetricConsumer.dll")) + .GetType("Consumer", throwOnError: true)!; + Assert.Multiple( + () => Assert.Equal(12.5, InvokeDouble(type, "ConvertVoltage", 12.5)), + () => Assert.Equal(25, InvokeDouble(type, "MultiplyVoltageCurrent", 12.5, 2.0))); + } + + [Fact] + public void MetricNamedConversionsRequireNamespaceImport() + { + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); + PackCore(repository); + Package metric = PackMetric(repository); + ProjectCreator consumer = CreateProject("MissingMetricImport", """ + public static class Consumer + { + public static double Convert() => Voltage.FromVolts(12.5).AsVolts(); + } + """, metric); + consumer.TryBuild(restore: true, out bool built, out BuildOutput output); using (output) { - Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"); + string log = output.GetConsoleLog(); + Assert.Multiple( + () => Assert.False(built, log), + () => Assert.Contains(output.ErrorEvents, error => error.Code == "CS0117" && error.Message!.Contains("FromVolts", StringComparison.Ordinal)), + () => Assert.DoesNotContain(output.ErrorEvents, error => error.Code is "CS8032" or "CS8785" or "AD0001"), + () => Assert.DoesNotContain(output.WarningEvents, warning => warning.Code is "CS8032" or "CS8785" or "AD0001")); } - - string assemblyFile = Path.Combine(consumerDirectory, "bin", "Debug", "netstandard2.0", "Consumer.dll"); - Assembly assembly = Assembly.LoadFrom(assemblyFile); - Type consumerType = assembly.GetType("ConsumerProject.Consumer") ?? throw new InvalidOperationException("Consumer type was not built."); - MethodInfo convertVoltage = consumerType.GetMethod("ConvertVoltage") ?? throw new InvalidOperationException("Consumer Voltage conversion was not built."); - MethodInfo multiplyVoltageCurrent = consumerType.GetMethod("MultiplyVoltageCurrent") ?? throw new InvalidOperationException("Consumer arithmetic was not built."); - double result = Assert.IsType(convertVoltage.Invoke(null, [12.5])); - double product = Assert.IsType(multiplyVoltageCurrent.Invoke(null, [12.5, 2.0])); - Assert.Multiple( - () => Assert.Equal(12.5, result), - () => Assert.Equal(25.0, product)); } [Fact] public void IndependentPackagesShareQuantitiesAndConsumerCanExtendArithmetic() { - string sourceProject = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "DimensionalQuantities", "DimensionalQuantities.csproj")); - FileInfo nupkg = PackLibrary(sourceProject, _scratch); using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); - repository.Package(nupkg, out Package quantities); + Package core = PackCore(repository); + Package metric = PackMetric(repository); ProjectCreator projectA = CreateProject("ProjectA", """ namespace PackageA; public static class Producer { - public static LengthCurrent Create() => Length.FromMetres(2) * Current.FromAmps(3); - public static System.Collections.Generic.List Values() => new() { Create(), Create() }; + public static Voltage Create() => Voltage.FromSiBaseUnit(6); + public static LengthCurrent CreateIntermediate() => Length.FromMetres(2) * Current.FromAmps(3); + public static System.Collections.Generic.List Values() => new() { Create(), Create() }; public static Measurement Measure() => new(); } public sealed class Measurement { - public LengthCurrent Value => Producer.Create(); + public Voltage Value => Producer.Create(); } - """, quantities); - projectA.TryBuild(restore: true, target: "Pack", out bool builtA, out BuildOutput outputA); - using (outputA) - { - Assert.True(builtA, outputA.GetConsoleLog()); - } - repository.Package(new FileInfo(Path.Combine(_scratch, "ProjectA", "packages", "ProjectA.1.0.0.nupkg")), out Package packageA); + """, core); + Pack(projectA, "ProjectA", repository, out Package packageA); ProjectCreator projectB = CreateProject("ProjectB", """ + using DimensionalQuantities.Extensions.Metric; namespace PackageB; public static class Consumer { - public static LengthCurrent Create() => Length.FromMetres(5) * Current.FromAmps(2); - public static double Read(LengthCurrent value) => value.AsSiBaseUnit(); - public static double Sum(System.Collections.Generic.IEnumerable values) + public static Voltage Create() => Voltage.FromVolts(10); + public static LengthCurrent CreateIntermediate() => Length.FromMetres(5) * Current.FromAmps(2); + public static double ReadIntermediate(LengthCurrent value) => value.AsSiBaseUnit(); + public static double Read(Voltage value) => value.AsVolts(); + public static double Sum(System.Collections.Generic.IEnumerable values) { double total = 0; - foreach (LengthCurrent value in values) + foreach (Voltage value in values) { - total += value.AsSiBaseUnit(); + total += value.AsVolts(); } return total; } } - """, quantities); - projectB.TryBuild(restore: true, target: "Pack", out bool builtB, out BuildOutput outputB); - using (outputB) - { - Assert.True(builtB, outputB.GetConsoleLog()); - } - repository.Package(new FileInfo(Path.Combine(_scratch, "ProjectB", "packages", "ProjectB.1.0.0.nupkg")), out Package packageB); + """, metric); + Pack(projectB, "ProjectB", repository, out Package packageB); ProjectCreator projectC = CreateProject("ProjectC", """ + using DimensionalQuantities.Extensions.Metric; using PackageA; public static class Application { public static double Direct() => PackageB.Consumer.Read(Producer.Create()); public static double Collection() => PackageB.Consumer.Sum(Producer.Values()); public static double Contained() => PackageB.Consumer.Read(Producer.Measure().Value); - public static double Prefixed() => Producer.Create().AsPrefixedUnits(3, 0); - public static double Product() => (Producer.Create() * Time.FromSeconds(4)).AsSiBaseUnit(); + public static double Prefixed() => Producer.Create().AsPrefixedUnits(3); + public static double Product() => (Producer.Create() * Current.FromAmps(4)).AsWatts(); + public static double Intermediate() => PackageB.Consumer.ReadIntermediate(Producer.CreateIntermediate()); + public static double IntermediateProduct() => (Producer.CreateIntermediate() * Time.FromSeconds(4)).AsSiBaseUnit(); } - """, quantities, packageA, packageB); - projectC.TryBuild(restore: true, out bool builtC, out BuildOutput outputC); - using (outputC) - { - Assert.True(builtC, outputC.GetConsoleLog()); - } + """, metric, packageA, packageB); + Build(projectC); - string outputDirectory = Path.Combine(_scratch, "ProjectC", "bin", "Debug", "netstandard2.0"); + string outputDirectory = OutputDirectory("ProjectC"); using PackageAssemblyContext context = new(outputDirectory); Assembly assemblyA = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectA.dll")); Assembly assemblyB = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectB.dll")); @@ -153,22 +171,65 @@ public static class Application Type producedType = producer.GetMethod("Create")!.ReturnType; Type independentlyProducedType = consumer.GetMethod("Create")!.ReturnType; Type acceptedType = consumer.GetMethod("Read")!.GetParameters().Single().ParameterType; - double direct = (double)application.GetMethod("Direct")!.Invoke(null, null)!; - double collection = (double)application.GetMethod("Collection")!.Invoke(null, null)!; - double contained = (double)application.GetMethod("Contained")!.Invoke(null, null)!; - double prefixed = (double)application.GetMethod("Prefixed")!.Invoke(null, null)!; - double product = (double)application.GetMethod("Product")!.Invoke(null, null)!; + Type intermediateFromA = producer.GetMethod("CreateIntermediate")!.ReturnType; + Type intermediateFromB = consumer.GetMethod("CreateIntermediate")!.ReturnType; + Type intermediateAccepted = consumer.GetMethod("ReadIntermediate")!.GetParameters().Single().ParameterType; Assert.Multiple( () => Assert.Equal(producedType, independentlyProducedType), () => Assert.Equal(producedType, acceptedType), - () => Assert.Equal(6, direct), - () => Assert.Equal(12, collection), - () => Assert.Equal(6, contained), - () => Assert.Equal(0.006, prefixed, 12), - () => Assert.Equal(24, product)); + () => Assert.Equal(intermediateFromA, intermediateFromB), + () => Assert.Equal(intermediateFromA, intermediateAccepted), + () => Assert.Equal(6, InvokeDouble(application, "Direct")), + () => Assert.Equal(12, InvokeDouble(application, "Collection")), + () => Assert.Equal(6, InvokeDouble(application, "Contained")), + () => Assert.Equal(0.006, InvokeDouble(application, "Prefixed"), 12), + () => Assert.Equal(24, InvokeDouble(application, "Product")), + () => Assert.Equal(6, InvokeDouble(application, "Intermediate")), + () => Assert.Equal(24, InvokeDouble(application, "IntermediateProduct"))); + } + + private Package PackCore(PackageRepository repository) + { + return PackSourceProject(repository, "DimensionalQuantities"); + } + + private Package PackMetric(PackageRepository repository) + { + return PackSourceProject(repository, "DimensionalQuantities.Extensions.Metric"); + } + + private Package PackSourceProject(PackageRepository repository, string projectName) + { + string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + FileInfo packageFile = PackageTestSupport.PackLibrary(Path.Combine(sourceDirectory, projectName, projectName + ".csproj"), _scratch); + return PackageTestSupport.AddPackedPackage(repository, packageFile); + } + + private static void Build(ProjectCreator project) + { + project.TryBuild(restore: true, out bool built, out BuildOutput output); + using (output) + { + Assert.True(built, output.GetConsoleLog()); + } } + private void Pack(ProjectCreator project, string name, PackageRepository repository, out Package package) + { + project.TryBuild(restore: true, target: "Pack", out bool built, out BuildOutput output); + using (output) + { + Assert.True(built, output.GetConsoleLog()); + } + package = PackageTestSupport.AddPackedPackage(repository, new FileInfo(Path.Combine(_scratch, name, "packages", $"{name}.1.0.0.nupkg"))); + } + + private string OutputDirectory(string project) => Path.Combine(_scratch, project, "bin", "Debug", "netstandard2.0"); + + private static double InvokeDouble(Type type, string method, params object[] arguments) + => Assert.IsType(type.GetMethod(method)!.Invoke(null, arguments)); + private ProjectCreator CreateProject(string name, string source, params Package[] packages) { string directory = Path.Combine(_scratch, name); @@ -210,34 +271,4 @@ public void Dispose() Directory.Delete(_scratch, recursive: true); } - private static FileInfo PackLibrary(string sourceProject, string scratch) - { - string packageOutput = Path.Combine(scratch, "package"); - Directory.CreateDirectory(packageOutput); - Dictionary properties = new() - { - ["Configuration"] = typeof(PackagedGeneratorTests).Assembly.GetCustomAttribute()!.Configuration, - ["NoBuild"] = "true", - ["PackageOutputPath"] = packageOutput, - ["NuGetAudit"] = "false", - }; - using ProjectCollection collection = new(properties); - Project project = collection.LoadProject(sourceProject); - string packageId = project.GetPropertyValue("PackageId"); - string packageVersion = project.GetPropertyValue("PackageVersion"); - using BuildOutput output = BuildOutput.Create(); - BuildParameters parameters = new(collection) { Loggers = [output] }; - BuildRequestData request = new(project.CreateProjectInstance(), ["Pack"]); - BuildResult result = BuildManager.DefaultBuildManager.Build(parameters, request); - if (result.OverallResult != BuildResultCode.Success) - { - throw new InvalidOperationException($"Pack failed.{Environment.NewLine}{output.GetConsoleLog()}"); - } - FileInfo nupkg = new(Path.Combine(packageOutput, $"{packageId}.{packageVersion}.nupkg")); - if (!nupkg.Exists) - { - throw new FileNotFoundException("Pack did not create the expected package", nupkg.FullName); - } - return nupkg; - } } diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/PackageTestSupport.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/PackageTestSupport.cs new file mode 100644 index 0000000..5762478 --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/PackageTestSupport.cs @@ -0,0 +1,53 @@ +using System.IO.Compression; +using System.Reflection; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Utilities.ProjectCreation; + +namespace DimensionalQuantities.IntegrationTests.Packaging; + +internal static class PackageTestSupport +{ + public static Package AddPackedPackage(PackageRepository repository, FileInfo archive) + { + repository.Package(archive, out Package package); + // ProjectCreation rewrites the nuspec without dependency groups; restore the packed manifest for transitive consumers. + using ZipArchive zip = ZipFile.OpenRead(archive.FullName); + ZipArchiveEntry manifest = zip.Entries.Single(entry => entry.Name.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + using Stream source = manifest.Open(); + using Stream destination = File.Create(repository.GetManifestFilePath(package.Id, package.Version)); + source.CopyTo(destination); + return package; + } + + public static FileInfo PackLibrary(string sourceProject, string scratch) + { + string packageOutput = Path.Combine(scratch, "package"); + Directory.CreateDirectory(packageOutput); + Dictionary properties = new() + { + ["Configuration"] = typeof(PackageTestSupport).Assembly.GetCustomAttribute()!.Configuration, + ["NoBuild"] = "true", + ["PackageOutputPath"] = packageOutput, + ["NuGetAudit"] = "false", + }; + using ProjectCollection collection = new(properties); + Project project = collection.LoadProject(sourceProject); + string packageId = project.GetPropertyValue("PackageId"); + string packageVersion = project.GetPropertyValue("PackageVersion"); + using BuildOutput output = BuildOutput.Create(); + BuildParameters parameters = new(collection) { Loggers = [output] }; + BuildRequestData request = new(project.CreateProjectInstance(), ["Pack"]); + BuildResult result = BuildManager.DefaultBuildManager.Build(parameters, request); + if (result.OverallResult != BuildResultCode.Success) + { + throw new InvalidOperationException($"Pack failed.{Environment.NewLine}{output.GetConsoleLog()}"); + } + FileInfo nupkg = new(Path.Combine(packageOutput, $"{packageId}.{packageVersion}.nupkg")); + if (!nupkg.Exists) + { + throw new FileNotFoundException("Pack did not create the expected package", nupkg.FullName); + } + return nupkg; + } +} diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs new file mode 100644 index 0000000..3a1921d --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs @@ -0,0 +1,166 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Reflection; +using System.IO.Compression; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text; +using System.Text.Json; +using Microsoft.Build.Locator; +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.Packaging; + +[Collection("MSBuild")] +public sealed class SymbolPackageTests : IDisposable +{ + private static readonly Guid _sourceLinkKind = new("CC110556-A091-4D38-9FEC-25AB9A351A6A"); + private static readonly Guid _embeddedSourceKind = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE"); + private readonly string _scratch = Directory.CreateTempSubdirectory("dimensional-quantities-symbols-").FullName; + + static SymbolPackageTests() + { + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + } + + [Fact] + public void CoreSymbolPackageMatchesRuntimeAndAnalyzerAssemblies() + { + (FileInfo package, FileInfo symbols) = Pack("DimensionalQuantities"); + using ZipArchive binaries = ZipFile.OpenRead(package.FullName); + using ZipArchive symbolArchive = ZipFile.OpenRead(symbols.FullName); + string commit = GetCurrentCommit(); + foreach (string stem in new[] + { + "lib/netstandard2.0/DimensionalQuantities", + "analyzers/dotnet/cs/DimensionalQuantities", + "analyzers/dotnet/cs/DimensionalQuantities.Roslyn", + "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.CodeFixes", + }) + { + SymbolDetails details = ReadSymbolDetails(binaries, symbolArchive, stem); + Assert.Multiple( + () => Assert.Equal(details.AssemblyGuid, details.PdbGuid), + () => Assert.Equal(details.AssemblyStamp, details.PdbStamp), + () => Assert.Contains($"https://raw.githubusercontent.com/GoSimulate/DimensionalQuantities/{commit}/*", details.SourceUrls)); + } + } + + [Fact] + public void MetricSymbolPackageMatchesAssemblyAndEmbedsGeneratedSource() + { + (FileInfo package, FileInfo symbols) = Pack("DimensionalQuantities.Extensions.Metric"); + using ZipArchive binaries = ZipFile.OpenRead(package.FullName); + using ZipArchive symbolArchive = ZipFile.OpenRead(symbols.FullName); + SymbolDetails details = ReadSymbolDetails(binaries, symbolArchive, "lib/netstandard2.0/DimensionalQuantities.Extensions.Metric"); + string commit = GetCurrentCommit(); + string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + string configuration = typeof(SymbolPackageTests).Assembly.GetCustomAttribute()!.Configuration; + byte[] generatedSource = File.ReadAllBytes(Path.Combine(sourceDirectory, "DimensionalQuantities.Extensions.Metric", "obj", configuration, "netstandard2.0", "MetricConversions.g.cs")); + Assert.Multiple( + () => Assert.Equal(details.AssemblyGuid, details.PdbGuid), + () => Assert.Equal(details.AssemblyStamp, details.PdbStamp), + () => Assert.Contains($"https://raw.githubusercontent.com/GoSimulate/DimensionalQuantities/{commit}/*", details.SourceUrls), + () => Assert.Equal(generatedSource, details.EmbeddedMetricConversions)); + } + + private (FileInfo Package, FileInfo Symbols) Pack(string projectName) + { + string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + FileInfo package = PackageTestSupport.PackLibrary(Path.Combine(sourceDirectory, projectName, projectName + ".csproj"), _scratch); + FileInfo symbols = new(Path.ChangeExtension(package.FullName, ".snupkg")); + if (!symbols.Exists) + { + throw new FileNotFoundException("Pack did not create the expected symbol package.", symbols.FullName); + } + return (package, symbols); + } + + private static SymbolDetails ReadSymbolDetails(ZipArchive binaries, ZipArchive symbols, string stem) + { + byte[] assemblyBytes = ReadEntry(binaries, stem + ".dll"); + byte[] pdbBytes = ReadEntry(symbols, stem + ".pdb"); + using PEReader assembly = new(new MemoryStream(assemblyBytes)); + using MetadataReaderProvider pdb = MetadataReaderProvider.FromPortablePdbStream(new MemoryStream(pdbBytes)); + DebugDirectoryEntry codeView = Assert.Single(assembly.ReadDebugDirectory().Where(entry => entry.Type == DebugDirectoryEntryType.CodeView)); + CodeViewDebugDirectoryData codeViewData = assembly.ReadCodeViewDebugDirectoryData(codeView); + MetadataReader reader = pdb.GetMetadataReader(); + byte[] pdbId = reader.DebugMetadataHeader!.Id.ToArray(); + string sourceLink = reader.CustomDebugInformation + .Select(reader.GetCustomDebugInformation) + .Where(info => reader.GetGuid(info.Kind) == _sourceLinkKind) + .Select(info => Encoding.UTF8.GetString(reader.GetBlobBytes(info.Value))) + .Single(); + using JsonDocument sourceLinkDocument = JsonDocument.Parse(sourceLink); + string[] sourceUrls = sourceLinkDocument.RootElement.GetProperty("documents").EnumerateObject() + .Select(mapping => mapping.Value.GetString()!).ToArray(); + byte[]? embeddedMetricConversions = reader.Documents + .Where(handle => reader.GetString(reader.GetDocument(handle).Name).EndsWith("MetricConversions.g.cs", StringComparison.Ordinal)) + .SelectMany(handle => reader.GetCustomDebugInformation(handle)) + .Select(reader.GetCustomDebugInformation) + .Where(info => reader.GetGuid(info.Kind) == _embeddedSourceKind) + .Select(info => DecodeEmbeddedSource(reader.GetBlobBytes(info.Value))) + .SingleOrDefault(); + return new(codeViewData.Guid, codeView.Stamp, new Guid(pdbId.AsSpan(0, 16)), BitConverter.ToUInt32(pdbId, 16), sourceUrls, embeddedMetricConversions); + } + + private static byte[] DecodeEmbeddedSource(byte[] blob) + { + if (blob.Length < sizeof(int)) + { + throw new InvalidDataException("Embedded source blob is too short."); + } + int uncompressedSize = BinaryPrimitives.ReadInt32LittleEndian(blob); + if (uncompressedSize == 0) + { + return blob[sizeof(int)..]; + } + if (uncompressedSize < 0) + { + throw new InvalidDataException("Embedded source has an invalid uncompressed size."); + } + using MemoryStream compressed = new(blob, sizeof(int), blob.Length - sizeof(int)); + using DeflateStream deflate = new(compressed, CompressionMode.Decompress); + using MemoryStream source = new(); + deflate.CopyTo(source); + byte[] bytes = source.ToArray(); + if (bytes.Length != uncompressedSize) + { + throw new InvalidDataException("Embedded source length does not match its header."); + } + return bytes; + } + + private static byte[] ReadEntry(ZipArchive archive, string path) + { + ZipArchiveEntry entry = archive.GetEntry(path) ?? throw new InvalidOperationException($"Package does not contain {path}."); + using Stream stream = entry.Open(); + using MemoryStream bytes = new(); + stream.CopyTo(bytes); + return bytes.ToArray(); + } + + private static string GetCurrentCommit() + { + string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + ProcessStartInfo start = new("git") { WorkingDirectory = sourceDirectory, RedirectStandardOutput = true, RedirectStandardError = true }; + start.ArgumentList.Add("rev-parse"); + start.ArgumentList.Add("HEAD"); + using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start git."); + string commit = process.StandardOutput.ReadToEnd().Trim(); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"git rev-parse HEAD failed: {error}"); + } + return commit; + } + + private sealed record SymbolDetails(Guid AssemblyGuid, uint AssemblyStamp, Guid PdbGuid, uint PdbStamp, string[] SourceUrls, byte[]? EmbeddedMetricConversions); + + public void Dispose() => Directory.Delete(_scratch, recursive: true); +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityDiscoveryCoordinatorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityDiscoveryCoordinatorTests.cs index f520a07..db61ae3 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityDiscoveryCoordinatorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/QuantityDiscoveryCoordinatorTests.cs @@ -685,7 +685,7 @@ public static class Consumer public static {{resultType}} Calculate() { Current current = Current.FromAmps(3); - Resistance resistance = Resistance.FromOhms(4); + Resistance resistance = Resistance.FromSiBaseUnit(4); Time time = Time.FromSeconds(2); {{declarations}} return {{resultName}}; @@ -707,7 +707,7 @@ public static class Consumer public static double Value() { Current current = Current.FromAmps(3); - Resistance resistance = Resistance.FromOhms(4); + Resistance resistance = Resistance.FromSiBaseUnit(4); Time time = Time.FromSeconds(2); double first; double second; @@ -715,7 +715,7 @@ public static double Value() var intermediate = current * current; var result = intermediate * resistance; Power typedResult = result; - first = typedResult.AsWatts(); + first = typedResult.AsSiBaseUnit(); } { var intermediate = time * time; diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs index 9bb3903..6b7c2ff 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/ArithmeticSourceEmitterTests.cs @@ -35,9 +35,9 @@ public void ExplicitPlanEmitsUsableArithmetic() const string source = """ public static class Consumer { - public static double Left() => (Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromOhms(4)).AsSiBaseUnit(); - public static double Right() => (Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromOhms(4))).AsSiBaseUnit(); - public static double Divided() => (Voltage.FromVolts(12) / Current.FromAmps(3)).AsSiBaseUnit(); + public static double Left() => (Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromSiBaseUnit(4)).AsSiBaseUnit(); + public static double Right() => (Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromSiBaseUnit(4))).AsSiBaseUnit(); + public static double Divided() => (Voltage.FromSiBaseUnit(12) / Current.FromAmps(3)).AsSiBaseUnit(); } """; Assembly assembly = CompileAndEmit(source, plan); diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/CoreConversionBoundaryTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/CoreConversionBoundaryTests.cs new file mode 100644 index 0000000..7c633c0 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/CoreConversionBoundaryTests.cs @@ -0,0 +1,52 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class CoreConversionBoundaryTests +{ + [Theory] + [InlineData("Voltage.FromVolts(12)")] + [InlineData("Voltage.FromPrefixedUnits(12, 0)")] + [InlineData("Voltage.FromSiBaseUnit(12).AsVolts()")] + [InlineData("Voltage.FromSiBaseUnit(12).AsPrefixedUnits(0)")] + public void CatalogueConversionsRequireAnExtensionPackage(string expression) + { + Diagnostic[] result = Compile($"public class Consumer {{ public object Value() => {expression}; }}"); + + Assert.Contains(result, diagnostic => diagnostic.Id is "CS0117" or "CS1061" or "CS1929"); + } + + [Theory] + [InlineData("Length.FromMilliMetres(12).AsMetres()")] + [InlineData("Mass.FromKiloGrams(12).AsGrams()")] + [InlineData("Voltage.FromSiBaseUnit(12).AsSiBaseUnit()")] + [InlineData("Voltage.FromArbitrarySiComponents(12).AsArbitrarySiComponents()")] + [InlineData("(Length.FromMetres(2) * Current.FromAmps(3)).AsPrefixedUnits(3, 0)")] + [InlineData("Voltage.FromSiBaseUnit(12).Squared().AsSiBaseUnit()")] + public void BaseGenericAndIntermediateConversionsRemainAvailable(string expression) + { + Diagnostic[] result = Compile($"public class Consumer {{ public object Value() => {expression}; }}"); + + Assert.Empty(result); + } + + private static Diagnostic[] Compile(string source) + { + CSharpParseOptions options = new(LanguageVersion.Preview); + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation compilation = CSharpCompilation.Create("Consumer", [CSharpSyntaxTree.ParseText(source, options)], + references, new(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: options); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation result, out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + return diagnostics.Concat(result.GetDiagnostics(TestContext.Current.CancellationToken)) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs index 674b1c7..32849f3 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityGeneratorTests.cs @@ -87,12 +87,12 @@ public static class Consumer [Theory] [InlineData("Length.FromMetres(2) * Length.FromMetres(3)", "Area", 6)] [InlineData("Current.FromAmps(2) * Current.FromAmps(3)", "CurrentSquared", 6)] - [InlineData("Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromOhms(4)", "Power", 24)] - [InlineData("Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromOhms(4))", "Power", 24)] - [InlineData("Voltage.FromVolts(12) / Current.FromAmps(3)", "Resistance", 4)] + [InlineData("Current.FromAmps(2) * Current.FromAmps(3) * Resistance.FromSiBaseUnit(4)", "Power", 24)] + [InlineData("Current.FromAmps(2) * (Current.FromAmps(3) * Resistance.FromSiBaseUnit(4))", "Power", 24)] + [InlineData("Voltage.FromSiBaseUnit(12) / Current.FromAmps(3)", "Resistance", 4)] [InlineData("Current.FromAmps(3) * Time.FromSeconds(2)", "TimeCurrent", 6)] [InlineData("(Current.FromAmps(2) * Time.FromSeconds(3)) / Time.FromSeconds(3)", "Current", 2)] - [InlineData("(Voltage.FromVolts(4) * Current.FromAmps(3)) / Current.FromAmps(2)", "Voltage", 6)] + [InlineData("(Voltage.FromSiBaseUnit(4) * Current.FromAmps(3)) / Current.FromAmps(2)", "Voltage", 6)] [InlineData("Current.FromAmps(3) * 2d", "Current", 6)] [InlineData("2 * Current.FromAmps(3)", "Current", 6)] [InlineData("Current.FromAmps(6) / 2f", "Current", 3)] @@ -150,7 +150,7 @@ public void ArithmeticReadsFieldAndMethodReturnTypes() public static class Consumer { public static Current Input = Current.FromAmps(2); - public static Resistance GetResistance() => Resistance.FromOhms(4); + public static Resistance GetResistance() => Resistance.FromSiBaseUnit(4); public static Power Calculate() => Input * Input * GetResistance(); } """; @@ -189,7 +189,7 @@ public static class Added const string editedSource = """ public static class Consumer { - public static Resistance Value() => Voltage.FromVolts(12) / Current.FromAmps(3); + public static Resistance Value() => Voltage.FromSiBaseUnit(12) / Current.FromAmps(3); } """; CSharpCompilation initialInput = CreateInputCompilation(firstSource); @@ -345,7 +345,7 @@ public static double Value(Current current, Length length, Time time) [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)] + [InlineData("Energy.FromSiBaseUnit(2) + Torque.FromSiBaseUnit(3)", "AsSiBaseUnit", 5)] public void SameDimensionArithmeticUsesCoreOperatorsWithoutGeneratedOverloads(string expression, string accessor, double expected) { string source = $$""" @@ -375,12 +375,12 @@ public static double Value() { Current first = Current.FromAmps(3); Current second = Current.FromAmps(2); - Resistance resistance = Resistance.FromOhms(4); + Resistance resistance = Resistance.FromSiBaseUnit(4); var firstSquared = first * first; var secondSquared = second * second; var combined = {{expression}}; var heating = combined * resistance; - return ((Power)heating).AsWatts(); + return ((Power)heating).AsSiBaseUnit(); } } """; @@ -390,8 +390,8 @@ public static double Value() } [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) + Current.FromAmps(2) * Current.FromAmps(2)) * Resistance.FromSiBaseUnit(4)).AsSiBaseUnit()", 52)] + [InlineData("((Current.FromAmps(3) * Current.FromAmps(3) - Current.FromAmps(2) * Current.FromAmps(2)) * Resistance.FromSiBaseUnit(4)).AsSiBaseUnit()", 20)] [InlineData("(-(Current.FromAmps(3) * Current.FromAmps(3))).AsSiBaseUnit()", -9)] public void SameDimensionArithmeticAroundNestedProductsCompilesAndExecutes(string expression, double expected) { @@ -425,7 +425,7 @@ public static class Consumer [Theory] [InlineData("Length.FromMetres(3) / Length.FromMilliMetres(500)", 6)] - [InlineData("Energy.FromJoules(6) / Torque.FromNewtonMetres(2)", 3)] + [InlineData("Energy.FromSiBaseUnit(6) / Torque.FromSiBaseUnit(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)] @@ -592,9 +592,9 @@ public static double Value() { var squared = Power.Of(Current.FromAmps(3)); var root = Power.Of(squared); - Voltage voltage = root * Resistance.FromOhms(4); + Voltage voltage = root * Resistance.FromSiBaseUnit(4); Current nested = Power.Of(Power.Of(root)); - return voltage.AsVolts() + nested.AsAmps(); + return voltage.AsSiBaseUnit() + nested.AsAmps(); } } """; @@ -654,9 +654,9 @@ public static class Consumer { public static bool Value() { - Length first = Power.Of(Area.FromSquareMetres(9)); - Length second = Power.Of(Area.FromSquareMetres(9)); - Length third = Power>.Of(Area.FromSquareMetres(9)); + Length first = Power.Of(Area.FromSiBaseUnit(9)); + Length second = Power.Of(Area.FromSiBaseUnit(9)); + Length third = Power>.Of(Area.FromSiBaseUnit(9)); return first == second && second == third && typeof(NumberHalf) == typeof(NumberTwoQuarters) && typeof(NumberHalf) == typeof(Fraction); } @@ -709,12 +709,12 @@ public static double Value() } [Theory] - [InlineData("var squared = Power.Of(current); var result = squared * resistance;", "((Power)result).AsWatts()", 36)] + [InlineData("var squared = Power.Of(current); var result = squared * resistance;", "((Power)result).AsSiBaseUnit()", 36)] [InlineData("var squared = current * current; var result = Power>.Of(squared);", "((Current)result).AsAmps()", 3)] [InlineData("var squared = Power.Of(current); var copy = squared; var result = Power>.Of(copy);", "((Current)result).AsAmps()", 3)] [InlineData("var result = Power.Of(current * resistance);", "result.AsSiBaseUnit()", 144)] [InlineData("var result = Power>.Of(Power.Of(current));", "((Current)result).AsAmps()", 3)] - [InlineData("var identity = Power.Of(current); var result = identity * current * resistance;", "((Voltage)result).AsVolts()", 12)] + [InlineData("var identity = Power.Of(current); var result = identity * current * resistance;", "((Voltage)result).AsSiBaseUnit()", 12)] public void PowersWorkWithArithmeticAndLocalVariables(string declarations, string expression, double expected) { string source = $$""" @@ -724,7 +724,7 @@ public static class Consumer public static double Value() { Current current = Current.FromAmps(3); - Resistance resistance = Resistance.FromOhms(4); + Resistance resistance = Resistance.FromSiBaseUnit(4); {{declarations}} return {{expression}}; } @@ -852,8 +852,8 @@ public static double Value() Length fourthRoot = fourthPower.Root4(); Area fromGeneric = Power.Of(root); Area fromProduct = root * cubeRoot; - return area.AsSquareMetres() + volume.AsCubicMetres() + fourthRoot.AsMetres() - + fromGeneric.AsSquareMetres() + fromProduct.AsSquareMetres(); + return area.AsSiBaseUnit() + volume.AsSiBaseUnit() + fourthRoot.AsMetres() + + fromGeneric.AsSiBaseUnit() + fromProduct.AsSiBaseUnit(); } } """; @@ -876,9 +876,9 @@ public static class Consumer public static double Value() { Current current = Current.FromAmps(3); - Resistance resistance = Resistance.FromOhms(4); + Resistance resistance = Resistance.FromSiBaseUnit(4); Voltage voltage = {{expression}}; - return voltage.AsVolts(); + return voltage.AsSiBaseUnit(); } } """; @@ -1138,10 +1138,10 @@ private static Resistance Recover(Power loss, Current line, bool delta) public static double[] Value() { Current line = Current.FromAmps(12); - Resistance resistance = Resistance.FromOhms(0.5); + Resistance resistance = Resistance.FromSiBaseUnit(0.5); Power loss = Loss(resistance, line, {{delta.ToString().ToLowerInvariant()}}); Resistance recovered = Recover(loss, line, {{delta.ToString().ToLowerInvariant()}}); - return [loss.AsWatts(), recovered.AsOhms()]; + return [loss.AsSiBaseUnit(), recovered.AsSiBaseUnit()]; } } """; diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs index 74c3f0b..68d02f5 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs @@ -60,43 +60,6 @@ public void VoltageAndMechanicalAliasAreTheSameType() Assert.True(result); } - [Fact] - public void CatalogueUnitsHaveUnprefixedFactoriesAndAccessors() - { - const string source = """ - public static class Consumer - { - public static double UseUnits() => - Area.FromSquareMetres(1).AsSquareMetres() + - Volume.FromCubicMetres(1).AsCubicMetres() + - Frequency.FromHertz(1).AsHertz() + - AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond() + - Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre() + - Pressure.FromPascals(1).AsPascals() + - Energy.FromJoules(1).AsJoules() + - Torque.FromNewtonMetres(1).AsNewtonMetres() + - Power.FromWatts(1).AsWatts() + - SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram() + - TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin() + - PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre() + - Voltage.FromVolts(1).AsVolts() + - Resistance.FromOhms(1).AsOhms() + - Inductance.FromHenries(1).AsHenries() + - MagneticFlux.FromWebers(1).AsWebers() + - MagneticFluxDensity.FromTeslas(1).AsTeslas() + - MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre() + - CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre() + - Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre() + - Resistivity.FromOhmMetres(1).AsOhmMetres() + - Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre() + - MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry() + - MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(); - } - """; - - Compile(source); - } - [Theory] [InlineData("Area", "LengthSquared")] [InlineData("CurrentDensity", "CurrentPerLengthSquared")] @@ -109,40 +72,6 @@ public void CatalogueAliasesShareTheCanonicalDimensionType(string alias, string Assert.True(result); } - [Fact] - public void SharedUnitsAreAvailableThroughEveryAliasWithoutDuplicateHelpers() - { - const string source = """ - public static class Consumer - { - public static double UsePressureAliases() - { - Pressure pressure = Pressure.FromPascals(1); - Stress stress = Stress.FromPascals(1); - YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); - return pressure.AsPascals() + stress.AsPascals() + modulus.AsKiloPascals(); - } - } - """; - - Compile(source); - } - - [Fact] - public void PrefixesAreOnlyGeneratedForSupportedFactorPlacements() - { - const string source = "public static class Consumer { public static double Value() => TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(); }"; - CSharpCompilation result = Compile(source); - INamespaceSymbol generated = result.GetTypeByMetadataName("DimensionalQuantities.Generated.LengthExtensions")!.ContainingNamespace; - string[] names = generated.GetTypeMembers().SelectMany(type => type.GetMembers().OfType()).Select(method => method.Name).ToArray(); - - Assert.Multiple( - () => Assert.DoesNotContain("FromInverseMilliKelvin", names), - () => Assert.DoesNotContain("AsInverseMilliKelvin", names), - () => Assert.DoesNotContain("FromMilliAmpsPerSquareMilliMetre", names), - () => Assert.DoesNotContain("FromKiloGramsPerCubicCentiMetre", names)); - } - [Fact] public void EverySiPrefixHasUsableFactoryAndAccessor() { @@ -161,39 +90,13 @@ public static void UsePrefixes() Compile(source); } - [Theory] - [InlineData("Length.FromMilliMetres(2500).AsSiBaseUnit()", 2.5)] - [InlineData("Voltage.FromSiBaseUnit(2.5).AsKiloVolts()", 0.0025)] - [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] - [InlineData("Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()", 1000)] - public void GeneratedConversionsApplyRepresentativeUnitScales(string expression, double expected) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()", 0.002)] - [InlineData("CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()", 1000000)] - [InlineData("SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()", 1000)] - [InlineData("TemperatureCoefficient.FromPrefixedUnits(1, kelvinPrefixExponent: -3).AsSiBaseUnit()", 1000)] - [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] - [InlineData("Torque.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] - public void PrefixedUnitFactoriesUseCanonicalUnitFactors(string expression, double expected) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("Voltage.FromSiBaseUnit(0.002).AsPrefixedUnits(-3)", 2)] - [InlineData("CurrentDensity.FromSiBaseUnit(1000000).AsPrefixedUnits(ampPrefixExponent: 0, metrePrefixExponent: -3)", 1)] - [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] - [InlineData("Torque.FromSiBaseUnit(2).AsPrefixedUnits()", 2)] - public void PrefixedUnitAccessorsUseCanonicalUnitFactors(string expression, double expected) + [Fact] + public void BaseUnitPrefixesKeepTheirSiScales() { - double result = Evaluate(expression); - Assert.Equal(expected, result); + Assert.Equal(2.5, Evaluate("Length.FromMilliMetres(2500).AsSiBaseUnit()")); + Assert.Equal(2.5, Evaluate("Mass.FromKiloGrams(2.5).AsSiBaseUnit()")); + Assert.Equal(2, Evaluate("Mass.FromPrefixedUnits(2).AsSiBaseUnit()")); + Assert.Equal(1000, Evaluate("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)")); } private static T Evaluate(string expression) diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs index 009190d..49dccee 100644 --- a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs @@ -7,7 +7,7 @@ namespace DimensionalQuantities.Roslyn.Analyzers; internal static class DimensionNames { public static string Get(Dimension dimension) => - EngineeringCatalogue.Entries.TryGetValue(dimension, out UnitDefinition[]? units) - ? units[0].QuantityName + EngineeringQuantityAliases.Entries.TryGetValue(dimension, out string[]? aliases) + ? aliases[0] : dimension == default ? "Dimensionless" : DimensionNameGenerator.GetQuantityName(dimension); } diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index cfdb6db..3cfea89 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -12,5 +12,6 @@ + diff --git a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs index 76e7a30..2bceb4a 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs @@ -5,10 +5,14 @@ namespace DimensionalQuantities.Roslyn.Discovery; -internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray Units, bool IncludeNamedConversions) +internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray Names, ImmutableArray Units, + bool IncludeNamedConversions) { - public string Name => Units[0].QuantityName; + public PlannedQuantity(Dimension dimension, ImmutableArray units, bool includeNamedConversions) + : this(dimension, [.. units.Select(unit => unit.QuantityName)], units, includeNamedConversions) { } + + public string Name => Names[0]; public ImmutableArray Aliases => - [.. Units.Select(unit => unit.QuantityName).Append(DimensionNameGenerator.GetQuantityName(Dimension)).Distinct(StringComparer.Ordinal)]; + [.. Names.Append(DimensionNameGenerator.GetQuantityName(Dimension)).Distinct(StringComparer.Ordinal)]; } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs index 674fd8b..5b987db 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityDiscovery.cs @@ -61,9 +61,9 @@ private static QuantityGenerationPlan Discover(IReadOnlyList pr dimensions.Add(dimension); } - foreach (KeyValuePair entry in EngineeringCatalogue.Entries) + foreach (KeyValuePair entry in EngineeringQuantityAliases.Entries) { - knownQuantities.Add(new(entry.Key, [.. entry.Value], true)); + knownQuantities.Add(new(entry.Key, [.. entry.Value], [], false)); dimensions.Add(entry.Key); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index 653129c..293e286 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -1,10 +1,4 @@ -using System.Globalization; -using DimensionalQuantities.Dimensions; -using DimensionalQuantities.Naming; -using DimensionalQuantities.Numerics; using DimensionalQuantities.Roslyn.Discovery; -using DimensionalQuantities.TypeSystem; -using DimensionalQuantities.Units; namespace DimensionalQuantities.Roslyn.Generators; @@ -12,155 +6,18 @@ internal static class QuantitySourceEmitter { public static string Emit(IReadOnlyList quantities) { - int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); - QuantityDefinition[] definitions = quantities.Select(quantity => DefineQuantity(quantity, prefixes)).ToArray(); - string aliases = string.Join("\n", definitions.SelectMany(definition => definition.Aliases.Select(alias => $"global using {alias} = {definition.TypeName};"))); - string extensions = string.Join("\n\n", definitions.Select(RenderQuantity)); - + string aliases = string.Join("\n", quantities.SelectMany(quantity => + quantity.Aliases.Select(alias => $"global using {alias} = {QuantityTypeNameFormatter.Format(quantity.Dimension)};"))); + UnitConversionSpec[] conversions = quantities.Where(quantity => !quantity.Units.IsEmpty) + .Select(quantity => new UnitConversionSpec(quantity.Name, quantity.Name, quantity.Units, + quantity.IncludeNamedConversions)).ToArray(); + string extensions = UnitConversionEmitter.Emit(conversions, "DimensionalQuantities.Generated", "internal"); return $""" // {aliases} global using DimensionalQuantities.Generated; - namespace DimensionalQuantities.Generated; - {extensions} """; } - - private static QuantityDefinition DefineQuantity(PlannedQuantity quantity, int[] prefixes) - { - Dimension dimension = quantity.Dimension; - IReadOnlyList units = quantity.Units; - string typeName = $"global::DimensionalQuantities.TypeSystem.Quantity>"; - string[] aliases = quantity.Aliases.ToArray(); - string quantityName = units[0].QuantityName; - UnitExpression canonicalExpression = units[0].Expression; - ConversionDefinition genericConversion = new("FromPrefixedUnits", "AsPrefixedUnits", GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression)); - ConversionDefinition[] namedConversions = quantity.IncludeNamedConversions - ? CollectConversions(units, prefixes) - .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) - .ToArray() - : []; - return new(quantityName, typeName, aliases, [genericConversion, .. namedConversions]); - } - - private static IReadOnlyDictionary CollectConversions(IReadOnlyList units, int[] prefixes) - { - Dictionary conversions = new(StringComparer.Ordinal); - foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) - { - if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) - { - throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); - } - - if (!conversions.ContainsKey(name)) - { - conversions.Add(name, scaleExponent); - } - } - - return conversions; - } - - private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) - => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions - .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); - - private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) - { - int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); - if (firstNumerator < 0) - { - yield return (expression.GetName(), expression.GetSiScaleExponent()); - yield break; - } - - foreach (int prefix in prefixes.Prepend(0)) - { - UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); - yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); - } - } - - private static PrefixParameterDefinition[] GetTermParameters(UnitExpression expression) => expression.Terms - .Select(term => new PrefixParameterDefinition(GetPrefixParameterName(term), term.PrefixExponent)).ToArray(); - - private static string GetTermScaleExponent(UnitExpression expression) => string.Join(" + ", expression.Terms.Select(FormatTermScaleExponent)); - - private static string FormatTermScaleExponent(UnitTerm term) - { - string prefix = $"((double){GetPrefixParameterName(term)}"; - if (term.Unit.SiBasePrefixExponent != 0) - { - prefix += " - " + term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture); - } - - prefix += ")"; - if (term.Power == Rational.One) - { - return prefix; - } - - string power = term.Power.Denominator.IsOne - ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) - : $"({term.Power.Numerator.ToString(CultureInfo.InvariantCulture)}d / {term.Power.Denominator.ToString(CultureInfo.InvariantCulture)}d)"; - return $"{prefix} * {power}"; - } - - private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; - - private static string RenderQuantity(QuantityDefinition definition) - { - string variableName = char.ToLowerInvariant(definition.Name[0]) + definition.Name[1..]; - string factories = string.Join("\n\t\t", definition.Conversions.Select(conversion => RenderFactory(definition.Name, conversion))); - string accessors = string.Join("\n\t\t", definition.Conversions.Select(conversion => RenderAccessor(variableName, conversion))); - - return $$""" - internal static class {{definition.Name}}Extensions - { - extension({{definition.Name}}) - { - {{factories}} - } - - extension({{definition.Name}} {{variableName}}) - { - {{accessors}} - } - } - """; - } - - private static string RenderFactory(string quantityName, ConversionDefinition conversion) - { - string parameters = string.Concat(conversion.PrefixParameters.Select(parameter => $", int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); - return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; - } - - private static string RenderAccessor(string variableName, ConversionDefinition conversion) - { - string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); - return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; - } - - private static string FormatExponent(Rational exponent) => ((double)exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; - - private static Type[] GetExponentTypes(Dimension dimension) => - [ - TypeSystemNumberCodec.CreateType(dimension.Length), - TypeSystemNumberCodec.CreateType(dimension.Mass), - TypeSystemNumberCodec.CreateType(dimension.Time), - TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), - TypeSystemNumberCodec.CreateType(dimension.Temperature), - TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), - TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) - ]; - - private sealed record QuantityDefinition(string Name, string TypeName, string[] Aliases, ConversionDefinition[] Conversions); - - private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, string ScaleExponentExpression); - - private sealed record PrefixParameterDefinition(string Name, int DefaultExponent); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityTypeNameFormatter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityTypeNameFormatter.cs new file mode 100644 index 0000000..8692957 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityTypeNameFormatter.cs @@ -0,0 +1,23 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class QuantityTypeNameFormatter +{ + public static string Format(Dimension dimension) + { + Type[] exponents = + [ + TypeSystemNumberCodec.CreateType(dimension.Length), + TypeSystemNumberCodec.CreateType(dimension.Mass), + TypeSystemNumberCodec.CreateType(dimension.Time), + TypeSystemNumberCodec.CreateType(dimension.ElectricCurrent), + TypeSystemNumberCodec.CreateType(dimension.Temperature), + TypeSystemNumberCodec.CreateType(dimension.AmountOfSubstance), + TypeSystemNumberCodec.CreateType(dimension.LuminousIntensity) + ]; + return $"global::DimensionalQuantities.TypeSystem.Quantity>"; + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs new file mode 100644 index 0000000..5a590f0 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class UnitConversionEmitter +{ + public static string Emit(IReadOnlyList quantities, string namespaceName, string accessibility) + { + int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); + string extensions = string.Join("\n\n", quantities.Select(quantity => RenderQuantity(quantity, prefixes, accessibility))); + return $""" + namespace {namespaceName}; + + {extensions} + """; + } + + private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes, string accessibility) + { + UnitExpression canonicalExpression = quantity.Units[0].Expression; + ConversionDefinition generic = new("FromPrefixedUnits", "AsPrefixedUnits", + GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression)); + ConversionDefinition[] named = quantity.IncludeNamed + ? CollectConversions(quantity.Units, prefixes) + .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) + .ToArray() + : []; + ConversionDefinition[] conversions = [generic, .. named]; + string variableName = char.ToLowerInvariant(quantity.Name[0]) + quantity.Name[1..]; + string factories = string.Join("\n\t\t", conversions.Select(conversion => RenderFactory(quantity.ReceiverType, conversion))); + string accessors = string.Join("\n\t\t", conversions.Select(conversion => RenderAccessor(variableName, conversion))); + + return $$""" + {{accessibility}} static class {{quantity.Name}}Extensions + { + extension({{quantity.ReceiverType}}) + { + {{factories}} + } + + extension({{quantity.ReceiverType}} {{variableName}}) + { + {{accessors}} + } + } + """; + } + + private static IReadOnlyDictionary CollectConversions(IReadOnlyList units, int[] prefixes) + { + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) + { + if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) + { + throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); + } + + if (!conversions.ContainsKey(name)) + { + conversions.Add(name, scaleExponent); + } + } + + return conversions; + } + + private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) + => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions + .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); + + private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) + { + int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); + if (firstNumerator < 0) + { + yield return (expression.GetName(), expression.GetSiScaleExponent()); + yield break; + } + + foreach (int prefix in prefixes.Prepend(0)) + { + UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); + } + } + + private static PrefixParameterDefinition[] GetTermParameters(UnitExpression expression) => expression.Terms + .Select(term => new PrefixParameterDefinition(GetPrefixParameterName(term), term.PrefixExponent)).ToArray(); + + private static string GetTermScaleExponent(UnitExpression expression) => string.Join(" + ", expression.Terms.Select(FormatTermScaleExponent)); + + private static string FormatTermScaleExponent(UnitTerm term) + { + string prefix = $"((double){GetPrefixParameterName(term)}"; + if (term.Unit.SiBasePrefixExponent != 0) + { + prefix += " - " + term.Unit.SiBasePrefixExponent.ToString(CultureInfo.InvariantCulture); + } + + prefix += ")"; + if (term.Power == Rational.One) + { + return prefix; + } + + string power = term.Power.Denominator.IsOne + ? term.Power.Numerator.ToString(CultureInfo.InvariantCulture) + : $"({term.Power.Numerator.ToString(CultureInfo.InvariantCulture)}d / {term.Power.Denominator.ToString(CultureInfo.InvariantCulture)}d)"; + return $"{prefix} * {power}"; + } + + private static string GetPrefixParameterName(UnitTerm term) => char.ToLowerInvariant(term.Unit.NameSingular[0]) + term.Unit.NameSingular[1..] + "PrefixExponent"; + + private static string RenderFactory(string quantityName, ConversionDefinition conversion) + { + string parameters = string.Concat(conversion.PrefixParameters.Select(parameter => $", int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; + } + + private static string RenderAccessor(string variableName, ConversionDefinition conversion) + { + string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); + return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; + } + + private static string FormatExponent(Rational exponent) => ((double)exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; + + private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, string ScaleExponentExpression); + + private sealed record PrefixParameterDefinition(string Name, int DefaultExponent); +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs new file mode 100644 index 0000000..547561c --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs @@ -0,0 +1,6 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal sealed record UnitConversionSpec(string Name, string ReceiverType, ImmutableArray Units, bool IncludeNamed); diff --git a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs index 74b66e5..be6f977 100644 --- a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs +++ b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs @@ -6,6 +6,9 @@ namespace DimensionalQuantities.Tests.Units; public sealed class UnitExpressionTests { + private static readonly AtomicUnit _newton = new("Newton", "Newtons", "N"); + private static readonly AtomicUnit _volt = new("Volt", "Volts", "V"); + private static readonly AtomicUnit _watt = new("Watt", "Watts", "W"); [Theory] [InlineData(1, "Metres")] [InlineData(2, "SquareMetres")] @@ -46,7 +49,7 @@ public void GetNameFormatsCompoundNumeratorAndDenominator() [Fact] public void GetNamePluralisesOnlyTheLastNumeratorFactor() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Newton), new UnitTerm(AtomicUnits.Metre)); + UnitExpression original = new(new UnitTerm(_newton), new UnitTerm(AtomicUnits.Metre)); string result = original.GetName(); string expected = "NewtonMetres"; @@ -199,7 +202,7 @@ public void GetSiScaleExponentCombinesFractionalGramOffsetAndPrefixedDenominator [InlineData(-30, -30)] public void GetSiScaleExponentAppliesVoltagePrefix(int prefixExponent, int expected) { - UnitExpression original = new(new UnitTerm(AtomicUnits.Volt, prefixExponent: prefixExponent)); + UnitExpression original = new(new UnitTerm(_volt, prefixExponent: prefixExponent)); Rational result = original.GetSiScaleExponent(); Assert.Equal(expected, result); @@ -233,7 +236,7 @@ public void GetSiScaleExponentCombinesReciprocalFactors() [Fact] public void GetSiScaleExponentCancelsKilogramInWattsPerKilogram() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Watt), new UnitTerm(AtomicUnits.Gram, -1, 3)); + UnitExpression original = new(new UnitTerm(_watt), new UnitTerm(AtomicUnits.Gram, -1, 3)); Rational result = original.GetSiScaleExponent(); Rational expected = Rational.Zero; @@ -243,7 +246,7 @@ public void GetSiScaleExponentCancelsKilogramInWattsPerKilogram() [Fact] public void GetSiScaleExponentIncludesKilowattPrefix() { - UnitExpression original = new(new UnitTerm(AtomicUnits.Watt, prefixExponent: 3), new UnitTerm(AtomicUnits.Gram, -1, 3)); + UnitExpression original = new(new UnitTerm(_watt, prefixExponent: 3), new UnitTerm(AtomicUnits.Gram, -1, 3)); Rational result = original.GetSiScaleExponent(); Rational expected = 3; diff --git a/src/DimensionalQuantities.slnx b/src/DimensionalQuantities.slnx index ddef88c..9fac51a 100644 --- a/src/DimensionalQuantities.slnx +++ b/src/DimensionalQuantities.slnx @@ -3,6 +3,11 @@ + + + + + diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index bb64c9f..eb9dfb9 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -13,10 +13,16 @@ + + + @@ -26,6 +32,7 @@ + diff --git a/src/DimensionalQuantities/Units/AtomicUnits.cs b/src/DimensionalQuantities/Units/AtomicUnits.cs index f114a19..361a06f 100644 --- a/src/DimensionalQuantities/Units/AtomicUnits.cs +++ b/src/DimensionalQuantities/Units/AtomicUnits.cs @@ -9,16 +9,4 @@ internal static class AtomicUnits public static readonly AtomicUnit Kelvin = new("Kelvin", "Kelvin", "K", SiBasePrefixExponents.Temperature); public static readonly AtomicUnit Mole = new("Mole", "Moles", "mol", SiBasePrefixExponents.AmountOfSubstance); public static readonly AtomicUnit Candela = new("Candela", "Candelas", "cd", SiBasePrefixExponents.LuminousIntensity); - public static readonly AtomicUnit Radian = new("Radian", "Radians", "rad"); - public static readonly AtomicUnit Hertz = new("Hertz", "Hertz", "Hz"); - public static readonly AtomicUnit Pascal = new("Pascal", "Pascals", "Pa"); - public static readonly AtomicUnit Joule = new("Joule", "Joules", "J"); - public static readonly AtomicUnit Newton = new("Newton", "Newtons", "N"); - public static readonly AtomicUnit Watt = new("Watt", "Watts", "W"); - public static readonly AtomicUnit Volt = new("Volt", "Volts", "V"); - public static readonly AtomicUnit Ohm = new("Ohm", "Ohms", "Ω"); - public static readonly AtomicUnit Henry = new("Henry", "Henries", "H"); - public static readonly AtomicUnit Weber = new("Weber", "Webers", "Wb"); - public static readonly AtomicUnit Tesla = new("Tesla", "Teslas", "T"); - public static readonly AtomicUnit Siemens = new("Siemens", "Siemens", "S"); } diff --git a/src/DimensionalQuantities/Units/EngineeringQuantityAliases.cs b/src/DimensionalQuantities/Units/EngineeringQuantityAliases.cs new file mode 100644 index 0000000..3e571c4 --- /dev/null +++ b/src/DimensionalQuantities/Units/EngineeringQuantityAliases.cs @@ -0,0 +1,32 @@ +using DimensionalQuantities.Dimensions; + +namespace DimensionalQuantities.Units; + +internal static class EngineeringQuantityAliases +{ + public static readonly Dictionary Entries = new() + { + { new(Length: 2), ["Area"] }, + { new(Length: 3), ["Volume"] }, + { new(Time: -1), ["Frequency", "AngularVelocity"] }, + { new(Length: -3, Mass: 1), ["Density"] }, + { new(Length: -1, Mass: 1, Time: -2), ["Pressure", "Stress", "YoungsModulus"] }, + { new(Length: 2, Mass: 1, Time: -2), ["Energy", "Torque"] }, + { new(Length: 2, Mass: 1, Time: -3), ["Power"] }, + { new(Length: 2, Time: -3), ["SpecificPower", "SpecificPowerLoss"] }, + { new(Temperature: -1), ["TemperatureCoefficient"] }, + { new(Length: -1, Mass: 1, Time: -3), ["PowerDensity", "VolumetricPowerLoss"] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), ["Voltage"] }, + { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), ["Resistance"] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), ["Inductance"] }, + { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), ["MagneticFlux", "FluxLinkage"] }, + { new(Mass: 1, Time: -2, ElectricCurrent: -1), ["MagneticFluxDensity", "MagneticPolarisation", "RemanentFluxDensity"] }, + { new(Length: -1, ElectricCurrent: 1), ["MagneticFieldStrength", "CoerciveFieldStrength"] }, + { new(Length: -2, ElectricCurrent: 1), ["CurrentDensity"] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), ["Permeability"] }, + { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), ["Resistivity"] }, + { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), ["Conductivity"] }, + { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), ["MagneticReluctivity"] }, + { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), ["MagneticVectorPotential"] } + }; +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e72dff1..ff91584 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,4 +4,17 @@ enable enable + + true + snupkg + true + true + true + portable + + + true + true + portable + From c0e65b3640f3ea2cbd5e5fa70bca34583e0570a8 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 22:38:12 +0100 Subject: [PATCH 2/6] refactor: generate metric conversions through Roslyn Remove the build console and custom execution target. Generate Metric extensions during compilation and organise conversion, API, and package tests around their owning components. --- .github/scripts/check-package.py | 2 - .../DimensionalQuantities.Build.csproj | 11 --- src/DimensionalQuantities.Build/Program.cs | 31 ------ .../MetricConversionsTests.cs | 8 +- ...nsionalQuantities.Extensions.Metric.csproj | 21 ++-- .../MsbuildCollection.cs | 2 +- .../PackagedGeneratorTests.cs | 15 ++- .../Packaging/SymbolPackageTests.cs | 5 +- .../MetricConversionGenerationTests.cs | 86 +++++++++++++++++ .../Generators/QuantitySourceEmitterTests.cs | 49 +++------- .../Generators/UnitConversionEmitterTests.cs | 95 +++++++++++++++++++ .../DimensionalQuantities.Roslyn.csproj | 1 - .../Generators}/EngineeringAtomicUnits.cs | 2 +- .../Generators}/EngineeringCatalogue.cs | 2 +- .../MetricConversionSourceEmitter.cs | 44 +++++++++ .../Generators/QuantityGenerator.cs | 1 + src/DimensionalQuantities.slnx | 1 - .../DimensionalQuantities.csproj | 1 - 18 files changed, 269 insertions(+), 108 deletions(-) delete mode 100644 src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj delete mode 100644 src/DimensionalQuantities.Build/Program.cs rename src/DimensionalQuantities.IntegrationTests/{GeneratedQuantityTests => Packaging}/MsbuildCollection.cs (61%) rename src/DimensionalQuantities.IntegrationTests/{GeneratedQuantityTests => Packaging}/PackagedGeneratorTests.cs (97%) create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs rename src/{DimensionalQuantities.Build/Units => DimensionalQuantities.Roslyn/Generators}/EngineeringAtomicUnits.cs (94%) rename src/{DimensionalQuantities.Build/Units => DimensionalQuantities.Roslyn/Generators}/EngineeringCatalogue.cs (98%) create mode 100644 src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs diff --git a/.github/scripts/check-package.py b/.github/scripts/check-package.py index aa62e43..68276f7 100644 --- a/.github/scripts/check-package.py +++ b/.github/scripts/check-package.py @@ -56,7 +56,6 @@ def read_package(package_id, extension): for names in (core_files, metric_files): assert not any("Microsoft.CodeAnalysis" in name for name in names) - assert not any("DimensionalQuantities.Build" in name for name in names) assert not any(".Tests" in name for name in names) for metadata in (core_metadata, metric_metadata): @@ -70,7 +69,6 @@ def read_package(package_id, extension): assert commit == os.environ["GITHUB_SHA"], (commit, os.environ["GITHUB_SHA"]) dependencies = metadata.findall(".//{*}dependency") assert not any("Roslyn" in item.attrib["id"] or "CodeAnalysis" in item.attrib["id"] for item in dependencies) - assert not any("DimensionalQuantities.Build" in item.attrib["id"] for item in dependencies) core_dependencies = core_metadata.findall(".//{*}dependency") assert not any(item.attrib["id"] == metric_id for item in core_dependencies) diff --git a/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj b/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj deleted file mode 100644 index 69036bf..0000000 --- a/src/DimensionalQuantities.Build/DimensionalQuantities.Build.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - net11.0 - false - - - - - - diff --git a/src/DimensionalQuantities.Build/Program.cs b/src/DimensionalQuantities.Build/Program.cs deleted file mode 100644 index 50f4bb9..0000000 --- a/src/DimensionalQuantities.Build/Program.cs +++ /dev/null @@ -1,31 +0,0 @@ -using DimensionalQuantities.Roslyn.Discovery; -using DimensionalQuantities.Roslyn.Generators; -using DimensionalQuantities.Build.Units; -using DimensionalQuantities.Units; - -if (args.Length != 1) -{ - throw new ArgumentException("Expected the generated source path."); -} - -List quantities = []; -foreach (KeyValuePair entry in EngineeringCatalogue.Entries) -{ - if (!EngineeringQuantityAliases.Entries.TryGetValue(entry.Key, out string[]? aliases) || - !aliases.SequenceEqual(entry.Value.Select(unit => unit.QuantityName))) - { - throw new InvalidOperationException($"Metric catalogue aliases differ from core for {entry.Key}."); - } - - quantities.Add(new(entry.Value[0].QuantityName, QuantityTypeNameFormatter.Format(entry.Key), - [.. entry.Value], true)); -} - -string source = "// \n" + UnitConversionEmitter.Emit(quantities, - "DimensionalQuantities.Extensions.Metric", "public"); -string path = Path.GetFullPath(args[0]); -Directory.CreateDirectory(Path.GetDirectoryName(path)!); -if (!File.Exists(path) || File.ReadAllText(path) != source) -{ - File.WriteAllText(path, source); -} diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs index 8f16769..dc51f24 100644 --- a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -54,7 +54,13 @@ public void NamedConversionsApplyMetricScale() { Assert.Multiple( () => Assert.Equal(0.0025, Voltage.FromSiBaseUnit(2.5).AsKiloVolts()), - () => Assert.Equal(1000, Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit()), + () => Assert.Equal(1000, Density.FromGramsPerCubicCentiMetre(1).AsSiBaseUnit())); + } + + [Fact] + public void GenericPrefixConversionsApplyMetricScale() + { + Assert.Multiple( () => Assert.Equal(0.002, Voltage.FromPrefixedUnits(2, -3).AsSiBaseUnit()), () => Assert.Equal(1000000, CurrentDensity.FromPrefixedUnits(1, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit()), () => Assert.Equal(1000, SpecificPower.FromPrefixedUnits(1, wattPrefixExponent: 3, gramPrefixExponent: 3).AsSiBaseUnit()), diff --git a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj index 265f5ea..4b69092 100644 --- a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj +++ b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj @@ -7,26 +7,19 @@ https://github.com/GoSimulate/DimensionalQuantities Named metric conversions for dimensional quantities. git - $(DotNetHostPath) - dotnet + true + true + $(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/generated - + + + - - - $(IntermediateOutputPath)MetricConversions.g.cs - - - - - - <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)" diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/MsbuildCollection.cs similarity index 61% rename from src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs rename to src/DimensionalQuantities.IntegrationTests/Packaging/MsbuildCollection.cs index b362707..28343f7 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/MsbuildCollection.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/MsbuildCollection.cs @@ -1,6 +1,6 @@ using Xunit; -namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; +namespace DimensionalQuantities.IntegrationTests.Packaging; [CollectionDefinition("MSBuild", DisableParallelization = true)] public sealed class MsbuildCollection; diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs similarity index 97% rename from src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs rename to src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs index a684491..d251bd5 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs @@ -1,10 +1,9 @@ using System.Reflection; -using DimensionalQuantities.IntegrationTests.Packaging; using Microsoft.Build.Locator; using Microsoft.Build.Utilities.ProjectCreation; using Xunit; -namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; +namespace DimensionalQuantities.IntegrationTests.Packaging; [Collection("MSBuild")] public sealed class PackagedGeneratorTests : IDisposable @@ -211,7 +210,10 @@ private static void Build(ProjectCreator project) project.TryBuild(restore: true, out bool built, out BuildOutput output); using (output) { - Assert.True(built, output.GetConsoleLog()); + if (!built) + { + throw new InvalidOperationException(output.GetConsoleLog()); + } } } @@ -220,7 +222,10 @@ private void Pack(ProjectCreator project, string name, PackageRepository reposit project.TryBuild(restore: true, target: "Pack", out bool built, out BuildOutput output); using (output) { - Assert.True(built, output.GetConsoleLog()); + if (!built) + { + throw new InvalidOperationException(output.GetConsoleLog()); + } } package = PackageTestSupport.AddPackedPackage(repository, new FileInfo(Path.Combine(_scratch, name, "packages", $"{name}.1.0.0.nupkg"))); } @@ -228,7 +233,7 @@ private void Pack(ProjectCreator project, string name, PackageRepository reposit private string OutputDirectory(string project) => Path.Combine(_scratch, project, "bin", "Debug", "netstandard2.0"); private static double InvokeDouble(Type type, string method, params object[] arguments) - => Assert.IsType(type.GetMethod(method)!.Invoke(null, arguments)); + => (double)type.GetMethod(method)!.Invoke(null, arguments)!; private ProjectCreator CreateProject(string name, string source, params Package[] packages) { diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs index 3a1921d..0959f1b 100644 --- a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs @@ -59,7 +59,8 @@ public void MetricSymbolPackageMatchesAssemblyAndEmbedsGeneratedSource() string commit = GetCurrentCommit(); string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); string configuration = typeof(SymbolPackageTests).Assembly.GetCustomAttribute()!.Configuration; - byte[] generatedSource = File.ReadAllBytes(Path.Combine(sourceDirectory, "DimensionalQuantities.Extensions.Metric", "obj", configuration, "netstandard2.0", "MetricConversions.g.cs")); + byte[] generatedSource = File.ReadAllBytes(Path.Combine(sourceDirectory, "DimensionalQuantities.Extensions.Metric", "obj", configuration, "netstandard2.0", + "generated", "DimensionalQuantities.Roslyn", "DimensionalQuantities.Roslyn.Generators.QuantityGenerator", "MetricConversions.g.cs")); Assert.Multiple( () => Assert.Equal(details.AssemblyGuid, details.PdbGuid), () => Assert.Equal(details.AssemblyStamp, details.PdbStamp), @@ -85,7 +86,7 @@ private static SymbolDetails ReadSymbolDetails(ZipArchive binaries, ZipArchive s byte[] pdbBytes = ReadEntry(symbols, stem + ".pdb"); using PEReader assembly = new(new MemoryStream(assemblyBytes)); using MetadataReaderProvider pdb = MetadataReaderProvider.FromPortablePdbStream(new MemoryStream(pdbBytes)); - DebugDirectoryEntry codeView = Assert.Single(assembly.ReadDebugDirectory().Where(entry => entry.Type == DebugDirectoryEntryType.CodeView)); + DebugDirectoryEntry codeView = assembly.ReadDebugDirectory().Single(entry => entry.Type == DebugDirectoryEntryType.CodeView); CodeViewDebugDirectoryData codeViewData = assembly.ReadCodeViewDebugDirectoryData(codeView); MetadataReader reader = pdb.GetMetadataReader(); byte[] pdbId = reader.DebugMetadataHeader!.Id.ToArray(); diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs new file mode 100644 index 0000000..fa3ab34 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs @@ -0,0 +1,86 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class MetricConversionGenerationTests +{ + [Fact] + public void EnabledMetricCompilationEmitsPublicConversions() + { + (Compilation compilation, GeneratorDriverRunResult result, ImmutableArray diagnostics) = Generate("true"); + INamedTypeSymbol? extensions = compilation.GetTypeByMetadataName("DimensionalQuantities.Extensions.Metric.VoltageExtensions"); + + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Contains(Assert.Single(result.Results).GeneratedSources, + generated => generated.HintName == "MetricConversions.g.cs"), + () => Assert.Equal(Accessibility.Public, extensions?.DeclaredAccessibility), + () => Assert.Empty(compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error))); + } + + [Theory] + [InlineData(null)] + [InlineData("false")] + public void ConsumerCompilationDoesNotEmitMetricConversions(string? propertyValue) + { + (Compilation compilation, GeneratorDriverRunResult result, ImmutableArray diagnostics) = Generate(propertyValue); + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.DoesNotContain(Assert.Single(result.Results).GeneratedSources, + generated => generated.HintName == "MetricConversions.g.cs"), + () => Assert.Null(compilation.GetTypeByMetadataName("DimensionalQuantities.Extensions.Metric.VoltageExtensions"))); + } + + private static (Compilation Compilation, GeneratorDriverRunResult Result, ImmutableArray Diagnostics) Generate(string? propertyValue) + { + CSharpParseOptions options = new(LanguageVersion.Preview); + string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation input = CSharpCompilation.Create("MetricGenerationTest", + [CSharpSyntaxTree.ParseText("internal static class Input { }", options)], references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], + parseOptions: options, optionsProvider: new MetricOptionsProvider(propertyValue)); + driver = driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + return (output, driver.GetRunResult(), diagnostics); + } + + private sealed class MetricOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly AnalyzerConfigOptions _options; + + public MetricOptionsProvider(string? propertyValue) => _options = new MetricOptions(propertyValue); + + public override AnalyzerConfigOptions GlobalOptions => _options; + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _options; + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _options; + } + + private sealed class MetricOptions : AnalyzerConfigOptions + { + private readonly string? _propertyValue; + + public MetricOptions(string? propertyValue) => _propertyValue = propertyValue; + + public override bool TryGetValue(string key, out string value) + { + if (key == "build_property.GenerateMetricConversions" && _propertyValue is not null) + { + value = _propertyValue; + return true; + } + + value = string.Empty; + return false; + } + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs index 68d02f5..f619447 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantitySourceEmitterTests.cs @@ -13,11 +13,6 @@ namespace DimensionalQuantities.Roslyn.Tests.Generators; public sealed class QuantitySourceEmitterTests { private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); - private static readonly string[] _prefixes = - [ - "Quetta", "Ronna", "Yotta", "Zetta", "Exa", "Peta", "Tera", "Giga", "Mega", "Kilo", "Hecto", "Deca", - "Deci", "Centi", "Milli", "Micro", "Nano", "Pico", "Femto", "Atto", "Zepto", "Yocto", "Ronto", "Quecto" - ]; [Fact] public void DiscoveredMechanicalQuantityUsesItsGenericPrefixConversion() @@ -72,33 +67,6 @@ public void CatalogueAliasesShareTheCanonicalDimensionType(string alias, string Assert.True(result); } - [Fact] - public void EverySiPrefixHasUsableFactoryAndAccessor() - { - string calls = string.Join("\n", _prefixes.Select(prefix => - $"Length value{prefix} = Length.From{prefix}Metres(1); double output{prefix} = value{prefix}.As{prefix}Metres();")); - string source = $$""" - public static class Consumer - { - public static void UsePrefixes() - { - {{calls}} - } - } - """; - - Compile(source); - } - - [Fact] - public void BaseUnitPrefixesKeepTheirSiScales() - { - Assert.Equal(2.5, Evaluate("Length.FromMilliMetres(2500).AsSiBaseUnit()")); - Assert.Equal(2.5, Evaluate("Mass.FromKiloGrams(2.5).AsSiBaseUnit()")); - Assert.Equal(2, Evaluate("Mass.FromPrefixedUnits(2).AsSiBaseUnit()")); - Assert.Equal(1000, Evaluate("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)")); - } - private static T Evaluate(string expression) { string source = $$""" @@ -109,7 +77,7 @@ public static class Consumer """; Assembly assembly = CompileAndEmit(source, QuantityDiscovery.Discover([]).KnownQuantities, []); MethodInfo method = assembly.GetType("Consumer")!.GetMethod("Value")!; - return Assert.IsType(method.Invoke(null, null)); + return (T)method.Invoke(null, null)!; } private static CSharpCompilation Compile(string source) @@ -121,7 +89,10 @@ private static CSharpCompilation Compile(string source) CSharpCompilation compilation = CreateInputCompilation(source).AddSyntaxTrees(generated); Diagnostic[] errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken) .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); - Assert.Empty(errors); + if (errors.Length != 0) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + } return compilation; } @@ -132,11 +103,17 @@ private static Assembly CompileAndEmit(string source, IReadOnlyList diagnostic.Severity == DiagnosticSeverity.Error).ToArray(); - Assert.Empty(errors); + if (errors.Length != 0) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + } using MemoryStream assemblyBytes = new(); EmitResult emit = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); - Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + if (!emit.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emit.Diagnostics.Select(diagnostic => diagnostic.ToString()))); + } return Assembly.Load(assemblyBytes.ToArray()); } diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs new file mode 100644 index 0000000..6e19d6a --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs @@ -0,0 +1,95 @@ +using System.Reflection; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using DimensionalQuantities.Units; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class UnitConversionEmitterTests +{ + private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); + private static readonly UnitConversionSpec[] _units = + [ + new("Length", "Length", [new UnitDefinition("Length", [new(AtomicUnits.Metre, prefixExponent: SiBasePrefixExponents.Length)])], true), + new("Mass", "Mass", [new UnitDefinition("Mass", [new(AtomicUnits.Gram, prefixExponent: SiBasePrefixExponents.Mass)])], true) + ]; + private static readonly string[] _prefixes = + [ + "Quetta", "Ronna", "Yotta", "Zetta", "Exa", "Peta", "Tera", "Giga", "Mega", "Kilo", "Hecto", "Deca", + "Deci", "Centi", "Milli", "Micro", "Nano", "Pico", "Femto", "Atto", "Zepto", "Yocto", "Ronto", "Quecto" + ]; + + [Fact] + public void EverySiPrefixHasUsableFactoryAndAccessor() + { + string calls = string.Join("\n", _prefixes.Select(prefix => + $"Length value{prefix} = Length.From{prefix}Metres(1); double output{prefix} = value{prefix}.As{prefix}Metres();")); + string source = $$""" + public static class Consumer + { + public static void UsePrefixes() + { + {{calls}} + } + } + """; + + CSharpCompilation compilation = Compile(source); + Assert.Empty(compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + } + + [Theory] + [InlineData("Length.FromMilliMetres(2500).AsSiBaseUnit()", 2.5)] + [InlineData("Mass.FromKiloGrams(2.5).AsSiBaseUnit()", 2.5)] + [InlineData("Mass.FromPrefixedUnits(2).AsSiBaseUnit()", 2)] + [InlineData("Mass.FromSiBaseUnit(1).AsPrefixedUnits(gramPrefixExponent: 0)", 1000)] + public void BaseUnitPrefixesKeepTheirSiScales(string expression, double expected) + { + string source = $$""" + public static class Consumer + { + public static double Value() => {{expression}}; + } + """; + CSharpCompilation compilation = Compile(source); + using MemoryStream assemblyBytes = new(); + EmitResult emit = compilation.Emit(assemblyBytes, cancellationToken: TestContext.Current.CancellationToken); + if (!emit.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emit.Diagnostics.Select(diagnostic => diagnostic.ToString()))); + } + + Assembly assembly = Assembly.Load(assemblyBytes.ToArray()); + MethodInfo method = assembly.GetType("Consumer")!.GetMethod("Value")!; + double result = (double)method.Invoke(null, null)!; + Assert.Equal(expected, result); + } + + private static CSharpCompilation Compile(string source) + { + string lengthType = QuantityTypeNameFormatter.Format(new Dimension(Length: 1)); + string massType = QuantityTypeNameFormatter.Format(new Dimension(Mass: 1)); + string generatedSource = $""" + global using Length = {lengthType}; + global using Mass = {massType}; + global using DimensionalQuantities.Generated; + + {UnitConversionEmitter.Emit(_units, "DimensionalQuantities.Generated", "internal")} + """; + string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation compilation = CSharpCompilation.Create("UnitConversionEmitterConsumer", + [ + CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs"), + CSharpSyntaxTree.ParseText(generatedSource, _parseOptions, path: "Conversions.g.cs") + ], references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + return compilation; + } +} diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index 3cfea89..cfdb6db 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -12,6 +12,5 @@ - diff --git a/src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs b/src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs similarity index 94% rename from src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs rename to src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs index d091db3..3c39ee6 100644 --- a/src/DimensionalQuantities.Build/Units/EngineeringAtomicUnits.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs @@ -1,6 +1,6 @@ using DimensionalQuantities.Units; -namespace DimensionalQuantities.Build.Units; +namespace DimensionalQuantities.Roslyn.Generators; internal static class EngineeringAtomicUnits { diff --git a/src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs b/src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs similarity index 98% rename from src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs rename to src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs index a6b7fbc..18dc9d2 100644 --- a/src/DimensionalQuantities.Build/Units/EngineeringCatalogue.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs @@ -1,7 +1,7 @@ using DimensionalQuantities.Dimensions; using DimensionalQuantities.Units; -namespace DimensionalQuantities.Build.Units; +namespace DimensionalQuantities.Roslyn.Generators; /// Recognised names and units for engineering dimensions. internal static class EngineeringCatalogue diff --git a/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs new file mode 100644 index 0000000..44e374c --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs @@ -0,0 +1,44 @@ +using System.Text; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Units; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class MetricConversionSourceEmitter +{ + public static void Register(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider enabled = context.AnalyzerConfigOptionsProvider.Select(static (options, _) => + options.GlobalOptions.TryGetValue("build_property.GenerateMetricConversions", out string? value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)); + context.RegisterSourceOutput(enabled, static (output, isEnabled) => + { + if (isEnabled) + { + output.AddSource("MetricConversions.g.cs", SourceText.From(Emit(), Encoding.UTF8)); + } + }); + } + + private static string Emit() + { + List quantities = []; + foreach (KeyValuePair entry in EngineeringCatalogue.Entries) + { + if (!EngineeringQuantityAliases.Entries.TryGetValue(entry.Key, out string[]? aliases) || + !aliases.SequenceEqual(entry.Value.Select(unit => unit.QuantityName))) + { + throw new InvalidOperationException($"Metric catalogue aliases differ from core for {entry.Key}."); + } + + quantities.Add(new(entry.Value[0].QuantityName, QuantityTypeNameFormatter.Format(entry.Key), + [.. entry.Value], true)); + } + + return "// \n" + UnitConversionEmitter.Emit(quantities, + "DimensionalQuantities.Extensions.Metric", "public"); + } +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 55a51f7..0f00122 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -13,6 +13,7 @@ public sealed class QuantityGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { + MetricConversionSourceEmitter.Register(context); QuantityGenerationPlan defaultPlan = DefaultQuantityDiscovery.Discover(); context.RegisterPostInitializationOutput(output => { diff --git a/src/DimensionalQuantities.slnx b/src/DimensionalQuantities.slnx index 9fac51a..bb1d34b 100644 --- a/src/DimensionalQuantities.slnx +++ b/src/DimensionalQuantities.slnx @@ -4,7 +4,6 @@ - diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index eb9dfb9..f08dc49 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -32,7 +32,6 @@ - From f0de8ff6af5779a83b4374c5e266ca5240011f67 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 23:04:56 +0100 Subject: [PATCH 3/6] refactor: declare metric units in the extension package --- README.md | 26 ++ .../MetricConversionsTests.cs | 2 +- ...nsionalQuantities.Extensions.Metric.csproj | 2 - .../MetricUnits.cs | 162 ++++++++++ .../Packaging/SymbolPackageTests.cs | 4 +- .../MetricConversionGenerationTests.cs | 86 ----- .../Generators/UnitProviderGenerationTests.cs | 295 ++++++++++++++++++ .../Generators/UnitProviderReaderTests.cs | 71 +++++ .../AnalyzerReleases.Unshipped.md | 1 + .../Generators/EngineeringAtomicUnits.cs | 19 -- .../Generators/EngineeringCatalogue.cs | 54 ---- .../Generators/GenerationDiagnostics.cs | 4 + .../MetricConversionSourceEmitter.cs | 44 --- .../Generators/QuantityGenerator.cs | 36 ++- .../Generators/UnitConversionEmitter.cs | 82 +++-- .../Generators/UnitConversionSpec.cs | 3 +- .../Generators/UnitProviderReader.cs | 210 +++++++++++++ .../Generators/UnitProviderSourceEmitter.cs | 65 ++++ .../Units/UnitExpressionTests.cs | 34 ++ src/DimensionalQuantities/Units/AtomicUnit.cs | 2 +- .../Declarations/AdditionalUnitAttribute.cs | 10 + .../Declarations/QuantityUnitAttribute.cs | 13 + .../Units/Declarations/UnitFactorAttribute.cs | 13 + .../Declarations/UnitProviderAttribute.cs | 12 + .../Units/Declarations/UnitSymbolAttribute.cs | 19 ++ .../Units/UnitDefinition.cs | 4 +- .../Units/UnitExpression.cs | 5 +- 27 files changed, 1035 insertions(+), 243 deletions(-) create mode 100644 src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs delete mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs delete mode 100644 src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs create mode 100644 src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/AdditionalUnitAttribute.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/UnitFactorAttribute.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/UnitProviderAttribute.cs create mode 100644 src/DimensionalQuantities/Units/Declarations/UnitSymbolAttribute.cs diff --git a/README.md b/README.md index 65e6439..841c292 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,37 @@ The analysers and generator report dimensional mistakes and problems with genera | DQ0005 | Error | A closed type-system encoding is invalid. | | DQ0006 | Warning | A generated alias conflicts with a declared name or shadows an imported type. | | DQ0007 | Error | An unresolved English exponent in `Power.Of(...)` cannot be parsed, for example `NumberTwoOverZero`. | +| DQ0008 | Error | A unit provider declaration is invalid, for example a missing unit symbol or a zero power denominator. | DQ0003 has an IDE quick fix to replace the raw type with its readable alias, such as `Length`. The fix is offered only when the alias resolves to the same type; it is withheld inside `nameof`, where replacing the type could change the resulting string. Alias collision warnings do not suppress or rename generated aliases. Use qualified names or distinct explicit aliases to disambiguate your own types. +### Defining an extension + +An extension project references core and declares its units using attributes. The generator compiles public conversion helpers into that extension's assembly; consumers reference the extension and import its namespace. + +```csharp +using DimensionalQuantities.Units.Declarations; + +namespace Example.Units; + +[UnitProvider] +internal static class CustomUnits +{ + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot))] + private sealed class LengthUnit; + + [UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)] + private sealed class Foot; +} +``` + +Consumers can then use `Length.FromFeet(6)` and `length.AsFeet()` with `using Example.Units;`. + +`Scale` converts each atomic unit to SI. Ordered `UnitFactor` attributes describe compound units, with `Power`, `PowerDenominator` and `PrefixExponent` controlling each factor. `QuantityUnit.Offset` is added after scaling into SI and subtracted before converting back. A provider can opt into named SI prefixes and parameterised prefix conversions with `GenerateSiPrefixes` and `GeneratePrefixConversions`; both default to false. `AdditionalUnit` references another factor declaration for an alternative conversion. + ### Current scope When upgrading from the combined package, add the Metric package and namespace wherever you use catalogue conversions, including their `FromPrefixedUnits` and `AsPrefixedUnits` helpers. Their names and conversion behaviour are unchanged. Core-only consumers can use `FromSiBaseUnit`, `AsSiBaseUnit`, `FromArbitrarySiComponents` and `AsArbitrarySiComponents` for every quantity. diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs index dc51f24..148aea2 100644 --- a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -74,7 +74,7 @@ public void GenericPrefixConversionsApplyMetricScale() [Fact] public void PrefixesOnlyApplyToSupportedFactorPlacements() { - string[] names = typeof(TemperatureCoefficientExtensions).Assembly.GetExportedTypes() + string[] names = typeof(P11_MetricUnitsA0_TemperatureCoefficientExtensions).Assembly.GetExportedTypes() .Where(type => type.Namespace == "DimensionalQuantities.Extensions.Metric") .SelectMany(type => type.GetMethods()).Select(method => method.Name).ToArray(); Assert.Multiple( diff --git a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj index 4b69092..13efde5 100644 --- a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj +++ b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj @@ -7,7 +7,6 @@ https://github.com/GoSimulate/DimensionalQuantities Named metric conversions for dimensional quantities. git - true true $(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/generated @@ -16,7 +15,6 @@ - diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs new file mode 100644 index 0000000..1c56bad --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs @@ -0,0 +1,162 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Extensions.Metric; + +[UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] +internal static class MetricUnits +{ + [UnitSymbol("Metre", "Metres", "m")] + private sealed class Metre { } + [UnitSymbol("Gram", "Grams", "g", SiBasePrefixExponent = 3)] + private sealed class Gram { } + [UnitSymbol("Second", "Seconds", "s")] + private sealed class Second { } + [UnitSymbol("Amp", "Amps", "A")] + private sealed class Amp { } + [UnitSymbol("Kelvin", "Kelvin", "K")] + private sealed class Kelvin { } + [UnitSymbol("Radian", "Radians", "rad")] + private sealed class Radian { } + [UnitSymbol("Hertz", "Hertz", "Hz")] + private sealed class Hertz { } + [UnitSymbol("Pascal", "Pascals", "Pa")] + private sealed class Pascal { } + [UnitSymbol("Joule", "Joules", "J")] + private sealed class Joule { } + [UnitSymbol("Newton", "Newtons", "N")] + private sealed class Newton { } + [UnitSymbol("Watt", "Watts", "W")] + private sealed class Watt { } + [UnitSymbol("Volt", "Volts", "V")] + private sealed class Volt { } + [UnitSymbol("Ohm", "Ohms", "Ω")] + private sealed class Ohm { } + [UnitSymbol("Henry", "Henries", "H")] + private sealed class Henry { } + [UnitSymbol("Weber", "Webers", "Wb")] + private sealed class Weber { } + [UnitSymbol("Tesla", "Teslas", "T")] + private sealed class Tesla { } + [UnitSymbol("Siemens", "Siemens", "S")] + private sealed class Siemens { } + + [QuantityUnit(typeof(Area))] + [UnitFactor(typeof(Metre), Power = 2)] + private sealed class AreaUnit { } + [QuantityUnit(typeof(Volume))] + [UnitFactor(typeof(Metre), Power = 3)] + private sealed class VolumeUnit { } + [QuantityUnit(typeof(Frequency))] + [UnitFactor(typeof(Hertz))] + private sealed class FrequencyUnit { } + [QuantityUnit(typeof(AngularVelocity))] + [UnitFactor(typeof(Radian))] + [UnitFactor(typeof(Second), Power = -1)] + private sealed class AngularVelocityUnit { } + [QuantityUnit(typeof(Density))] + [UnitFactor(typeof(Gram), PrefixExponent = 3)] + [UnitFactor(typeof(Metre), Power = -3)] + [AdditionalUnit(typeof(DensityCgs))] + private sealed class DensityUnit { } + [UnitFactor(typeof(Gram))] + [UnitFactor(typeof(Metre), Power = -3, PrefixExponent = -2)] + private sealed class DensityCgs { } + [QuantityUnit(typeof(Pressure))] + [UnitFactor(typeof(Pascal))] + private sealed class PressureUnit { } + [QuantityUnit(typeof(Stress))] + [UnitFactor(typeof(Pascal))] + private sealed class StressUnit { } + [QuantityUnit(typeof(YoungsModulus))] + [UnitFactor(typeof(Pascal))] + private sealed class YoungsModulusUnit { } + [QuantityUnit(typeof(Energy))] + [UnitFactor(typeof(Joule))] + private sealed class EnergyUnit { } + [QuantityUnit(typeof(Torque))] + [UnitFactor(typeof(Newton))] + [UnitFactor(typeof(Metre))] + private sealed class TorqueUnit { } + [QuantityUnit(typeof(Power))] + [UnitFactor(typeof(Watt))] + private sealed class PowerUnit { } + [QuantityUnit(typeof(SpecificPower))] + [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(Gram), Power = -1, PrefixExponent = 3)] + private sealed class SpecificPowerUnit { } + [QuantityUnit(typeof(SpecificPowerLoss))] + [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(Gram), Power = -1, PrefixExponent = 3)] + private sealed class SpecificPowerLossUnit { } + [QuantityUnit(typeof(TemperatureCoefficient))] + [UnitFactor(typeof(Kelvin), Power = -1)] + private sealed class TemperatureCoefficientUnit { } + [QuantityUnit(typeof(PowerDensity))] + [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(Metre), Power = -3)] + private sealed class PowerDensityUnit { } + [QuantityUnit(typeof(VolumetricPowerLoss))] + [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(Metre), Power = -3)] + private sealed class VolumetricPowerLossUnit { } + [QuantityUnit(typeof(Voltage))] + [UnitFactor(typeof(Volt))] + private sealed class VoltageUnit { } + [QuantityUnit(typeof(Resistance))] + [UnitFactor(typeof(Ohm))] + private sealed class ResistanceUnit { } + [QuantityUnit(typeof(Inductance))] + [UnitFactor(typeof(Henry))] + private sealed class InductanceUnit { } + [QuantityUnit(typeof(MagneticFlux))] + [UnitFactor(typeof(Weber))] + private sealed class MagneticFluxUnit { } + [QuantityUnit(typeof(FluxLinkage))] + [UnitFactor(typeof(Weber))] + private sealed class FluxLinkageUnit { } + [QuantityUnit(typeof(MagneticFluxDensity))] + [UnitFactor(typeof(Tesla))] + private sealed class MagneticFluxDensityUnit { } + [QuantityUnit(typeof(MagneticPolarisation))] + [UnitFactor(typeof(Tesla))] + private sealed class MagneticPolarisationUnit { } + [QuantityUnit(typeof(RemanentFluxDensity))] + [UnitFactor(typeof(Tesla))] + private sealed class RemanentFluxDensityUnit { } + [QuantityUnit(typeof(MagneticFieldStrength))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class MagneticFieldStrengthUnit { } + [QuantityUnit(typeof(CoerciveFieldStrength))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class CoerciveFieldStrengthUnit { } + [QuantityUnit(typeof(CurrentDensity))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -2)] + [AdditionalUnit(typeof(CurrentDensityPerSquareMillimetre))] + private sealed class CurrentDensityUnit { } + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -2, PrefixExponent = -3)] + private sealed class CurrentDensityPerSquareMillimetre { } + [QuantityUnit(typeof(Permeability))] + [UnitFactor(typeof(Henry))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class PermeabilityUnit { } + [QuantityUnit(typeof(Resistivity))] + [UnitFactor(typeof(Ohm))] + [UnitFactor(typeof(Metre))] + private sealed class ResistivityUnit { } + [QuantityUnit(typeof(Conductivity))] + [UnitFactor(typeof(Siemens))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class ConductivityUnit { } + [QuantityUnit(typeof(MagneticReluctivity))] + [UnitFactor(typeof(Metre))] + [UnitFactor(typeof(Henry), Power = -1)] + private sealed class MagneticReluctivityUnit { } + [QuantityUnit(typeof(MagneticVectorPotential))] + [UnitFactor(typeof(Weber))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class MagneticVectorPotentialUnit { } +} diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs index 0959f1b..1f7ec60 100644 --- a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs @@ -60,7 +60,7 @@ public void MetricSymbolPackageMatchesAssemblyAndEmbedsGeneratedSource() string sourceDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); string configuration = typeof(SymbolPackageTests).Assembly.GetCustomAttribute()!.Configuration; byte[] generatedSource = File.ReadAllBytes(Path.Combine(sourceDirectory, "DimensionalQuantities.Extensions.Metric", "obj", configuration, "netstandard2.0", - "generated", "DimensionalQuantities.Roslyn", "DimensionalQuantities.Roslyn.Generators.QuantityGenerator", "MetricConversions.g.cs")); + "generated", "DimensionalQuantities.Roslyn", "DimensionalQuantities.Roslyn.Generators.QuantityGenerator", "DimensionalQuantities.Extensions.Metric.MetricUnits.g.cs")); Assert.Multiple( () => Assert.Equal(details.AssemblyGuid, details.PdbGuid), () => Assert.Equal(details.AssemblyStamp, details.PdbStamp), @@ -99,7 +99,7 @@ private static SymbolDetails ReadSymbolDetails(ZipArchive binaries, ZipArchive s string[] sourceUrls = sourceLinkDocument.RootElement.GetProperty("documents").EnumerateObject() .Select(mapping => mapping.Value.GetString()!).ToArray(); byte[]? embeddedMetricConversions = reader.Documents - .Where(handle => reader.GetString(reader.GetDocument(handle).Name).EndsWith("MetricConversions.g.cs", StringComparison.Ordinal)) + .Where(handle => reader.GetString(reader.GetDocument(handle).Name).EndsWith("DimensionalQuantities.Extensions.Metric.MetricUnits.g.cs", StringComparison.Ordinal)) .SelectMany(handle => reader.GetCustomDebugInformation(handle)) .Select(reader.GetCustomDebugInformation) .Where(info => reader.GetGuid(info.Kind) == _embeddedSourceKind) diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs deleted file mode 100644 index fa3ab34..0000000 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/MetricConversionGenerationTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Immutable; -using DimensionalQuantities.Roslyn.Generators; -using DimensionalQuantities.TypeSystem; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Xunit; - -namespace DimensionalQuantities.Roslyn.Tests.Generators; - -public sealed class MetricConversionGenerationTests -{ - [Fact] - public void EnabledMetricCompilationEmitsPublicConversions() - { - (Compilation compilation, GeneratorDriverRunResult result, ImmutableArray diagnostics) = Generate("true"); - INamedTypeSymbol? extensions = compilation.GetTypeByMetadataName("DimensionalQuantities.Extensions.Metric.VoltageExtensions"); - - Assert.Multiple( - () => Assert.Empty(diagnostics), - () => Assert.Contains(Assert.Single(result.Results).GeneratedSources, - generated => generated.HintName == "MetricConversions.g.cs"), - () => Assert.Equal(Accessibility.Public, extensions?.DeclaredAccessibility), - () => Assert.Empty(compilation.GetDiagnostics(TestContext.Current.CancellationToken) - .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error))); - } - - [Theory] - [InlineData(null)] - [InlineData("false")] - public void ConsumerCompilationDoesNotEmitMetricConversions(string? propertyValue) - { - (Compilation compilation, GeneratorDriverRunResult result, ImmutableArray diagnostics) = Generate(propertyValue); - Assert.Multiple( - () => Assert.Empty(diagnostics), - () => Assert.DoesNotContain(Assert.Single(result.Results).GeneratedSources, - generated => generated.HintName == "MetricConversions.g.cs"), - () => Assert.Null(compilation.GetTypeByMetadataName("DimensionalQuantities.Extensions.Metric.VoltageExtensions"))); - } - - private static (Compilation Compilation, GeneratorDriverRunResult Result, ImmutableArray Diagnostics) Generate(string? propertyValue) - { - CSharpParseOptions options = new(LanguageVersion.Preview); - string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); - IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) - .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); - CSharpCompilation input = CSharpCompilation.Create("MetricGenerationTest", - [CSharpSyntaxTree.ParseText("internal static class Input { }", options)], references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], - parseOptions: options, optionsProvider: new MetricOptionsProvider(propertyValue)); - driver = driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray diagnostics, - TestContext.Current.CancellationToken); - return (output, driver.GetRunResult(), diagnostics); - } - - private sealed class MetricOptionsProvider : AnalyzerConfigOptionsProvider - { - private readonly AnalyzerConfigOptions _options; - - public MetricOptionsProvider(string? propertyValue) => _options = new MetricOptions(propertyValue); - - public override AnalyzerConfigOptions GlobalOptions => _options; - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _options; - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _options; - } - - private sealed class MetricOptions : AnalyzerConfigOptions - { - private readonly string? _propertyValue; - - public MetricOptions(string? propertyValue) => _propertyValue = propertyValue; - - public override bool TryGetValue(string key, out string value) - { - if (key == "build_property.GenerateMetricConversions" && _propertyValue is not null) - { - value = _propertyValue; - return true; - } - - value = string.Empty; - return false; - } - } -} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs new file mode 100644 index 0000000..067120d --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs @@ -0,0 +1,295 @@ +using System.Collections.Immutable; +using System.Reflection; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class UnitProviderGenerationTests +{ + private const string FootProvider = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + + [UnitProvider] + internal static class CustomUnits + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot))] + private sealed class LengthUnit; + + [UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)] + private sealed class Foot; + } + """; + + [Fact] + public void IndependentProviderGeneratesScaledFactoriesAndAccessors() + { + const string consumer = """ + namespace Independent.Units; + public static class Consumer + { + public static double[] Values() => [Length.FromFeet(10).AsMetres(), Length.FromMetres(0.6096).AsFeet()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(FootProvider, consumer); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Multiple( + () => Assert.Equal(3.048, result[0], 12), + () => Assert.Equal(2, result[1], 12)); + } + + [Fact] + public void ProviderConversionsArePublicAndNotRegeneratedByConsumers() + { + (Compilation provider, ImmutableArray providerDiagnostics) = Generate(FootProvider, ""); + Assert.Empty(providerDiagnostics); + MetadataReference reference = MetadataReference.CreateFromImage(Emit(provider)); + const string consumer = """ + using Independent.Units; + public static class Consumer + { + public static Area Value() => Length.FromFeet(1) * Length.FromFeet(2); + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate("", consumer, reference); + INamespaceSymbol? localNamespace = compilation.Assembly.GlobalNamespace.GetNamespaceMembers() + .SingleOrDefault(symbol => symbol.Name == "Independent"); + + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Empty(compilation.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.Null(localNamespace)); + } + + [Fact] + public void ProviderCanOptIntoNamedAndParameterisedSiPrefixes() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] + internal static class CustomUnits + { + [QuantityUnit(typeof(Voltage))] + [UnitFactor(typeof(Spark))] + private sealed class VoltageUnit; + [UnitSymbol("Spark", "Sparks", "sp")] + private sealed class Spark; + } + public static class Consumer + { + public static double[] Values() => [Voltage.FromMilliSparks(500).AsSiBaseUnit(), Voltage.FromSiBaseUnit(2000).AsKiloSparks(), + Voltage.FromPrefixedUnits(3, sparkPrefixExponent: 3).AsSiBaseUnit(), Voltage.FromSiBaseUnit(0.004).AsPrefixedUnits(-3)]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Equal([0.5, 2, 3000, 4], result); + } + + [Fact] + public void CompoundFactorsPreserveOrderPowersAndAdditionalConversions() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] + internal static class CustomUnits + { + [QuantityUnit(typeof(CurrentDensity))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -2)] + [AdditionalUnit(typeof(PerSquareMillimetre))] + private sealed class CurrentDensityUnit; + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -2, PrefixExponent = -3)] + private sealed class PerSquareMillimetre; + [UnitSymbol("Amp", "Amps", "A")] + private sealed class Amp; + [UnitSymbol("Metre", "Metres", "m")] + private sealed class Metre; + } + public static class Consumer + { + public static double[] Values() => [CurrentDensity.FromAmpsPerSquareMilliMetre(1).AsSiBaseUnit(), + CurrentDensity.FromPrefixedUnits(2, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit(), + CurrentDensity.FromSiBaseUnit(1000000).AsAmpsPerSquareMilliMetre()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Equal([1000000d, 2000000d, 1d], result); + } + + [Fact] + public void AffineConversionsApplyScaleAndOffsetInBothDirections() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider] + internal static class CustomUnits + { + [QuantityUnit(typeof(Temperature), Offset = 255.3722222222222)] + [UnitFactor(typeof(Fahrenheit))] + private sealed class TemperatureUnit; + [UnitSymbol("Fahrenheit", "Fahrenheit", "°F", Scale = 5d / 9d)] + private sealed class Fahrenheit; + } + public static class Consumer + { + public static double[] Values() => [Temperature.FromFahrenheit(32).AsKelvin(), Temperature.FromKelvin(373.15).AsFahrenheit()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Multiple( + () => Assert.Equal(273.15, result[0], 10), + () => Assert.Equal(212, result[1], 10)); + } + + [Fact] + public void ProviderFactoriesCanBeUsedInArithmeticInTheirOwnAssembly() + { + const string consumer = """ + namespace Independent.Units; + public static class Consumer + { + public static double[] Values() => [(Length.FromFeet(2) * Length.FromFeet(3)).AsSiBaseUnit()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(FootProvider, consumer); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Equal(0.55741824, Assert.Single(result), 12); + } + + [Fact] + public void RecordClassProvidersGenerateConversions() + { + const string provider = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider] + internal record class CustomUnits + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot))] + private sealed class LengthUnit; + [UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)] + private sealed class Foot; + } + """; + const string consumer = """ + namespace Independent.Units; + public static class Consumer + { + public static double[] Values() => [Length.FromFeet(1).AsMetres()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(provider, consumer); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Equal(0.3048, Assert.Single(result), 12); + } + + [Theory] + [InlineData("Bad name", "Feet")] + [InlineData("Foot", "Bad names")] + public void InvalidUnitNamesReportDeclarationDiagnostics(string singular, string plural) + { + string source = $$""" + using DimensionalQuantities.Units.Declarations; + [UnitProvider(GeneratePrefixConversions = true)] + internal static class Units + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot))] + private sealed class LengthUnit; + [UnitSymbol("{{singular}}", "{{plural}}", "ft")] + private sealed class Foot; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Multiple( + () => Assert.Empty(compilation.GetDiagnostics(TestContext.Current.CancellationToken).Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)), + () => Assert.Equal("DQ0008", Assert.Single(diagnostics).Id)); + } + + [Fact] + public void ProvidersInSameNamespaceCanConvertSameQuantity() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider] + internal static class ImperialUnits + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot))] + private sealed class LengthUnit; + [UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)] + private sealed class Foot; + } + [UnitProvider] + internal static class SurveyUnits + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Yard))] + private sealed class LengthUnit; + [UnitSymbol("Yard", "Yards", "yd", Scale = 0.9144)] + private sealed class Yard; + } + public static class Consumer + { + public static double[] Values() => [Length.FromFeet(3).AsYards(), Length.FromYards(2).AsFeet()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + double[] result = Evaluate(compilation, "Independent.Units.Consumer"); + Assert.Multiple( + () => Assert.Equal(1, result[0], 12), + () => Assert.Equal(6, result[1], 12)); + } + + private static (Compilation Compilation, ImmutableArray Diagnostics) Generate(string provider, string consumer, params MetadataReference[] additionalReferences) + { + CSharpParseOptions options = new(LanguageVersion.Preview); + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)).Concat(additionalReferences); + CSharpCompilation input = CSharpCompilation.Create("ProviderTest" + Guid.NewGuid().ToString("N"), + [CSharpSyntaxTree.ParseText(provider, options), CSharpSyntaxTree.ParseText(consumer, options)], references, new(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: options); + driver.RunGeneratorsAndUpdateCompilation(input, out Compilation output, out ImmutableArray diagnostics, TestContext.Current.CancellationToken); + return (output, diagnostics); + } + + private static double[] Evaluate(Compilation compilation, string consumerType) + { + Assembly assembly = Assembly.Load(Emit(compilation)); + return (double[])assembly.GetType(consumerType)!.GetMethod("Values")!.Invoke(null, null)!; + } + + private static byte[] Emit(Compilation compilation) + { + using MemoryStream stream = new(); + EmitResult result = compilation.Emit(stream, cancellationToken: TestContext.Current.CancellationToken); + if (!result.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, result.Diagnostics)); + } + return stream.ToArray(); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs new file mode 100644 index 0000000..16de54d --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs @@ -0,0 +1,71 @@ +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using DimensionalQuantities.Numerics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Generators; + +public sealed class UnitProviderReaderTests +{ + [Fact] + public void InvalidPowerDenominatorReportsDiagnostic() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(Foot), PowerDenominator = 0)] + private sealed class LengthUnit; + [UnitSymbol("Foot", "Feet", "ft")] + private sealed class Foot; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + Assert.Multiple( + () => Assert.Empty(units), + () => Assert.Equal("DQ0008", Assert.Single(diagnostics).Id)); + } + + [Fact] + public void RationalFactorPowerAndScaleAreReadFromDeclarations() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(Length))] + [UnitFactor(typeof(SquareMetre), PowerDenominator = 2)] + private sealed class LengthUnit; + [UnitSymbol("SquareMetre", "SquareMetres", "m²", Scale = 4)] + private sealed class SquareMetre; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + UnitConversionSpec result = Assert.Single(units); + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Equal(new Rational(1, 2), result.Units[0].Expression.Terms[0].Power), + () => Assert.Equal(4, result.Units[0].Expression.Terms[0].Unit.Scale)); + } + + private static (IReadOnlyList Units, List Diagnostics) Read(string source) + { + CSharpParseOptions options = new(LanguageVersion.Preview); + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + string lengthType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: 1)); + source = $"global using Length = {lengthType};\n" + source; + CSharpCompilation input = CSharpCompilation.Create("UnitProviderReaderTest" + Guid.NewGuid().ToString("N"), + [CSharpSyntaxTree.ParseText(source, options)], references, new(OutputKind.DynamicallyLinkedLibrary)); + UnitProviderReader reader = new(input); + List diagnostics = []; + IReadOnlyList units = reader.Read(input.GetTypeByMetadataName("Units")!, diagnostics.Add); + return (units, diagnostics); + } +} diff --git a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md index ec46be7..f2d3fec 100644 --- a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md +++ b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md @@ -9,3 +9,4 @@ DQ0004 | DimensionalQuantities | Warning | QuantityEncodingAnalyzer DQ0005 | DimensionalQuantities | Error | QuantityEncodingAnalyzer DQ0006 | DimensionalQuantities | Warning | QuantityGenerator DQ0007 | DimensionalQuantities | Error | QuantityGenerator +DQ0008 | DimensionalQuantities | Error | QuantityGenerator diff --git a/src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs b/src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs deleted file mode 100644 index 3c39ee6..0000000 --- a/src/DimensionalQuantities.Roslyn/Generators/EngineeringAtomicUnits.cs +++ /dev/null @@ -1,19 +0,0 @@ -using DimensionalQuantities.Units; - -namespace DimensionalQuantities.Roslyn.Generators; - -internal static class EngineeringAtomicUnits -{ - public static readonly AtomicUnit Radian = new("Radian", "Radians", "rad"); - public static readonly AtomicUnit Hertz = new("Hertz", "Hertz", "Hz"); - public static readonly AtomicUnit Pascal = new("Pascal", "Pascals", "Pa"); - public static readonly AtomicUnit Joule = new("Joule", "Joules", "J"); - public static readonly AtomicUnit Newton = new("Newton", "Newtons", "N"); - public static readonly AtomicUnit Watt = new("Watt", "Watts", "W"); - public static readonly AtomicUnit Volt = new("Volt", "Volts", "V"); - public static readonly AtomicUnit Ohm = new("Ohm", "Ohms", "Ω"); - public static readonly AtomicUnit Henry = new("Henry", "Henries", "H"); - public static readonly AtomicUnit Weber = new("Weber", "Webers", "Wb"); - public static readonly AtomicUnit Tesla = new("Tesla", "Teslas", "T"); - public static readonly AtomicUnit Siemens = new("Siemens", "Siemens", "S"); -} diff --git a/src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs b/src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs deleted file mode 100644 index 18dc9d2..0000000 --- a/src/DimensionalQuantities.Roslyn/Generators/EngineeringCatalogue.cs +++ /dev/null @@ -1,54 +0,0 @@ -using DimensionalQuantities.Dimensions; -using DimensionalQuantities.Units; - -namespace DimensionalQuantities.Roslyn.Generators; - -/// Recognised names and units for engineering dimensions. -internal static class EngineeringCatalogue -{ - private static readonly UnitExpression _density = new(new(AtomicUnits.Gram, prefixExponent: SiPrefixes.Kilo), new(AtomicUnits.Metre, -3)); - private static readonly UnitExpression _specificPower = new(new(EngineeringAtomicUnits.Watt), new(AtomicUnits.Gram, -1, SiPrefixes.Kilo)); - private static readonly UnitExpression _powerDensity = new(new(EngineeringAtomicUnits.Watt), new(AtomicUnits.Metre, -3)); - private static readonly UnitExpression _magneticFieldStrength = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -1)); - private static readonly UnitExpression _currentDensity = new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, -2)); - - public static readonly Dictionary Entries = new() - { - { new(Length: 2), [new("Area", [new(AtomicUnits.Metre, 2)])] }, - { new(Length: 3), [new("Volume", [new(AtomicUnits.Metre, 3)])] }, - { new(Time: -1), [new("Frequency", [new(EngineeringAtomicUnits.Hertz)]), new("AngularVelocity", [new(EngineeringAtomicUnits.Radian), new(AtomicUnits.Second, -1)])] }, - { new(Length: -3, Mass: 1), [new("Density", _density, [new(new(AtomicUnits.Gram), new(AtomicUnits.Metre, power: -3, prefixExponent: SiPrefixes.Centi))])] }, - { - new(Length: -1, Mass: 1, Time: -2), - [ - new("Pressure", [new(EngineeringAtomicUnits.Pascal)]), - new("Stress", [new(EngineeringAtomicUnits.Pascal)]), - new("YoungsModulus", [new(EngineeringAtomicUnits.Pascal)]) - ] - }, - { new(Length: 2, Mass: 1, Time: -2), [new("Energy", [new(EngineeringAtomicUnits.Joule)]), new("Torque", [new(EngineeringAtomicUnits.Newton), new(AtomicUnits.Metre)])] }, - { new(Length: 2, Mass: 1, Time: -3), [new("Power", [new(EngineeringAtomicUnits.Watt)])] }, - { new(Length: 2, Time: -3), [new("SpecificPower", _specificPower), new("SpecificPowerLoss", _specificPower)] }, - { new(Temperature: -1), [new("TemperatureCoefficient", [new(AtomicUnits.Kelvin, -1)])] }, - { new(Length: -1, Mass: 1, Time: -3), [new("PowerDensity", _powerDensity), new("VolumetricPowerLoss", _powerDensity)] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -1), [new("Voltage", [new(EngineeringAtomicUnits.Volt)])] }, - { new(Length: 2, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistance", [new(EngineeringAtomicUnits.Ohm)])] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Inductance", [new(EngineeringAtomicUnits.Henry)])] }, - { new(Length: 2, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticFlux", [new(EngineeringAtomicUnits.Weber)]), new("FluxLinkage", [new(EngineeringAtomicUnits.Weber)])] }, - { - new(Mass: 1, Time: -2, ElectricCurrent: -1), - [ - new("MagneticFluxDensity", [new(EngineeringAtomicUnits.Tesla)]), - new("MagneticPolarisation", [new(EngineeringAtomicUnits.Tesla)]), - new("RemanentFluxDensity", [new(EngineeringAtomicUnits.Tesla)]) - ] - }, - { new(Length: -1, ElectricCurrent: 1), [new("MagneticFieldStrength", _magneticFieldStrength), new("CoerciveFieldStrength", _magneticFieldStrength)] }, - { new(Length: -2, ElectricCurrent: 1), [new("CurrentDensity", _currentDensity, [new(new(AtomicUnits.Amp), new(AtomicUnits.Metre, power: -2, prefixExponent: SiPrefixes.Milli))])] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -2), [new("Permeability", [new(EngineeringAtomicUnits.Henry), new(AtomicUnits.Metre, -1)])] }, - { new(Length: 3, Mass: 1, Time: -3, ElectricCurrent: -2), [new("Resistivity", [new(EngineeringAtomicUnits.Ohm), new(AtomicUnits.Metre)])] }, - { new(Length: -3, Mass: -1, Time: 3, ElectricCurrent: 2), [new("Conductivity", [new(EngineeringAtomicUnits.Siemens), new(AtomicUnits.Metre, -1)])] }, - { new(Length: -1, Mass: -1, Time: 2, ElectricCurrent: 2), [new("MagneticReluctivity", [new(AtomicUnits.Metre), new(EngineeringAtomicUnits.Henry, -1)])] }, - { new(Length: 1, Mass: 1, Time: -2, ElectricCurrent: -1), [new("MagneticVectorPotential", [new(EngineeringAtomicUnits.Weber), new(AtomicUnits.Metre, -1)])] }, - }; -} diff --git a/src/DimensionalQuantities.Roslyn/Generators/GenerationDiagnostics.cs b/src/DimensionalQuantities.Roslyn/Generators/GenerationDiagnostics.cs index e1c2d81..46a6953 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/GenerationDiagnostics.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/GenerationDiagnostics.cs @@ -16,6 +16,10 @@ internal static class GenerationDiagnostics "DQ0007", "Invalid English exponent", "English exponent '{0}' cannot be resolved", "DimensionalQuantities", DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor InvalidUnitProvider = new( + "DQ0008", "Invalid unit provider declaration", "{0}", + "DimensionalQuantities", DiagnosticSeverity.Error, isEnabledByDefault: true); + public static ImmutableArray FindInvalidEnglishExponents(CSharpCompilation compilation, ImmutableArray inputTrees, CancellationToken cancellationToken) { diff --git a/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs deleted file mode 100644 index 44e374c..0000000 --- a/src/DimensionalQuantities.Roslyn/Generators/MetricConversionSourceEmitter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Text; -using DimensionalQuantities.Dimensions; -using DimensionalQuantities.Roslyn.Discovery; -using DimensionalQuantities.Units; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace DimensionalQuantities.Roslyn.Generators; - -internal static class MetricConversionSourceEmitter -{ - public static void Register(IncrementalGeneratorInitializationContext context) - { - IncrementalValueProvider enabled = context.AnalyzerConfigOptionsProvider.Select(static (options, _) => - options.GlobalOptions.TryGetValue("build_property.GenerateMetricConversions", out string? value) - && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)); - context.RegisterSourceOutput(enabled, static (output, isEnabled) => - { - if (isEnabled) - { - output.AddSource("MetricConversions.g.cs", SourceText.From(Emit(), Encoding.UTF8)); - } - }); - } - - private static string Emit() - { - List quantities = []; - foreach (KeyValuePair entry in EngineeringCatalogue.Entries) - { - if (!EngineeringQuantityAliases.Entries.TryGetValue(entry.Key, out string[]? aliases) || - !aliases.SequenceEqual(entry.Value.Select(unit => unit.QuantityName))) - { - throw new InvalidOperationException($"Metric catalogue aliases differ from core for {entry.Key}."); - } - - quantities.Add(new(entry.Value[0].QuantityName, QuantityTypeNameFormatter.Format(entry.Key), - [.. entry.Value], true)); - } - - return "// \n" + UnitConversionEmitter.Emit(quantities, - "DimensionalQuantities.Extensions.Metric", "public"); - } -} diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 0f00122..0297600 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -13,7 +13,7 @@ public sealed class QuantityGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - MetricConversionSourceEmitter.Register(context); + IncrementalValuesProvider providers = UnitProviderSourceEmitter.FindProviders(context); QuantityGenerationPlan defaultPlan = DefaultQuantityDiscovery.Discover(); context.RegisterPostInitializationOutput(output => { @@ -28,18 +28,27 @@ public void Initialize(IncrementalGeneratorInitializationContext context) IncrementalValuesProvider inputTrees = context.SyntaxProvider.CreateSyntaxProvider( static (node, _) => node is CompilationUnitSyntax unit && IsDiscoveryInputTree(unit.SyntaxTree), static (syntaxContext, _) => syntaxContext.Node.SyntaxTree); - IncrementalValueProvider<(Compilation Left, ImmutableArray Right)> inputs = context.CompilationProvider.Combine(inputTrees.Collect()); + IncrementalValueProvider<((Compilation Left, ImmutableArray Right) Left, ImmutableArray Right)> inputs = + context.CompilationProvider.Combine(inputTrees.Collect()).Combine(providers.Collect()); context.RegisterSourceOutput(inputs, (output, input) => { - CSharpCompilation original = (CSharpCompilation)input.Left; - foreach (Diagnostic diagnostic in GenerationDiagnostics.FindInvalidEnglishExponents(original, input.Right, output.CancellationToken)) + CSharpCompilation original = (CSharpCompilation)input.Left.Left; + ImmutableArray sourceTrees = input.Left.Right; + ImmutableArray providerSources = UnitProviderSourceEmitter.Emit(original, input.Right, output.ReportDiagnostic); + foreach (GeneratedSource source in providerSources) + { + output.AddSource(source.HintName, SourceText.From(source.Text, Encoding.UTF8)); + } + + CSharpCompilation withProviders = AddSources(original, providerSources); + foreach (Diagnostic diagnostic in GenerationDiagnostics.FindInvalidEnglishExponents(original, sourceTrees, output.CancellationToken)) { output.ReportDiagnostic(diagnostic); } - QuantityDiscoveryResult result = QuantityDiscoveryCoordinator.Discover(original, - input.Right, defaultPlan, output.CancellationToken); - foreach (Diagnostic diagnostic in AliasCollisionDiagnostics.Find(original, result, input.Right, output.CancellationToken)) + QuantityDiscoveryResult result = QuantityDiscoveryCoordinator.Discover(withProviders, + sourceTrees, defaultPlan, output.CancellationToken); + foreach (Diagnostic diagnostic in AliasCollisionDiagnostics.Find(original, result, sourceTrees, output.CancellationToken)) { output.ReportDiagnostic(diagnostic); } @@ -51,6 +60,19 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + private static CSharpCompilation AddSources(CSharpCompilation compilation, ImmutableArray sources) + { + if (sources.IsEmpty) + { + return compilation; + } + + CSharpParseOptions options = compilation.SyntaxTrees.OfType() + .Select(tree => (CSharpParseOptions)tree.Options).FirstOrDefault() ?? CSharpParseOptions.Default; + SyntaxTree[] trees = sources.Select(source => CSharpSyntaxTree.ParseText(source.Text, options, path: source.HintName)).ToArray(); + return compilation.AddSyntaxTrees(trees); + } + private static bool IsDiscoveryInputTree(SyntaxTree tree) { string path = tree.FilePath.Replace('\\', '/'); diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs index 5a590f0..5fe4430 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs @@ -1,15 +1,22 @@ using System.Globalization; using DimensionalQuantities.Numerics; using DimensionalQuantities.Units; +using Microsoft.CodeAnalysis.CSharp; namespace DimensionalQuantities.Roslyn.Generators; internal static class UnitConversionEmitter { - public static string Emit(IReadOnlyList quantities, string namespaceName, string accessibility) + public static string Emit(IReadOnlyList quantities, string namespaceName, string accessibility, + string classNamePrefix = "") { int[] prefixes = SiPrefixes.Entries.Keys.OrderByDescending(exponent => exponent).ToArray(); - string extensions = string.Join("\n\n", quantities.Select(quantity => RenderQuantity(quantity, prefixes, accessibility))); + string extensions = string.Join("\n\n", quantities.Select(quantity => RenderQuantity(quantity, prefixes, accessibility, classNamePrefix))); + if (namespaceName.Length == 0) + { + return extensions; + } + return $""" namespace {namespaceName}; @@ -17,23 +24,34 @@ namespace {namespaceName}; """; } - private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes, string accessibility) + private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes, string accessibility, string classNamePrefix) { UnitExpression canonicalExpression = quantity.Units[0].Expression; ConversionDefinition generic = new("FromPrefixedUnits", "AsPrefixedUnits", - GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression)); + GetTermParameters(canonicalExpression), GetTermScaleExponent(canonicalExpression), + canonicalExpression.GetScale(), quantity.Units[0].Offset); ConversionDefinition[] named = quantity.IncludeNamed - ? CollectConversions(quantity.Units, prefixes) - .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], FormatExponent(conversion.Value))) + ? CollectConversions(quantity.Units, quantity.GenerateSiPrefixes ? prefixes : []) + .Select(conversion => new ConversionDefinition($"From{conversion.Key}", $"As{conversion.Key}", [], + FormatExponent(conversion.Value.Exponent), conversion.Value.Scale, conversion.Value.Offset)) .ToArray() : []; - ConversionDefinition[] conversions = [generic, .. named]; + ConversionDefinition[] conversions = quantity.GeneratePrefixConversions ? [generic, .. named] : named; string variableName = char.ToLowerInvariant(quantity.Name[0]) + quantity.Name[1..]; + string className = classNamePrefix + quantity.Name + "Extensions"; + if (!SyntaxFacts.IsValidIdentifier(className) || !SyntaxFacts.IsValidIdentifier(variableName) + || conversions.Any(conversion => !SyntaxFacts.IsValidIdentifier(conversion.FactoryName) + || !SyntaxFacts.IsValidIdentifier(conversion.AccessorName) + || conversion.PrefixParameters.Any(parameter => !SyntaxFacts.IsValidIdentifier(parameter.Name)))) + { + throw new InvalidOperationException($"Unit provider for {quantity.Name} produces an invalid C# identifier."); + } + string factories = string.Join("\n\t\t", conversions.Select(conversion => RenderFactory(quantity.ReceiverType, conversion))); string accessors = string.Join("\n\t\t", conversions.Select(conversion => RenderAccessor(variableName, conversion))); return $$""" - {{accessibility}} static class {{quantity.Name}}Extensions + {{accessibility}} static class {{className}} { extension({{quantity.ReceiverType}}) { @@ -48,42 +66,46 @@ private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes """; } - private static IReadOnlyDictionary CollectConversions(IReadOnlyList units, int[] prefixes) + private static IReadOnlyDictionary CollectConversions( + IReadOnlyList units, int[] generatedPrefixes) { - Dictionary conversions = new(StringComparer.Ordinal); - foreach ((string name, Rational scaleExponent) in units.SelectMany(unit => GetConversions(unit, prefixes))) + Dictionary conversions = new(StringComparer.Ordinal); + foreach ((string name, Rational exponent, double scale, double offset) in units.SelectMany(unit => GetConversions(unit, generatedPrefixes))) { - if (conversions.TryGetValue(name, out Rational existingExponent) && existingExponent != scaleExponent) + if (conversions.TryGetValue(name, out (Rational Exponent, double Scale, double Offset) existing) && existing != (exponent, scale, offset)) { throw new InvalidOperationException($"Unit {name} has conflicting SI scales."); } if (!conversions.ContainsKey(name)) { - conversions.Add(name, scaleExponent); + conversions.Add(name, (exponent, scale, offset)); } } return conversions; } - private static IEnumerable<(string Name, Rational ScaleExponent)> GetConversions(UnitDefinition unit, int[] prefixes) - => GetStandardConversions(unit.Expression, prefixes).Concat(unit.AdditionalConversions - .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent()))); + private static IEnumerable<(string Name, Rational Exponent, double Scale, double Offset)> GetConversions( + UnitDefinition unit, int[] generatedPrefixes) + => GetStandardConversions(unit, generatedPrefixes).Concat(unit.AdditionalConversions + .Select(expression => (expression.GetName(unit.Expression), expression.GetSiScaleExponent(), expression.GetScale(), unit.Offset))); - private static IEnumerable<(string Name, Rational ScaleExponent)> GetStandardConversions(UnitExpression expression, int[] prefixes) + private static IEnumerable<(string Name, Rational Exponent, double Scale, double Offset)> GetStandardConversions( + UnitDefinition unit, int[] generatedPrefixes) { + UnitExpression expression = unit.Expression; int firstNumerator = Array.FindIndex(expression.Terms, term => term.Power > Rational.Zero); - if (firstNumerator < 0) + if (firstNumerator < 0 || generatedPrefixes.Length == 0) { - yield return (expression.GetName(), expression.GetSiScaleExponent()); + yield return (expression.GetName(), expression.GetSiScaleExponent(), expression.GetScale(), unit.Offset); yield break; } - foreach (int prefix in prefixes.Prepend(0)) + foreach (int prefix in generatedPrefixes.Prepend(0)) { UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); - yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent()); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent(), prefixed.GetScale(), unit.Offset); } } @@ -117,18 +139,30 @@ private static string FormatTermScaleExponent(UnitTerm term) private static string RenderFactory(string quantityName, ConversionDefinition conversion) { string parameters = string.Concat(conversion.PrefixParameters.Select(parameter => $", int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); - return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; + if (conversion.Scale == 1 && conversion.Offset == 0) + { + return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromArbitrarySiComponents(value, overallScaleExponent: {conversion.ScaleExponentExpression});"; + } + + return $"public static {quantityName} {conversion.FactoryName}(double value{parameters}) => {quantityName}.FromSiBaseUnit(value * {FormatDouble(conversion.Scale)} * global::System.Math.Pow(10, {conversion.ScaleExponentExpression}) + {FormatDouble(conversion.Offset)});"; } private static string RenderAccessor(string variableName, ConversionDefinition conversion) { string parameters = string.Join(", ", conversion.PrefixParameters.Select(parameter => $"int {parameter.Name} = {parameter.DefaultExponent.ToString(CultureInfo.InvariantCulture)}")); - return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; + if (conversion.Scale == 1 && conversion.Offset == 0) + { + return $"public double {conversion.AccessorName}({parameters}) => {variableName}.AsArbitrarySiComponents(overallScaleExponent: {conversion.ScaleExponentExpression});"; + } + + return $"public double {conversion.AccessorName}({parameters}) => ({variableName}.AsSiBaseUnit() - {FormatDouble(conversion.Offset)}) / ({FormatDouble(conversion.Scale)} * global::System.Math.Pow(10, {conversion.ScaleExponentExpression}));"; } private static string FormatExponent(Rational exponent) => ((double)exponent).ToString("R", CultureInfo.InvariantCulture) + "d"; + private static string FormatDouble(double value) => value.ToString("R", CultureInfo.InvariantCulture) + "d"; - private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, string ScaleExponentExpression); + private sealed record ConversionDefinition(string FactoryName, string AccessorName, PrefixParameterDefinition[] PrefixParameters, + string ScaleExponentExpression, double Scale, double Offset); private sealed record PrefixParameterDefinition(string Name, int DefaultExponent); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs index 547561c..bd38557 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs @@ -3,4 +3,5 @@ namespace DimensionalQuantities.Roslyn.Generators; -internal sealed record UnitConversionSpec(string Name, string ReceiverType, ImmutableArray Units, bool IncludeNamed); +internal sealed record UnitConversionSpec(string Name, string ReceiverType, ImmutableArray Units, bool IncludeNamed, + bool GenerateSiPrefixes = true, bool GeneratePrefixConversions = true); diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs new file mode 100644 index 0000000..be8b652 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs @@ -0,0 +1,210 @@ +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.Units; +using DimensionalQuantities.Units.Declarations; +using Microsoft.CodeAnalysis; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal sealed class UnitProviderReader +{ + private readonly INamedTypeSymbol _providerAttribute; + private readonly INamedTypeSymbol _quantityAttribute; + private readonly INamedTypeSymbol _factorAttribute; + private readonly INamedTypeSymbol _symbolAttribute; + private readonly INamedTypeSymbol _additionalAttribute; + private readonly QuantityTypeReader _quantityReader; + + public UnitProviderReader(Compilation compilation) + { + _providerAttribute = compilation.GetRequiredType(typeof(UnitProviderAttribute)); + _quantityAttribute = compilation.GetRequiredType(typeof(QuantityUnitAttribute)); + _factorAttribute = compilation.GetRequiredType(typeof(UnitFactorAttribute)); + _symbolAttribute = compilation.GetRequiredType(typeof(UnitSymbolAttribute)); + _additionalAttribute = compilation.GetRequiredType(typeof(AdditionalUnitAttribute)); + _quantityReader = new(compilation); + } + + public IReadOnlyList Read(INamedTypeSymbol provider, Action report) + { + AttributeData? marker = Find(provider, _providerAttribute); + if (marker is null) + { + return []; + } + + bool siPrefixes = GetNamed(marker, nameof(UnitProviderAttribute.GenerateSiPrefixes), false); + bool prefixConversions = GetNamed(marker, nameof(UnitProviderAttribute.GeneratePrefixConversions), false); + List<(ITypeSymbol Type, string Name, List Units)> groups = []; + foreach (INamedTypeSymbol declaration in provider.GetTypeMembers()) + { + if (Find(declaration, _quantityAttribute) is not { } unitMarker + || !TryReadQuantity(declaration, unitMarker, provider, report, out INamedTypeSymbol? quantityType, out UnitDefinition unit)) + { + continue; + } + + int index = groups.FindIndex(group => SymbolEqualityComparer.Default.Equals(group.Type, quantityType)); + if (index < 0) + { + groups.Add((quantityType!, unit.QuantityName, [unit])); + } + else + { + groups[index].Units.Add(unit); + } + } + + return groups.Select(group => new UnitConversionSpec(group.Name, + group.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), [.. group.Units], true, + siPrefixes, prefixConversions)).ToArray(); + } + + private bool TryReadQuantity(INamedTypeSymbol declaration, AttributeData marker, INamedTypeSymbol provider, + Action report, out INamedTypeSymbol? quantityType, out UnitDefinition unit) + { + unit = default; + quantityType = marker.ConstructorArguments[0].Value as INamedTypeSymbol; + if (quantityType is null || !_quantityReader.TryRead(quantityType, out _)) + { + Report(report, marker, $"{declaration.Name} must name a valid Quantity."); + return false; + } + + if (!TryReadExpression(declaration, report, out UnitExpression expression)) + { + return false; + } + + double offset = GetNamed(marker, nameof(QuantityUnitAttribute.Offset), 0d); + if (!IsFinite(offset)) + { + Report(report, marker, $"{declaration.Name} has a non-finite offset."); + return false; + } + + if (!TryReadAlternatives(declaration, provider, report, out UnitExpression[] alternatives)) + { + return false; + } + + string name = declaration.Name.EndsWith("Unit", StringComparison.Ordinal) + ? declaration.Name[..^4] : declaration.Name; + if (name.Length == 0) + { + Report(report, marker, "A quantity unit declaration must have a name."); + return false; + } + + unit = new(name, expression, alternatives, offset); + return true; + } + + private bool TryReadAlternatives(INamedTypeSymbol declaration, INamedTypeSymbol provider, + Action report, out UnitExpression[] alternatives) + { + List results = []; + foreach (AttributeData addition in FindAll(declaration, _additionalAttribute)) + { + if (addition.ConstructorArguments[0].Value is not INamedTypeSymbol additionalType + || !SymbolEqualityComparer.Default.Equals(additionalType.ContainingType, provider)) + { + Report(report, addition, "An additional unit must be a nested type in this provider."); + alternatives = []; + return false; + } + + if (!TryReadExpression(additionalType, report, out UnitExpression expression)) + { + alternatives = []; + return false; + } + + results.Add(expression); + } + + alternatives = [.. results]; + return true; + } + + private bool TryReadExpression(INamedTypeSymbol declaration, Action report, out UnitExpression expression) + { + List terms = []; + foreach (AttributeData factor in FindAll(declaration, _factorAttribute)) + { + if (!TryReadFactor(factor, report, out UnitTerm term)) + { + expression = default; + return false; + } + + terms.Add(term); + } + + expression = new([.. terms]); + if (terms.Count > 0) + { + return true; + } + + Report(report, Find(declaration, _quantityAttribute) ?? declaration.GetAttributes().FirstOrDefault(), + $"{declaration.Name} must contain at least one unit factor."); + return false; + } + + private bool TryReadFactor(AttributeData factor, Action report, out UnitTerm term) + { + term = default; + if (factor.ConstructorArguments[0].Value is not INamedTypeSymbol symbolType + || Find(symbolType, _symbolAttribute) is not { } symbol) + { + Report(report, factor, "A unit factor must reference a type marked UnitSymbol."); + return false; + } + + int power = GetNamed(factor, nameof(UnitFactorAttribute.Power), 1); + int denominator = GetNamed(factor, nameof(UnitFactorAttribute.PowerDenominator), 1); + int prefix = GetNamed(factor, nameof(UnitFactorAttribute.PrefixExponent), 0); + int basePrefix = GetNamed(symbol, nameof(UnitSymbolAttribute.SiBasePrefixExponent), 0); + double scale = GetNamed(symbol, nameof(UnitSymbolAttribute.Scale), 1d); + string? singular = symbol.ConstructorArguments[0].Value as string; + string? plural = symbol.ConstructorArguments[1].Value as string; + string? glyph = symbol.ConstructorArguments[2].Value as string; + if (power == 0 || denominator <= 0 || (prefix != 0 && !SiPrefixes.Entries.ContainsKey(prefix)) + || (basePrefix != 0 && !SiPrefixes.Entries.ContainsKey(basePrefix)) + || !IsFinite(scale) || scale <= 0 + || string.IsNullOrEmpty(singular) || string.IsNullOrEmpty(plural) || string.IsNullOrEmpty(glyph)) + { + Report(report, factor, "A unit factor has an invalid power, prefix, symbol, or scale."); + return false; + } + + term = new(new AtomicUnit(singular!, plural!, glyph!, basePrefix, scale), new Rational(power, denominator), prefix); + return true; + } + + private static AttributeData? Find(ISymbol symbol, INamedTypeSymbol attributeType) => + FindAll(symbol, attributeType).FirstOrDefault(); + + private static IEnumerable FindAll(ISymbol symbol, INamedTypeSymbol attributeType) => symbol.GetAttributes() + .Where(attribute => SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType)); + + private static T GetNamed(AttributeData attribute, string name, T fallback) + { + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is T value) + { + return value; + } + } + + return fallback; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private static void Report(Action report, AttributeData? attribute, string message) => + report(Diagnostic.Create(GenerationDiagnostics.InvalidUnitProvider, + attribute?.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None, message)); +} diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs new file mode 100644 index 0000000..c2ea10f --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs @@ -0,0 +1,65 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Units.Declarations; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class UnitProviderSourceEmitter +{ + public static IncrementalValuesProvider FindProviders(IncrementalGeneratorInitializationContext context) + { + return context.SyntaxProvider.ForAttributeWithMetadataName( + typeof(UnitProviderAttribute).FullName!, + static (node, _) => node is TypeDeclarationSyntax, + static (syntaxContext, _) => (INamedTypeSymbol)syntaxContext.TargetSymbol); + } + + public static ImmutableArray Emit(Compilation compilation, ImmutableArray providers, + Action report) + { + ImmutableArray.Builder sources = ImmutableArray.CreateBuilder(); + HashSet seen = new(SymbolEqualityComparer.Default); + foreach (INamedTypeSymbol provider in providers) + { + if (!seen.Add(provider)) + { + continue; + } + + IReadOnlyList units = new UnitProviderReader(compilation).Read(provider, report); + if (units.Count == 0) + { + continue; + } + + try + { + string namespaceName = provider.ContainingNamespace.IsGlobalNamespace + ? string.Empty : provider.ContainingNamespace.ToDisplayString(); + string source = "// \n" + UnitConversionEmitter.Emit(units, namespaceName, "public", + ClassNamePrefix(provider)); + string hint = provider.ToDisplayString() + ".g.cs"; + sources.Add(new(hint, source)); + } + catch (InvalidOperationException exception) + { + report(Diagnostic.Create(GenerationDiagnostics.InvalidUnitProvider, + provider.Locations.FirstOrDefault() ?? Location.None, exception.Message)); + } + } + + return sources.ToImmutable(); + } + + private static string ClassNamePrefix(INamedTypeSymbol provider) + { + Stack types = new(); + for (INamedTypeSymbol? type = provider; type is not null; type = type.ContainingType) + { + types.Push(type); + } + + return string.Concat(types.Select(type => $"P{type.Name.Length}_{type.Name}A{type.Arity}_")); + } +} diff --git a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs index be6f977..b97d5b6 100644 --- a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs +++ b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs @@ -265,4 +265,38 @@ public void WithPrefixChangesOnlyTheSelectedTermAndPreservesOriginal() () => Assert.Equal(expected, result), () => Assert.Equal(expectedOriginal, original)); } + + [Fact] + public void GetScaleCombinesNonDecimalFactorsAndNegativePowers() + { + AtomicUnit foot = new("Foot", "Feet", "ft", Scale: 0.3048); + AtomicUnit minute = new("Minute", "Minutes", "min", Scale: 60); + UnitExpression original = new(new UnitTerm(foot, 2), new UnitTerm(minute, -1)); + double result = original.GetScale(); + double expected = 0.001548384; + + Assert.Equal(expected, result, 12); + } + + [Fact] + public void GetScaleAppliesFractionalPowers() + { + AtomicUnit unit = new("Sample", "Samples", "x", Scale: 16); + UnitExpression original = new(new UnitTerm(unit, new Rational(3, 2))); + double result = original.GetScale(); + double expected = 64; + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameAcceptsAReferenceWithDifferentFactors() + { + UnitExpression reference = new(new UnitTerm(AtomicUnits.Amp)); + UnitExpression original = new(new UnitTerm(AtomicUnits.Amp), new UnitTerm(AtomicUnits.Metre, -2)); + string result = original.GetName(reference); + string expected = "AmpsPerSquareMetre"; + + Assert.Equal(expected, result); + } } diff --git a/src/DimensionalQuantities/Units/AtomicUnit.cs b/src/DimensionalQuantities/Units/AtomicUnit.cs index 25ce1d6..a6d2892 100644 --- a/src/DimensionalQuantities/Units/AtomicUnit.cs +++ b/src/DimensionalQuantities/Units/AtomicUnit.cs @@ -1,3 +1,3 @@ namespace DimensionalQuantities.Units; -internal readonly record struct AtomicUnit(string NameSingular, string NamePlural, string Symbol, int SiBasePrefixExponent = 0); +internal readonly record struct AtomicUnit(string NameSingular, string NamePlural, string Symbol, int SiBasePrefixExponent = 0, double Scale = 1); diff --git a/src/DimensionalQuantities/Units/Declarations/AdditionalUnitAttribute.cs b/src/DimensionalQuantities/Units/Declarations/AdditionalUnitAttribute.cs new file mode 100644 index 0000000..cdb350b --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/AdditionalUnitAttribute.cs @@ -0,0 +1,10 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Adds a separately declared unit expression for a quantity. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class AdditionalUnitAttribute : Attribute +{ + public AdditionalUnitAttribute(Type unitType) => UnitType = unitType; + + public Type UnitType { get; } +} diff --git a/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs b/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs new file mode 100644 index 0000000..f9e1819 --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs @@ -0,0 +1,13 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Associates a unit declaration with a quantity type. +[AttributeUsage(AttributeTargets.Class)] +public sealed class QuantityUnitAttribute : Attribute +{ + public QuantityUnitAttribute(Type quantityType) => QuantityType = quantityType; + + public Type QuantityType { get; } + + /// Offset added after scaling a magnitude into coherent SI units. + public double Offset { get; set; } +} diff --git a/src/DimensionalQuantities/Units/Declarations/UnitFactorAttribute.cs b/src/DimensionalQuantities/Units/Declarations/UnitFactorAttribute.cs new file mode 100644 index 0000000..c2878b8 --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/UnitFactorAttribute.cs @@ -0,0 +1,13 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// One ordered factor of a declared unit expression. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class UnitFactorAttribute : Attribute +{ + public UnitFactorAttribute(Type symbolType) => SymbolType = symbolType; + + public Type SymbolType { get; } + public int Power { get; set; } = 1; + public int PowerDenominator { get; set; } = 1; + public int PrefixExponent { get; set; } +} diff --git a/src/DimensionalQuantities/Units/Declarations/UnitProviderAttribute.cs b/src/DimensionalQuantities/Units/Declarations/UnitProviderAttribute.cs new file mode 100644 index 0000000..1dda005 --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/UnitProviderAttribute.cs @@ -0,0 +1,12 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Marks a type whose nested declarations define conversions for this assembly. +[AttributeUsage(AttributeTargets.Class)] +public sealed class UnitProviderAttribute : Attribute +{ + /// Generate named conversions for all supported SI prefixes. + public bool GenerateSiPrefixes { get; set; } + + /// Generate parameterised prefix conversions. + public bool GeneratePrefixConversions { get; set; } +} diff --git a/src/DimensionalQuantities/Units/Declarations/UnitSymbolAttribute.cs b/src/DimensionalQuantities/Units/Declarations/UnitSymbolAttribute.cs new file mode 100644 index 0000000..2e83c6b --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/UnitSymbolAttribute.cs @@ -0,0 +1,19 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Defines the spelling and SI scale of an atomic unit. +[AttributeUsage(AttributeTargets.Class)] +public sealed class UnitSymbolAttribute : Attribute +{ + public UnitSymbolAttribute(string singular, string plural, string symbol) + { + Singular = singular; + Plural = plural; + Symbol = symbol; + } + + public string Singular { get; } + public string Plural { get; } + public string Symbol { get; } + public int SiBasePrefixExponent { get; set; } + public double Scale { get; set; } = 1; +} diff --git a/src/DimensionalQuantities/Units/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs index b8d7454..d7d3a54 100644 --- a/src/DimensionalQuantities/Units/UnitDefinition.cs +++ b/src/DimensionalQuantities/Units/UnitDefinition.cs @@ -2,11 +2,12 @@ namespace DimensionalQuantities.Units; internal readonly record struct UnitDefinition { - public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null) + public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null, double offset = 0) { QuantityName = quantityName; Expression = expression; AdditionalConversions = additionalConversions ?? []; + Offset = offset; } public UnitDefinition(string quantityName, UnitTerm[] terms) : this(quantityName, new UnitExpression(terms)) { } @@ -14,4 +15,5 @@ public UnitDefinition(string quantityName, UnitExpression expression, UnitExpres public string QuantityName { get; } public UnitExpression Expression { get; } public UnitExpression[] AdditionalConversions { get; } + public double Offset { get; } } diff --git a/src/DimensionalQuantities/Units/UnitExpression.cs b/src/DimensionalQuantities/Units/UnitExpression.cs index a7710cd..55a9494 100644 --- a/src/DimensionalQuantities/Units/UnitExpression.cs +++ b/src/DimensionalQuantities/Units/UnitExpression.cs @@ -32,6 +32,8 @@ public Rational GetSiScaleExponent() return exponent; } + public double GetScale() => Terms.Aggregate(1d, (scale, term) => scale * Math.Pow(term.Unit.Scale, (double)term.Power)); + public string GetName(UnitExpression? reference = null, int variableFactorIndex = -1) { StringBuilder numerator = new(); @@ -48,7 +50,8 @@ public string GetName(UnitExpression? reference = null, int variableFactorIndex AppendRootName(destination, power); AppendPowerName(destination, power.Numerator); bool generatedPrefix = index == variableFactorIndex || - (reference is { } original && term.PrefixExponent != original.Terms[index].PrefixExponent); + (reference is { } original && original.Terms.Length == Terms.Length + && term.PrefixExponent != original.Terms[index].PrefixExponent); AppendUnitName(destination, term, index == lastNumerator, generatedPrefix); } From ca8fd70d5da058921f41265ca751da4b009976d1 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 23:34:40 +0100 Subject: [PATCH 4/6] refactor: share SI unit symbols from core --- .../MetricUnits.cs | 11 +--- .../Generators/UnitProviderGenerationTests.cs | 33 ++++++++++++ .../Units/AtomicUnits.cs | 21 +++++--- .../Units/SiUnitMetadata.cs | 53 +++++++++++++++++++ .../Units/Symbols/Amp.cs | 10 ++++ .../Units/Symbols/Candela.cs | 10 ++++ .../Units/Symbols/Gram.cs | 10 ++++ .../Units/Symbols/Kelvin.cs | 10 ++++ .../Units/Symbols/Metre.cs | 10 ++++ .../Units/Symbols/Mole.cs | 10 ++++ .../Units/Symbols/Second.cs | 10 ++++ 11 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 src/DimensionalQuantities/Units/SiUnitMetadata.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Amp.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Candela.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Gram.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Kelvin.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Metre.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Mole.cs create mode 100644 src/DimensionalQuantities/Units/Symbols/Second.cs diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs index 1c56bad..b261e9b 100644 --- a/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs +++ b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs @@ -1,20 +1,11 @@ using DimensionalQuantities.Units.Declarations; +using DimensionalQuantities.Units.Symbols; namespace DimensionalQuantities.Extensions.Metric; [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] internal static class MetricUnits { - [UnitSymbol("Metre", "Metres", "m")] - private sealed class Metre { } - [UnitSymbol("Gram", "Grams", "g", SiBasePrefixExponent = 3)] - private sealed class Gram { } - [UnitSymbol("Second", "Seconds", "s")] - private sealed class Second { } - [UnitSymbol("Amp", "Amps", "A")] - private sealed class Amp { } - [UnitSymbol("Kelvin", "Kelvin", "K")] - private sealed class Kelvin { } [UnitSymbol("Radian", "Radians", "rad")] private sealed class Radian { } [UnitSymbol("Hertz", "Hertz", "Hz")] diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs index 067120d..7cbe998 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs @@ -130,6 +130,39 @@ public static double[] Values() => [CurrentDensity.FromAmpsPerSquareMilliMetre(1 Assert.Equal([1000000d, 2000000d, 1d], result); } + [Fact] + public void ProviderUsesCoreSiSymbolsAcrossAssemblyBoundary() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + using DimensionalQuantities.Units.Symbols; + namespace Independent.Units; + [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] + internal static class CompoundUnits + { + [QuantityUnit(typeof(CurrentDensity))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -2)] + private sealed class CurrentDensityUnit; + [QuantityUnit(typeof(Density))] + [UnitFactor(typeof(Gram), PrefixExponent = 3)] + [UnitFactor(typeof(Metre), Power = -3)] + private sealed class DensityUnit; + } + public static class Consumer + { + public static double[] Values() => + [CurrentDensity.FromAmpsPerSquareMetre(2).AsSiBaseUnit(), + CurrentDensity.FromPrefixedUnits(2, ampPrefixExponent: 0, metrePrefixExponent: -3).AsSiBaseUnit(), + Density.FromGramsPerCubicMetre(2).AsSiBaseUnit(), + Density.FromPrefixedUnits(2, gramPrefixExponent: 0, metrePrefixExponent: 0).AsSiBaseUnit()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + Assert.Equal([2d, 2000000d, 0.002d, 0.002d], Evaluate(compilation, "Independent.Units.Consumer")); + } + [Fact] public void AffineConversionsApplyScaleAndOffsetInBothDirections() { diff --git a/src/DimensionalQuantities/Units/AtomicUnits.cs b/src/DimensionalQuantities/Units/AtomicUnits.cs index 361a06f..c13bb71 100644 --- a/src/DimensionalQuantities/Units/AtomicUnits.cs +++ b/src/DimensionalQuantities/Units/AtomicUnits.cs @@ -2,11 +2,18 @@ namespace DimensionalQuantities.Units; internal static class AtomicUnits { - public static readonly AtomicUnit Metre = new("Metre", "Metres", "m", SiBasePrefixExponents.Length); - public static readonly AtomicUnit Gram = new("Gram", "Grams", "g", SiBasePrefixExponents.Mass); - public static readonly AtomicUnit Second = new("Second", "Seconds", "s", SiBasePrefixExponents.Time); - public static readonly AtomicUnit Amp = new("Amp", "Amps", "A", SiBasePrefixExponents.ElectricCurrent); - public static readonly AtomicUnit Kelvin = new("Kelvin", "Kelvin", "K", SiBasePrefixExponents.Temperature); - public static readonly AtomicUnit Mole = new("Mole", "Moles", "mol", SiBasePrefixExponents.AmountOfSubstance); - public static readonly AtomicUnit Candela = new("Candela", "Candelas", "cd", SiBasePrefixExponents.LuminousIntensity); + public static readonly AtomicUnit Metre = new(SiUnitMetadata.Metre.Singular, SiUnitMetadata.Metre.Plural, + SiUnitMetadata.Metre.Symbol, SiBasePrefixExponents.Length); + public static readonly AtomicUnit Gram = new(SiUnitMetadata.Gram.Singular, SiUnitMetadata.Gram.Plural, + SiUnitMetadata.Gram.Symbol, SiBasePrefixExponents.Mass); + public static readonly AtomicUnit Second = new(SiUnitMetadata.Second.Singular, SiUnitMetadata.Second.Plural, + SiUnitMetadata.Second.Symbol, SiBasePrefixExponents.Time); + public static readonly AtomicUnit Amp = new(SiUnitMetadata.Amp.Singular, SiUnitMetadata.Amp.Plural, + SiUnitMetadata.Amp.Symbol, SiBasePrefixExponents.ElectricCurrent); + public static readonly AtomicUnit Kelvin = new(SiUnitMetadata.Kelvin.Singular, SiUnitMetadata.Kelvin.Plural, + SiUnitMetadata.Kelvin.Symbol, SiBasePrefixExponents.Temperature); + public static readonly AtomicUnit Mole = new(SiUnitMetadata.Mole.Singular, SiUnitMetadata.Mole.Plural, + SiUnitMetadata.Mole.Symbol, SiBasePrefixExponents.AmountOfSubstance); + public static readonly AtomicUnit Candela = new(SiUnitMetadata.Candela.Singular, SiUnitMetadata.Candela.Plural, + SiUnitMetadata.Candela.Symbol, SiBasePrefixExponents.LuminousIntensity); } diff --git a/src/DimensionalQuantities/Units/SiUnitMetadata.cs b/src/DimensionalQuantities/Units/SiUnitMetadata.cs new file mode 100644 index 0000000..4acfc39 --- /dev/null +++ b/src/DimensionalQuantities/Units/SiUnitMetadata.cs @@ -0,0 +1,53 @@ +namespace DimensionalQuantities.Units; + +internal static class SiUnitMetadata +{ + internal static class Metre + { + public const string Singular = "Metre"; + public const string Plural = "Metres"; + public const string Symbol = "m"; + } + + internal static class Gram + { + public const string Singular = "Gram"; + public const string Plural = "Grams"; + public const string Symbol = "g"; + } + + internal static class Second + { + public const string Singular = "Second"; + public const string Plural = "Seconds"; + public const string Symbol = "s"; + } + + internal static class Amp + { + public const string Singular = "Amp"; + public const string Plural = "Amps"; + public const string Symbol = "A"; + } + + internal static class Kelvin + { + public const string Singular = "Kelvin"; + public const string Plural = "Kelvin"; + public const string Symbol = "K"; + } + + internal static class Mole + { + public const string Singular = "Mole"; + public const string Plural = "Moles"; + public const string Symbol = "mol"; + } + + internal static class Candela + { + public const string Singular = "Candela"; + public const string Plural = "Candelas"; + public const string Symbol = "cd"; + } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Amp.cs b/src/DimensionalQuantities/Units/Symbols/Amp.cs new file mode 100644 index 0000000..b268b88 --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Amp.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI amp symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Amp.Singular, SiUnitMetadata.Amp.Plural, SiUnitMetadata.Amp.Symbol)] +public sealed class Amp +{ + private Amp() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Candela.cs b/src/DimensionalQuantities/Units/Symbols/Candela.cs new file mode 100644 index 0000000..3b80c61 --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Candela.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI candela symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Candela.Singular, SiUnitMetadata.Candela.Plural, SiUnitMetadata.Candela.Symbol)] +public sealed class Candela +{ + private Candela() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Gram.cs b/src/DimensionalQuantities/Units/Symbols/Gram.cs new file mode 100644 index 0000000..cc799df --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Gram.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The gram symbol for unit-provider declarations; coherent SI mass uses a kilogram. +[UnitSymbol(SiUnitMetadata.Gram.Singular, SiUnitMetadata.Gram.Plural, SiUnitMetadata.Gram.Symbol, SiBasePrefixExponent = SiBasePrefixExponents.Mass)] +public sealed class Gram +{ + private Gram() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Kelvin.cs b/src/DimensionalQuantities/Units/Symbols/Kelvin.cs new file mode 100644 index 0000000..6445dc0 --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Kelvin.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI kelvin symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Kelvin.Singular, SiUnitMetadata.Kelvin.Plural, SiUnitMetadata.Kelvin.Symbol)] +public sealed class Kelvin +{ + private Kelvin() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Metre.cs b/src/DimensionalQuantities/Units/Symbols/Metre.cs new file mode 100644 index 0000000..af7e6d5 --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Metre.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI metre symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Metre.Singular, SiUnitMetadata.Metre.Plural, SiUnitMetadata.Metre.Symbol)] +public sealed class Metre +{ + private Metre() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Mole.cs b/src/DimensionalQuantities/Units/Symbols/Mole.cs new file mode 100644 index 0000000..4891e01 --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Mole.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI mole symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Mole.Singular, SiUnitMetadata.Mole.Plural, SiUnitMetadata.Mole.Symbol)] +public sealed class Mole +{ + private Mole() { } +} diff --git a/src/DimensionalQuantities/Units/Symbols/Second.cs b/src/DimensionalQuantities/Units/Symbols/Second.cs new file mode 100644 index 0000000..f2ce24d --- /dev/null +++ b/src/DimensionalQuantities/Units/Symbols/Second.cs @@ -0,0 +1,10 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Units.Symbols; + +/// The SI second symbol for unit-provider declarations. +[UnitSymbol(SiUnitMetadata.Second.Singular, SiUnitMetadata.Second.Plural, SiUnitMetadata.Second.Symbol)] +public sealed class Second +{ + private Second() { } +} From 9c7e5bcb249ceeea26d327d519b20d52c150d34a Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sun, 25 Oct 2026 00:07:43 +0100 Subject: [PATCH 5/6] build: keep project settings in their project files --- ...nsionalQuantities.Extensions.Metric.csproj | 6 ++ ...ensionalQuantities.Roslyn.CodeFixes.csproj | 3 + .../DimensionalQuantities.Roslyn.csproj | 3 + .../DimensionalQuantities.csproj | 87 ++++++++++--------- src/Directory.Build.props | 13 --- 5 files changed, 57 insertions(+), 55 deletions(-) diff --git a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj index 13efde5..d52a031 100644 --- a/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj +++ b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj @@ -9,6 +9,12 @@ git true $(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/generated + true + snupkg + true + true + true + portable diff --git a/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj b/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj index 7df797a..273655f 100644 --- a/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj +++ b/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj @@ -3,6 +3,9 @@ netstandard2.0 true false + true + true + portable diff --git a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj index cfdb6db..d5a41c0 100644 --- a/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj +++ b/src/DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj @@ -4,6 +4,9 @@ true true false + true + true + portable diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index f08dc49..7cc0794 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -1,44 +1,47 @@ - - netstandard2.0 - GoSim - MIT - README.md - https://github.com/GoSimulate/DimensionalQuantities - Compile-time dimensional quantities with generated aliases and arithmetic. - git - - - - - - - - - - - - - - - - - - - - - - - Rational.cs - - - Rational.cs - - + + netstandard2.0 + GoSim + MIT + README.md + https://github.com/GoSimulate/DimensionalQuantities + Compile-time dimensional quantities with generated aliases and arithmetic. + git + true + snupkg + true + true + true + portable + + + + + + + + + + + + + + + + + + + + + + + Rational.cs + + + Rational.cs + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ff91584..e72dff1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,17 +4,4 @@ enable enable - - true - snupkg - true - true - true - portable - - - true - true - portable - From 02ec7b8f62b6e3180682a606b6e140ba0302c4a6 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sun, 25 Oct 2026 00:07:43 +0100 Subject: [PATCH 6/6] refactor: clarify unit declarations and generation responsibilities --- README.md | 20 +++- .../MetricConversionsTests.cs | 107 +++++++++++++----- .../MetricSymbols.cs | 31 +++++ .../MetricUnits.cs | 92 ++++----------- .../Generators/QuantityDiscoveryTests.cs | 2 +- ...cs => UnitConversionSourceEmitterTests.cs} | 6 +- .../Generators/UnitProviderGenerationTests.cs | 30 ++++- .../Generators/UnitProviderReaderTests.cs | 56 ++++++++- .../Discovery/CompilationExtensions.cs | 16 +++ .../Discovery/PlannedQuantity.cs | 2 +- .../Discovery/QuantityDiscoveryCoordinator.cs | 12 +- .../Generators/QuantityGenerator.cs | 19 +--- .../Generators/QuantitySourceEmitter.cs | 2 +- ...tter.cs => UnitConversionSourceEmitter.cs} | 8 +- .../Generators/UnitConversionSpec.cs | 4 +- ...s => UnitProviderGenerationCoordinator.cs} | 6 +- .../Generators/UnitProviderReader.cs | 62 +++++++--- .../Naming/DimensionNameGeneratorTests.cs | 84 -------------- .../Units/UnitExpressionTests.cs | 25 ++++ .../Naming/DimensionNameGenerator.cs | 10 +- .../Naming/DimensionNameParser.cs | 2 +- .../Declarations/QuantityUnitAttribute.cs | 5 +- .../Declarations/UnitConversionAttribute.cs | 9 ++ .../Units/UnitDefinition.cs | 8 +- 24 files changed, 356 insertions(+), 262 deletions(-) create mode 100644 src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs rename src/DimensionalQuantities.Roslyn.Tests/Generators/{UnitConversionEmitterTests.cs => UnitConversionSourceEmitterTests.cs} (95%) rename src/DimensionalQuantities.Roslyn/Generators/{UnitConversionEmitter.cs => UnitConversionSourceEmitter.cs} (96%) rename src/DimensionalQuantities.Roslyn/Generators/{UnitProviderSourceEmitter.cs => UnitProviderGenerationCoordinator.cs} (86%) create mode 100644 src/DimensionalQuantities/Units/Declarations/UnitConversionAttribute.cs diff --git a/README.md b/README.md index 841c292..ff59b85 100644 --- a/README.md +++ b/README.md @@ -189,17 +189,29 @@ namespace Example.Units; internal static class CustomUnits { [QuantityUnit(typeof(Length))] - [UnitFactor(typeof(Foot))] + [UnitFactor(typeof(CustomSymbols.Foot))] private sealed class LengthUnit; + [QuantityUnit(typeof(Pressure))] + [QuantityUnit(typeof(Stress))] + [QuantityUnit(typeof(YoungsModulus))] + [UnitFactor(typeof(CustomSymbols.Pascal))] + private sealed class PascalUnit; +} + +internal static class CustomSymbols +{ [UnitSymbol("Foot", "Feet", "ft", Scale = 0.3048)] - private sealed class Foot; + internal sealed class Foot; + + [UnitSymbol("Pascal", "Pascals", "Pa")] + internal sealed class Pascal; } ``` -Consumers can then use `Length.FromFeet(6)` and `length.AsFeet()` with `using Example.Units;`. +Consumers can then use `Length.FromFeet(6)` and `length.AsFeet()` with `using Example.Units;`. The Pascal declaration associates the same unit with Pressure, Stress and Young’s modulus; their shared underlying type receives one set of conversion helpers. -`Scale` converts each atomic unit to SI. Ordered `UnitFactor` attributes describe compound units, with `Power`, `PowerDenominator` and `PrefixExponent` controlling each factor. `QuantityUnit.Offset` is added after scaling into SI and subtracted before converting back. A provider can opt into named SI prefixes and parameterised prefix conversions with `GenerateSiPrefixes` and `GeneratePrefixConversions`; both default to false. `AdditionalUnit` references another factor declaration for an alternative conversion. +`Scale` converts each atomic unit to SI. Ordered `UnitFactor` attributes describe compound units, with `Power`, `PowerDenominator` and `PrefixExponent` controlling each factor. An optional `[UnitConversion(Offset = ...)]` on the unit declaration supplies an offset added after scaling into SI and subtracted before converting back; it applies to every associated quantity. A provider can opt into named SI prefixes and parameterised prefix conversions with `GenerateSiPrefixes` and `GeneratePrefixConversions`; both default to false. `AdditionalUnit` references another factor declaration for an alternative conversion. ### Current scope diff --git a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs index 148aea2..651797b 100644 --- a/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using DimensionalQuantities.Extensions.Metric; using Xunit; @@ -6,37 +7,73 @@ namespace DimensionalQuantities.Extensions.Metric.Tests; public sealed class MetricConversionsTests { [Fact] - public void CatalogueFactoriesAndAccessorsCoverEngineeringDimensions() + public void CatalogueFactoriesConvertToExpectedSiValues() { double[] result = [ - Area.FromSquareMetres(1).AsSquareMetres(), - Volume.FromCubicMetres(1).AsCubicMetres(), - Frequency.FromHertz(1).AsHertz(), - AngularVelocity.FromRadiansPerSecond(1).AsRadiansPerSecond(), - Density.FromGramsPerCubicMetre(1).AsGramsPerCubicMetre(), - Pressure.FromPascals(1).AsPascals(), - Energy.FromJoules(1).AsJoules(), - Torque.FromNewtonMetres(1).AsNewtonMetres(), - Power.FromWatts(1).AsWatts(), - SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram(), - TemperatureCoefficient.FromInverseKelvin(1).AsInverseKelvin(), - PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(), - Voltage.FromVolts(1).AsVolts(), - Resistance.FromOhms(1).AsOhms(), - Inductance.FromHenries(1).AsHenries(), - MagneticFlux.FromWebers(1).AsWebers(), - MagneticFluxDensity.FromTeslas(1).AsTeslas(), - MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre(), - CurrentDensity.FromAmpsPerSquareMetre(1).AsAmpsPerSquareMetre(), - Permeability.FromHenriesPerMetre(1).AsHenriesPerMetre(), - Resistivity.FromOhmMetres(1).AsOhmMetres(), - Conductivity.FromSiemensPerMetre(1).AsSiemensPerMetre(), - MagneticReluctivity.FromMetresPerHenry(1).AsMetresPerHenry(), - MagneticVectorPotential.FromWebersPerMetre(1).AsWebersPerMetre(), + Area.FromSquareMetres(1).AsSiBaseUnit(), + Volume.FromCubicMetres(1).AsSiBaseUnit(), + Frequency.FromHertz(1).AsSiBaseUnit(), + AngularVelocity.FromRadiansPerSecond(1).AsSiBaseUnit(), + Density.FromGramsPerCubicMetre(1).AsSiBaseUnit(), + Pressure.FromPascals(1).AsSiBaseUnit(), + Energy.FromJoules(1).AsSiBaseUnit(), + Torque.FromNewtonMetres(1).AsSiBaseUnit(), + Power.FromWatts(1).AsSiBaseUnit(), + SpecificPower.FromWattsPerKilogram(1).AsSiBaseUnit(), + TemperatureCoefficient.FromInverseKelvin(1).AsSiBaseUnit(), + PowerDensity.FromWattsPerCubicMetre(1).AsSiBaseUnit(), + Voltage.FromVolts(1).AsSiBaseUnit(), + Resistance.FromOhms(1).AsSiBaseUnit(), + Inductance.FromHenries(1).AsSiBaseUnit(), + MagneticFlux.FromWebers(1).AsSiBaseUnit(), + MagneticFluxDensity.FromTeslas(1).AsSiBaseUnit(), + MagneticFieldStrength.FromAmpsPerMetre(1).AsSiBaseUnit(), + CurrentDensity.FromAmpsPerSquareMetre(1).AsSiBaseUnit(), + Permeability.FromHenriesPerMetre(1).AsSiBaseUnit(), + Resistivity.FromOhmMetres(1).AsSiBaseUnit(), + Conductivity.FromSiemensPerMetre(1).AsSiBaseUnit(), + MagneticReluctivity.FromMetresPerHenry(1).AsSiBaseUnit(), + MagneticVectorPotential.FromWebersPerMetre(1).AsSiBaseUnit(), ]; - Assert.Equal(Enumerable.Repeat(1d, 24), result); + double[] expected = [1d, 1d, 1d, 1d, 0.001d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d]; + Assert.Equal(expected, result); + } + + [Fact] + public void CatalogueAccessorsConvertFromKnownSiValues() + { + double[] result = + [ + Area.FromSiBaseUnit(1).AsSquareMetres(), + Volume.FromSiBaseUnit(1).AsCubicMetres(), + Frequency.FromSiBaseUnit(1).AsHertz(), + AngularVelocity.FromSiBaseUnit(1).AsRadiansPerSecond(), + Density.FromSiBaseUnit(1).AsGramsPerCubicMetre(), + Pressure.FromSiBaseUnit(1).AsPascals(), + Energy.FromSiBaseUnit(1).AsJoules(), + Torque.FromSiBaseUnit(1).AsNewtonMetres(), + Power.FromSiBaseUnit(1).AsWatts(), + SpecificPower.FromSiBaseUnit(1).AsWattsPerKilogram(), + TemperatureCoefficient.FromSiBaseUnit(1).AsInverseKelvin(), + PowerDensity.FromSiBaseUnit(1).AsWattsPerCubicMetre(), + Voltage.FromSiBaseUnit(1).AsVolts(), + Resistance.FromSiBaseUnit(1).AsOhms(), + Inductance.FromSiBaseUnit(1).AsHenries(), + MagneticFlux.FromSiBaseUnit(1).AsWebers(), + MagneticFluxDensity.FromSiBaseUnit(1).AsTeslas(), + MagneticFieldStrength.FromSiBaseUnit(1).AsAmpsPerMetre(), + CurrentDensity.FromSiBaseUnit(1).AsAmpsPerSquareMetre(), + Permeability.FromSiBaseUnit(1).AsHenriesPerMetre(), + Resistivity.FromSiBaseUnit(1).AsOhmMetres(), + Conductivity.FromSiBaseUnit(1).AsSiemensPerMetre(), + MagneticReluctivity.FromSiBaseUnit(1).AsMetresPerHenry(), + MagneticVectorPotential.FromSiBaseUnit(1).AsWebersPerMetre(), + ]; + + double[] expected = [1d, 1d, 1d, 1d, 1000d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d, 1d]; + Assert.Equal(expected, result); } [Fact] @@ -45,8 +82,20 @@ public void AliasesShareNamedConversionsWithoutDuplicateHelpers() Pressure pressure = Pressure.FromPascals(1); Stress stress = Stress.FromPascals(1); YoungsModulus modulus = YoungsModulus.FromKiloPascals(1); - double[] result = [pressure.AsPascals(), stress.AsPascals(), modulus.AsKiloPascals()]; - Assert.Equal([1d, 1d, 1d], result); + double[] result = + [ + pressure.AsPascals(), stress.AsPascals(), modulus.AsKiloPascals(), + SpecificPower.FromWattsPerKilogram(1).AsWattsPerKilogram(), + SpecificPowerLoss.FromWattsPerKilogram(1).AsWattsPerKilogram(), + PowerDensity.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(), + VolumetricPowerLoss.FromWattsPerCubicMetre(1).AsWattsPerCubicMetre(), + MagneticFlux.FromWebers(1).AsWebers(), FluxLinkage.FromWebers(1).AsWebers(), + MagneticFluxDensity.FromTeslas(1).AsTeslas(), MagneticPolarisation.FromTeslas(1).AsTeslas(), + RemanentFluxDensity.FromTeslas(1).AsTeslas(), + MagneticFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre(), + CoerciveFieldStrength.FromAmpsPerMetre(1).AsAmpsPerMetre() + ]; + Assert.Equal(Enumerable.Repeat(1d, result.Length), result); } [Fact] @@ -74,7 +123,7 @@ public void GenericPrefixConversionsApplyMetricScale() [Fact] public void PrefixesOnlyApplyToSupportedFactorPlacements() { - string[] names = typeof(P11_MetricUnitsA0_TemperatureCoefficientExtensions).Assembly.GetExportedTypes() + string[] names = Assembly.Load("DimensionalQuantities.Extensions.Metric").GetExportedTypes() .Where(type => type.Namespace == "DimensionalQuantities.Extensions.Metric") .SelectMany(type => type.GetMethods()).Select(method => method.Name).ToArray(); Assert.Multiple( diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs b/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs new file mode 100644 index 0000000..3a2813d --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/MetricSymbols.cs @@ -0,0 +1,31 @@ +using DimensionalQuantities.Units.Declarations; + +namespace DimensionalQuantities.Extensions.Metric; + +internal static class MetricSymbols +{ + [UnitSymbol("Radian", "Radians", "rad")] + internal sealed class Radian { } + [UnitSymbol("Hertz", "Hertz", "Hz")] + internal sealed class Hertz { } + [UnitSymbol("Pascal", "Pascals", "Pa")] + internal sealed class Pascal { } + [UnitSymbol("Joule", "Joules", "J")] + internal sealed class Joule { } + [UnitSymbol("Newton", "Newtons", "N")] + internal sealed class Newton { } + [UnitSymbol("Watt", "Watts", "W")] + internal sealed class Watt { } + [UnitSymbol("Volt", "Volts", "V")] + internal sealed class Volt { } + [UnitSymbol("Ohm", "Ohms", "Ω")] + internal sealed class Ohm { } + [UnitSymbol("Henry", "Henries", "H")] + internal sealed class Henry { } + [UnitSymbol("Weber", "Webers", "Wb")] + internal sealed class Weber { } + [UnitSymbol("Tesla", "Teslas", "T")] + internal sealed class Tesla { } + [UnitSymbol("Siemens", "Siemens", "S")] + internal sealed class Siemens { } +} diff --git a/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs index b261e9b..b57291b 100644 --- a/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs +++ b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs @@ -6,31 +6,6 @@ namespace DimensionalQuantities.Extensions.Metric; [UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] internal static class MetricUnits { - [UnitSymbol("Radian", "Radians", "rad")] - private sealed class Radian { } - [UnitSymbol("Hertz", "Hertz", "Hz")] - private sealed class Hertz { } - [UnitSymbol("Pascal", "Pascals", "Pa")] - private sealed class Pascal { } - [UnitSymbol("Joule", "Joules", "J")] - private sealed class Joule { } - [UnitSymbol("Newton", "Newtons", "N")] - private sealed class Newton { } - [UnitSymbol("Watt", "Watts", "W")] - private sealed class Watt { } - [UnitSymbol("Volt", "Volts", "V")] - private sealed class Volt { } - [UnitSymbol("Ohm", "Ohms", "Ω")] - private sealed class Ohm { } - [UnitSymbol("Henry", "Henries", "H")] - private sealed class Henry { } - [UnitSymbol("Weber", "Webers", "Wb")] - private sealed class Weber { } - [UnitSymbol("Tesla", "Teslas", "T")] - private sealed class Tesla { } - [UnitSymbol("Siemens", "Siemens", "S")] - private sealed class Siemens { } - [QuantityUnit(typeof(Area))] [UnitFactor(typeof(Metre), Power = 2)] private sealed class AreaUnit { } @@ -38,10 +13,10 @@ private sealed class AreaUnit { } [UnitFactor(typeof(Metre), Power = 3)] private sealed class VolumeUnit { } [QuantityUnit(typeof(Frequency))] - [UnitFactor(typeof(Hertz))] + [UnitFactor(typeof(MetricSymbols.Hertz))] private sealed class FrequencyUnit { } [QuantityUnit(typeof(AngularVelocity))] - [UnitFactor(typeof(Radian))] + [UnitFactor(typeof(MetricSymbols.Radian))] [UnitFactor(typeof(Second), Power = -1)] private sealed class AngularVelocityUnit { } [QuantityUnit(typeof(Density))] @@ -53,75 +28,56 @@ private sealed class DensityUnit { } [UnitFactor(typeof(Metre), Power = -3, PrefixExponent = -2)] private sealed class DensityCgs { } [QuantityUnit(typeof(Pressure))] - [UnitFactor(typeof(Pascal))] - private sealed class PressureUnit { } [QuantityUnit(typeof(Stress))] - [UnitFactor(typeof(Pascal))] - private sealed class StressUnit { } [QuantityUnit(typeof(YoungsModulus))] - [UnitFactor(typeof(Pascal))] - private sealed class YoungsModulusUnit { } + [UnitFactor(typeof(MetricSymbols.Pascal))] + private sealed class PascalUnit { } [QuantityUnit(typeof(Energy))] - [UnitFactor(typeof(Joule))] + [UnitFactor(typeof(MetricSymbols.Joule))] private sealed class EnergyUnit { } [QuantityUnit(typeof(Torque))] - [UnitFactor(typeof(Newton))] + [UnitFactor(typeof(MetricSymbols.Newton))] [UnitFactor(typeof(Metre))] private sealed class TorqueUnit { } [QuantityUnit(typeof(Power))] - [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(MetricSymbols.Watt))] private sealed class PowerUnit { } [QuantityUnit(typeof(SpecificPower))] - [UnitFactor(typeof(Watt))] - [UnitFactor(typeof(Gram), Power = -1, PrefixExponent = 3)] - private sealed class SpecificPowerUnit { } [QuantityUnit(typeof(SpecificPowerLoss))] - [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(MetricSymbols.Watt))] [UnitFactor(typeof(Gram), Power = -1, PrefixExponent = 3)] - private sealed class SpecificPowerLossUnit { } + private sealed class SpecificPowerUnit { } [QuantityUnit(typeof(TemperatureCoefficient))] [UnitFactor(typeof(Kelvin), Power = -1)] private sealed class TemperatureCoefficientUnit { } [QuantityUnit(typeof(PowerDensity))] - [UnitFactor(typeof(Watt))] - [UnitFactor(typeof(Metre), Power = -3)] - private sealed class PowerDensityUnit { } [QuantityUnit(typeof(VolumetricPowerLoss))] - [UnitFactor(typeof(Watt))] + [UnitFactor(typeof(MetricSymbols.Watt))] [UnitFactor(typeof(Metre), Power = -3)] - private sealed class VolumetricPowerLossUnit { } + private sealed class PowerDensityUnit { } [QuantityUnit(typeof(Voltage))] - [UnitFactor(typeof(Volt))] + [UnitFactor(typeof(MetricSymbols.Volt))] private sealed class VoltageUnit { } [QuantityUnit(typeof(Resistance))] - [UnitFactor(typeof(Ohm))] + [UnitFactor(typeof(MetricSymbols.Ohm))] private sealed class ResistanceUnit { } [QuantityUnit(typeof(Inductance))] - [UnitFactor(typeof(Henry))] + [UnitFactor(typeof(MetricSymbols.Henry))] private sealed class InductanceUnit { } [QuantityUnit(typeof(MagneticFlux))] - [UnitFactor(typeof(Weber))] - private sealed class MagneticFluxUnit { } [QuantityUnit(typeof(FluxLinkage))] - [UnitFactor(typeof(Weber))] - private sealed class FluxLinkageUnit { } + [UnitFactor(typeof(MetricSymbols.Weber))] + private sealed class MagneticFluxUnit { } [QuantityUnit(typeof(MagneticFluxDensity))] - [UnitFactor(typeof(Tesla))] - private sealed class MagneticFluxDensityUnit { } [QuantityUnit(typeof(MagneticPolarisation))] - [UnitFactor(typeof(Tesla))] - private sealed class MagneticPolarisationUnit { } [QuantityUnit(typeof(RemanentFluxDensity))] - [UnitFactor(typeof(Tesla))] - private sealed class RemanentFluxDensityUnit { } + [UnitFactor(typeof(MetricSymbols.Tesla))] + private sealed class MagneticFluxDensityUnit { } [QuantityUnit(typeof(MagneticFieldStrength))] - [UnitFactor(typeof(Amp))] - [UnitFactor(typeof(Metre), Power = -1)] - private sealed class MagneticFieldStrengthUnit { } [QuantityUnit(typeof(CoerciveFieldStrength))] [UnitFactor(typeof(Amp))] [UnitFactor(typeof(Metre), Power = -1)] - private sealed class CoerciveFieldStrengthUnit { } + private sealed class MagneticFieldStrengthUnit { } [QuantityUnit(typeof(CurrentDensity))] [UnitFactor(typeof(Amp))] [UnitFactor(typeof(Metre), Power = -2)] @@ -131,23 +87,23 @@ private sealed class CurrentDensityUnit { } [UnitFactor(typeof(Metre), Power = -2, PrefixExponent = -3)] private sealed class CurrentDensityPerSquareMillimetre { } [QuantityUnit(typeof(Permeability))] - [UnitFactor(typeof(Henry))] + [UnitFactor(typeof(MetricSymbols.Henry))] [UnitFactor(typeof(Metre), Power = -1)] private sealed class PermeabilityUnit { } [QuantityUnit(typeof(Resistivity))] - [UnitFactor(typeof(Ohm))] + [UnitFactor(typeof(MetricSymbols.Ohm))] [UnitFactor(typeof(Metre))] private sealed class ResistivityUnit { } [QuantityUnit(typeof(Conductivity))] - [UnitFactor(typeof(Siemens))] + [UnitFactor(typeof(MetricSymbols.Siemens))] [UnitFactor(typeof(Metre), Power = -1)] private sealed class ConductivityUnit { } [QuantityUnit(typeof(MagneticReluctivity))] [UnitFactor(typeof(Metre))] - [UnitFactor(typeof(Henry), Power = -1)] + [UnitFactor(typeof(MetricSymbols.Henry), Power = -1)] private sealed class MagneticReluctivityUnit { } [QuantityUnit(typeof(MagneticVectorPotential))] - [UnitFactor(typeof(Weber))] + [UnitFactor(typeof(MetricSymbols.Weber))] [UnitFactor(typeof(Metre), Power = -1)] private sealed class MagneticVectorPotentialUnit { } } diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs index adf5605..ab22999 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/QuantityDiscoveryTests.cs @@ -195,7 +195,7 @@ public void UnknownQuantityUsesSiComponentPowersAndBasePrefixes() Assert.Multiple( () => Assert.Equal(massSquared, quantity.Dimension), () => Assert.False(quantity.IncludeNamedConversions), - () => Assert.Equal("MassSquared", unit.QuantityName), + () => Assert.Equal("MassSquared", unit.Name), () => Assert.Equal(2, term.Power), () => Assert.Equal(SiBasePrefixExponents.Mass, term.PrefixExponent)); } diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs similarity index 95% rename from src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs rename to src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs index 6e19d6a..a57cfb3 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionEmitterTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs @@ -10,7 +10,7 @@ namespace DimensionalQuantities.Roslyn.Tests.Generators; -public sealed class UnitConversionEmitterTests +public sealed class UnitConversionSourceEmitterTests { private static readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); private static readonly UnitConversionSpec[] _units = @@ -80,12 +80,12 @@ private static CSharpCompilation Compile(string source) global using Mass = {massType}; global using DimensionalQuantities.Generated; - {UnitConversionEmitter.Emit(_units, "DimensionalQuantities.Generated", "internal")} + {UnitConversionSourceEmitter.Emit(_units, "DimensionalQuantities.Generated", "internal")} """; string[] platformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); IEnumerable references = platformAssemblies.Select(path => MetadataReference.CreateFromFile(path)) .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); - CSharpCompilation compilation = CSharpCompilation.Create("UnitConversionEmitterConsumer", + CSharpCompilation compilation = CSharpCompilation.Create("UnitConversionSourceEmitterConsumer", [ CSharpSyntaxTree.ParseText(source, _parseOptions, path: "Consumer.cs"), CSharpSyntaxTree.ParseText(generatedSource, _parseOptions, path: "Conversions.g.cs") diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs index 7cbe998..7030cfa 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs @@ -163,6 +163,33 @@ public static double[] Values() => Assert.Equal([2d, 2000000d, 0.002d, 0.002d], Evaluate(compilation, "Independent.Units.Consumer")); } + [Fact] + public void OneUnitDeclarationCanServeDistinctQuantityTypes() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider] + internal static class CommonUnits + { + [QuantityUnit(typeof(Length))] + [QuantityUnit(typeof(Time))] + [UnitFactor(typeof(Token))] + private sealed class TokenUnit; + [UnitSymbol("Token", "Tokens", "token")] + private sealed class Token; + } + public static class Consumer + { + public static double[] Values() => + [Length.FromTokens(2).AsSiBaseUnit(), Time.FromTokens(3).AsSiBaseUnit()]; + } + """; + (Compilation compilation, ImmutableArray diagnostics) = Generate(source, ""); + Assert.Empty(diagnostics); + Assert.Equal([2d, 3d], Evaluate(compilation, "Independent.Units.Consumer")); + } + [Fact] public void AffineConversionsApplyScaleAndOffsetInBothDirections() { @@ -172,7 +199,8 @@ namespace Independent.Units; [UnitProvider] internal static class CustomUnits { - [QuantityUnit(typeof(Temperature), Offset = 255.3722222222222)] + [QuantityUnit(typeof(Temperature))] + [UnitConversion(Offset = 255.3722222222222)] [UnitFactor(typeof(Fahrenheit))] private sealed class TemperatureUnit; [UnitSymbol("Fahrenheit", "Fahrenheit", "°F", Scale = 5d / 9d)] diff --git a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs index 16de54d..2131d20 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs @@ -53,6 +53,58 @@ private sealed class SquareMetre; () => Assert.Equal(4, result.Units[0].Expression.Terms[0].Unit.Scale)); } + [Fact] + public void MultipleAliasesForSameQuantityShareOneUnitDefinition() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(Pressure))] + [QuantityUnit(typeof(Stress))] + [UnitFactor(typeof(Pascal))] + private sealed class PascalUnit; + [UnitSymbol("Pascal", "Pascals", "Pa")] + private sealed class Pascal; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + UnitConversionSpec result = Assert.Single(units); + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.Single(result.Units), + () => Assert.Equal("Pascal", result.ExtensionName)); + } + + [Fact] + public void OneDeclarationCanAssociateDistinctQuantityTypes() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + [UnitProvider] + internal static class Units + { + [QuantityUnit(typeof(Length))] + [QuantityUnit(typeof(Time))] + [UnitFactor(typeof(Token))] + private sealed class TokenUnit; + [UnitSymbol("Token", "Tokens", "token")] + private sealed class Token; + } + """; + (IReadOnlyList units, List diagnostics) = Read(source); + string[] expected = + [ + QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: 1)), + QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Time: 1)) + ]; + Assert.Multiple( + () => Assert.Empty(diagnostics), + () => Assert.All(units, unit => Assert.Equal("Token", unit.ExtensionName)), + () => Assert.Equal(expected.Order(StringComparer.Ordinal), units.Select(unit => unit.ReceiverType).Order(StringComparer.Ordinal))); + } + private static (IReadOnlyList Units, List Diagnostics) Read(string source) { CSharpParseOptions options = new(LanguageVersion.Preview); @@ -60,7 +112,9 @@ private static (IReadOnlyList Units, List Diagno IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); string lengthType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: 1)); - source = $"global using Length = {lengthType};\n" + source; + string timeType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Time: 1)); + string pressureType = QuantityTypeNameFormatter.Format(new DimensionalQuantities.Dimensions.Dimension(Length: -1, Mass: 1, Time: -2)); + source = $"global using Length = {lengthType};\nglobal using Time = {timeType};\nglobal using Pressure = {pressureType};\nglobal using Stress = {pressureType};\n" + source; CSharpCompilation input = CSharpCompilation.Create("UnitProviderReaderTest" + Guid.NewGuid().ToString("N"), [CSharpSyntaxTree.ParseText(source, options)], references, new(OutputKind.DynamicallyLinkedLibrary)); UnitProviderReader reader = new(input); diff --git a/src/DimensionalQuantities.Roslyn/Discovery/CompilationExtensions.cs b/src/DimensionalQuantities.Roslyn/Discovery/CompilationExtensions.cs index 63e12c3..1113243 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/CompilationExtensions.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/CompilationExtensions.cs @@ -1,4 +1,7 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Generators; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace DimensionalQuantities.Roslyn.Discovery; @@ -7,4 +10,17 @@ internal static class CompilationExtensions public static INamedTypeSymbol GetRequiredType(this Compilation compilation, Type type) => compilation.GetTypeByMetadataName(type.FullName!) ?? throw new InvalidOperationException($"Required type '{type.FullName}' is missing or ambiguous in the consuming project. Check its DimensionalQuantities reference."); + + public static CSharpCompilation AddGeneratedSources(this CSharpCompilation compilation, ImmutableArray sources) + { + if (sources.IsEmpty) + { + return compilation; + } + + CSharpParseOptions options = compilation.SyntaxTrees.OfType() + .Select(tree => (CSharpParseOptions)tree.Options).FirstOrDefault() ?? CSharpParseOptions.Default; + SyntaxTree[] trees = sources.Select(source => CSharpSyntaxTree.ParseText(source.Text, options, path: source.HintName)).ToArray(); + return compilation.AddSyntaxTrees(trees); + } } diff --git a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs index 2bceb4a..5f6b978 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/PlannedQuantity.cs @@ -9,7 +9,7 @@ internal sealed record PlannedQuantity(Dimension Dimension, ImmutableArray units, bool includeNamedConversions) - : this(dimension, [.. units.Select(unit => unit.QuantityName)], units, includeNamedConversions) { } + : this(dimension, [.. units.Select(unit => unit.Name)], units, includeNamedConversions) { } public string Name => Names[0]; diff --git a/src/DimensionalQuantities.Roslyn/Discovery/QuantityDiscoveryCoordinator.cs b/src/DimensionalQuantities.Roslyn/Discovery/QuantityDiscoveryCoordinator.cs index a6e779c..a2360d0 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/QuantityDiscoveryCoordinator.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/QuantityDiscoveryCoordinator.cs @@ -19,7 +19,7 @@ public static QuantityDiscoveryResult Discover(CSharpCompilation compilation, Im } ImmutableArray aliases = DiscoverySeedCollector.FindEnglishAliases(compilation, inputTrees, cancellationToken); - CSharpCompilation seeded = aliases.IsEmpty ? compilation : Bind(compilation, + CSharpCompilation seeded = aliases.IsEmpty ? compilation : compilation.AddGeneratedSources( [new("EnglishExponents.g.cs", EnglishExponentAliasEmitter.Emit(aliases))]); HashSet requested = [.. DiscoverySeedCollector.FindRequestedDimensions(seeded, inputTrees, cancellationToken)]; HashSet arithmetic = []; @@ -32,7 +32,7 @@ public static QuantityDiscoveryResult Discover(CSharpCompilation compilation, Im QuantityGenerationPlan plan = QuantityDiscovery.DiscoverWithPredefinedQuantities(defaults, arithmetic, powers, methods, requested); ImmutableArray sources = DiscoverySourceComposer.Compose(plan, aliases); - CSharpCompilation bound = Bind(compilation, sources); + CSharpCompilation bound = compilation.AddGeneratedSources(sources); BoundOperationResult found = BoundOperationDiscovery.Discover(bound, inputTrees, cancellationToken); bool changed = AddNew(arithmetic, found.ArithmeticOperations); changed |= AddNew(powers, found.PowerOperations); @@ -65,12 +65,4 @@ private static bool AddNew(HashSet existing, IEnumerable discovered) return changed; } - - private static CSharpCompilation Bind(CSharpCompilation compilation, ImmutableArray sources) - { - CSharpParseOptions options = compilation.SyntaxTrees.OfType() - .Select(tree => (CSharpParseOptions)tree.Options).FirstOrDefault() ?? CSharpParseOptions.Default; - SyntaxTree[] trees = sources.Select(source => CSharpSyntaxTree.ParseText(source.Text, options, path: source.HintName)).ToArray(); - return compilation.AddSyntaxTrees(trees); - } } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 0297600..81bc678 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs @@ -13,7 +13,7 @@ public sealed class QuantityGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - IncrementalValuesProvider providers = UnitProviderSourceEmitter.FindProviders(context); + IncrementalValuesProvider providers = UnitProviderGenerationCoordinator.FindProviders(context); QuantityGenerationPlan defaultPlan = DefaultQuantityDiscovery.Discover(); context.RegisterPostInitializationOutput(output => { @@ -34,13 +34,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { CSharpCompilation original = (CSharpCompilation)input.Left.Left; ImmutableArray sourceTrees = input.Left.Right; - ImmutableArray providerSources = UnitProviderSourceEmitter.Emit(original, input.Right, output.ReportDiagnostic); + ImmutableArray providerSources = UnitProviderGenerationCoordinator.Generate(original, input.Right, output.ReportDiagnostic); foreach (GeneratedSource source in providerSources) { output.AddSource(source.HintName, SourceText.From(source.Text, Encoding.UTF8)); } - CSharpCompilation withProviders = AddSources(original, providerSources); + CSharpCompilation withProviders = original.AddGeneratedSources(providerSources); foreach (Diagnostic diagnostic in GenerationDiagnostics.FindInvalidEnglishExponents(original, sourceTrees, output.CancellationToken)) { output.ReportDiagnostic(diagnostic); @@ -60,19 +60,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - private static CSharpCompilation AddSources(CSharpCompilation compilation, ImmutableArray sources) - { - if (sources.IsEmpty) - { - return compilation; - } - - CSharpParseOptions options = compilation.SyntaxTrees.OfType() - .Select(tree => (CSharpParseOptions)tree.Options).FirstOrDefault() ?? CSharpParseOptions.Default; - SyntaxTree[] trees = sources.Select(source => CSharpSyntaxTree.ParseText(source.Text, options, path: source.HintName)).ToArray(); - return compilation.AddSyntaxTrees(trees); - } - private static bool IsDiscoveryInputTree(SyntaxTree tree) { string path = tree.FilePath.Replace('\\', '/'); diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index 293e286..131bc30 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs @@ -11,7 +11,7 @@ public static string Emit(IReadOnlyList quantities) UnitConversionSpec[] conversions = quantities.Where(quantity => !quantity.Units.IsEmpty) .Select(quantity => new UnitConversionSpec(quantity.Name, quantity.Name, quantity.Units, quantity.IncludeNamedConversions)).ToArray(); - string extensions = UnitConversionEmitter.Emit(conversions, "DimensionalQuantities.Generated", "internal"); + string extensions = UnitConversionSourceEmitter.Emit(conversions, "DimensionalQuantities.Generated", "internal"); return $""" // {aliases} diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs similarity index 96% rename from src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs rename to src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs index 5fe4430..4c5086f 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs @@ -5,7 +5,7 @@ namespace DimensionalQuantities.Roslyn.Generators; -internal static class UnitConversionEmitter +internal static class UnitConversionSourceEmitter { public static string Emit(IReadOnlyList quantities, string namespaceName, string accessibility, string classNamePrefix = "") @@ -37,14 +37,14 @@ private static string RenderQuantity(UnitConversionSpec quantity, int[] prefixes .ToArray() : []; ConversionDefinition[] conversions = quantity.GeneratePrefixConversions ? [generic, .. named] : named; - string variableName = char.ToLowerInvariant(quantity.Name[0]) + quantity.Name[1..]; - string className = classNamePrefix + quantity.Name + "Extensions"; + string variableName = char.ToLowerInvariant(quantity.ExtensionName[0]) + quantity.ExtensionName[1..]; + string className = classNamePrefix + (quantity.ExtensionClassName ?? quantity.ExtensionName) + "Extensions"; if (!SyntaxFacts.IsValidIdentifier(className) || !SyntaxFacts.IsValidIdentifier(variableName) || conversions.Any(conversion => !SyntaxFacts.IsValidIdentifier(conversion.FactoryName) || !SyntaxFacts.IsValidIdentifier(conversion.AccessorName) || conversion.PrefixParameters.Any(parameter => !SyntaxFacts.IsValidIdentifier(parameter.Name)))) { - throw new InvalidOperationException($"Unit provider for {quantity.Name} produces an invalid C# identifier."); + throw new InvalidOperationException($"Unit provider for {quantity.ExtensionName} produces an invalid C# identifier."); } string factories = string.Join("\n\t\t", conversions.Select(conversion => RenderFactory(quantity.ReceiverType, conversion))); diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs index bd38557..410f91a 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs @@ -3,5 +3,5 @@ namespace DimensionalQuantities.Roslyn.Generators; -internal sealed record UnitConversionSpec(string Name, string ReceiverType, ImmutableArray Units, bool IncludeNamed, - bool GenerateSiPrefixes = true, bool GeneratePrefixConversions = true); +internal sealed record UnitConversionSpec(string ExtensionName, string ReceiverType, ImmutableArray Units, bool IncludeNamed, + bool GenerateSiPrefixes = true, bool GeneratePrefixConversions = true, string? ExtensionClassName = null); diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderGenerationCoordinator.cs similarity index 86% rename from src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs rename to src/DimensionalQuantities.Roslyn/Generators/UnitProviderGenerationCoordinator.cs index c2ea10f..d5bf835 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderSourceEmitter.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderGenerationCoordinator.cs @@ -5,7 +5,7 @@ namespace DimensionalQuantities.Roslyn.Generators; -internal static class UnitProviderSourceEmitter +internal static class UnitProviderGenerationCoordinator { public static IncrementalValuesProvider FindProviders(IncrementalGeneratorInitializationContext context) { @@ -15,7 +15,7 @@ public static IncrementalValuesProvider FindProviders(Incremen static (syntaxContext, _) => (INamedTypeSymbol)syntaxContext.TargetSymbol); } - public static ImmutableArray Emit(Compilation compilation, ImmutableArray providers, + public static ImmutableArray Generate(Compilation compilation, ImmutableArray providers, Action report) { ImmutableArray.Builder sources = ImmutableArray.CreateBuilder(); @@ -37,7 +37,7 @@ public static ImmutableArray Emit(Compilation compilation, Immu { string namespaceName = provider.ContainingNamespace.IsGlobalNamespace ? string.Empty : provider.ContainingNamespace.ToDisplayString(); - string source = "// \n" + UnitConversionEmitter.Emit(units, namespaceName, "public", + string source = "// \n" + UnitConversionSourceEmitter.Emit(units, namespaceName, "public", ClassNamePrefix(provider)); string hint = provider.ToDisplayString() + ".g.cs"; sources.Add(new(hint, source)); diff --git a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs index be8b652..4de97fe 100644 --- a/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs @@ -1,4 +1,5 @@ using DimensionalQuantities.Numerics; +using DimensionalQuantities.Naming; using DimensionalQuantities.Roslyn.Discovery; using DimensionalQuantities.Units; using DimensionalQuantities.Units.Declarations; @@ -10,6 +11,7 @@ internal sealed class UnitProviderReader { private readonly INamedTypeSymbol _providerAttribute; private readonly INamedTypeSymbol _quantityAttribute; + private readonly INamedTypeSymbol _conversionAttribute; private readonly INamedTypeSymbol _factorAttribute; private readonly INamedTypeSymbol _symbolAttribute; private readonly INamedTypeSymbol _additionalAttribute; @@ -19,6 +21,7 @@ public UnitProviderReader(Compilation compilation) { _providerAttribute = compilation.GetRequiredType(typeof(UnitProviderAttribute)); _quantityAttribute = compilation.GetRequiredType(typeof(QuantityUnitAttribute)); + _conversionAttribute = compilation.GetRequiredType(typeof(UnitConversionAttribute)); _factorAttribute = compilation.GetRequiredType(typeof(UnitFactorAttribute)); _symbolAttribute = compilation.GetRequiredType(typeof(UnitSymbolAttribute)); _additionalAttribute = compilation.GetRequiredType(typeof(AdditionalUnitAttribute)); @@ -38,8 +41,35 @@ public IReadOnlyList Read(INamedTypeSymbol provider, Action< List<(ITypeSymbol Type, string Name, List Units)> groups = []; foreach (INamedTypeSymbol declaration in provider.GetTypeMembers()) { - if (Find(declaration, _quantityAttribute) is not { } unitMarker - || !TryReadQuantity(declaration, unitMarker, provider, report, out INamedTypeSymbol? quantityType, out UnitDefinition unit)) + AttributeData[] associations = FindAll(declaration, _quantityAttribute).ToArray(); + if (associations.Length == 0 || !TryReadUnitDefinition(declaration, provider, report, out UnitDefinition unit)) + { + continue; + } + + AddAssociations(declaration, unit, associations, groups, report); + } + + return groups.Select(group => new UnitConversionSpec(group.Name, + group.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), [.. group.Units], true, + siPrefixes, prefixConversions, + groups.Count(other => other.Name == group.Name) > 1 ? group.Name + "For" + GetDimensionName(group.Type) : null)).ToArray(); + } + + private void AddAssociations(INamedTypeSymbol declaration, UnitDefinition unit, AttributeData[] associations, + List<(ITypeSymbol Type, string Name, List Units)> groups, Action report) + { + HashSet associatedTypes = new(SymbolEqualityComparer.Default); + foreach (AttributeData association in associations) + { + INamedTypeSymbol? quantityType = association.ConstructorArguments[0].Value as INamedTypeSymbol; + if (quantityType is null || !_quantityReader.TryRead(quantityType, out _)) + { + Report(report, association, $"{declaration.Name} must name a valid Quantity."); + continue; + } + + if (!associatedTypes.Add(quantityType)) { continue; } @@ -47,39 +77,35 @@ public IReadOnlyList Read(INamedTypeSymbol provider, Action< int index = groups.FindIndex(group => SymbolEqualityComparer.Default.Equals(group.Type, quantityType)); if (index < 0) { - groups.Add((quantityType!, unit.QuantityName, [unit])); + groups.Add((quantityType, unit.Name, [unit])); } else { groups[index].Units.Add(unit); } } + } - return groups.Select(group => new UnitConversionSpec(group.Name, - group.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), [.. group.Units], true, - siPrefixes, prefixConversions)).ToArray(); + private string GetDimensionName(ITypeSymbol quantityType) + { + _quantityReader.TryRead(quantityType, out DimensionalQuantities.Dimensions.Dimension dimension); + return dimension == default ? "Dimensionless" : DimensionNameGenerator.GetQuantityName(dimension); } - private bool TryReadQuantity(INamedTypeSymbol declaration, AttributeData marker, INamedTypeSymbol provider, - Action report, out INamedTypeSymbol? quantityType, out UnitDefinition unit) + private bool TryReadUnitDefinition(INamedTypeSymbol declaration, INamedTypeSymbol provider, + Action report, out UnitDefinition unit) { unit = default; - quantityType = marker.ConstructorArguments[0].Value as INamedTypeSymbol; - if (quantityType is null || !_quantityReader.TryRead(quantityType, out _)) - { - Report(report, marker, $"{declaration.Name} must name a valid Quantity."); - return false; - } - if (!TryReadExpression(declaration, report, out UnitExpression expression)) { return false; } - double offset = GetNamed(marker, nameof(QuantityUnitAttribute.Offset), 0d); + AttributeData? conversion = Find(declaration, _conversionAttribute); + double offset = conversion is null ? 0d : GetNamed(conversion, nameof(UnitConversionAttribute.Offset), 0d); if (!IsFinite(offset)) { - Report(report, marker, $"{declaration.Name} has a non-finite offset."); + Report(report, conversion, $"{declaration.Name} has a non-finite offset."); return false; } @@ -92,7 +118,7 @@ private bool TryReadQuantity(INamedTypeSymbol declaration, AttributeData marker, ? declaration.Name[..^4] : declaration.Name; if (name.Length == 0) { - Report(report, marker, "A quantity unit declaration must have a name."); + Report(report, Find(declaration, _quantityAttribute), "A quantity unit declaration must have a name."); return false; } diff --git a/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs index cac1559..036ceeb 100644 --- a/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs +++ b/src/DimensionalQuantities.Tests/Naming/DimensionNameGeneratorTests.cs @@ -22,22 +22,6 @@ public void GetQuantityNameUsesBaseVocabulary(int length, int mass, int time, in Assert.Equal(expected, result); } - [Theory] - [InlineData(1, 0, 0, 0, 0, 0, 0, "Metres")] - [InlineData(0, 1, 0, 0, 0, 0, 0, "Kilograms")] - [InlineData(0, 0, 1, 0, 0, 0, 0, "Seconds")] - [InlineData(0, 0, 0, 1, 0, 0, 0, "Amps")] - [InlineData(0, 0, 0, 0, 1, 0, 0, "Kelvin")] - [InlineData(0, 0, 0, 0, 0, 1, 0, "Moles")] - [InlineData(0, 0, 0, 0, 0, 0, 1, "Candelas")] - public void GetUnitNameUsesBaseVocabulary(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) - { - Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); - string result = DimensionNameGenerator.GetUnitName(original); - - Assert.Equal(expected, result); - } - [Theory] [InlineData(1, "Current")] [InlineData(2, "CurrentSquared")] @@ -57,25 +41,6 @@ public void GetQuantityNameFormatsIntegerPowers(int exponent, string expected) Assert.Equal(expected, result); } - [Theory] - [InlineData(1, "Amps")] - [InlineData(2, "AmpsSquared")] - [InlineData(3, "AmpsCubed")] - [InlineData(4, "Amps4")] - [InlineData(12, "Amps12")] - [InlineData(-1, "InverseAmps")] - [InlineData(-2, "InverseAmpsSquared")] - [InlineData(-3, "InverseAmpsCubed")] - [InlineData(-4, "InverseAmps4")] - [InlineData(-12, "InverseAmps12")] - public void GetUnitNameFormatsIntegerPowers(int exponent, string expected) - { - Dimension original = new(ElectricCurrent: exponent); - string result = DimensionNameGenerator.GetUnitName(original); - - Assert.Equal(expected, result); - } - [Theory] [InlineData(1, 1, 1, 1, 1, 1, 1, "LengthMassTimeCurrentTemperatureAmountOfSubstanceLuminousIntensity")] [InlineData(3, 0, -12, 0, 0, 0, 0, "LengthCubedPerTime12")] @@ -92,22 +57,6 @@ public void GetQuantityNameComposesFactorsInSiOrder(int length, int mass, int ti Assert.Equal(expected, result); } - [Theory] - [InlineData(1, 1, 1, 1, 1, 1, 1, "MetresKilogramsSecondsAmpsKelvinMolesCandelas")] - [InlineData(3, 0, -12, 0, 0, 0, 0, "MetresCubedPerSeconds12")] - [InlineData(1, 1, -2, 0, 0, 0, 0, "MetresKilogramsPerSecondsSquared")] - [InlineData(2, 1, -3, 0, 0, 0, 0, "MetresSquaredKilogramsPerSecondsCubed")] - [InlineData(0, 0, 0, 0, -1, 0, 1, "CandelasPerKelvin")] - [InlineData(-2, -1, 3, -4, 1, -2, 1, "SecondsCubedKelvinCandelasPerMetresSquaredKilogramsAmps4MolesSquared")] - [InlineData(-1, -1, -1, -1, -1, -1, -1, "InverseMetresKilogramsSecondsAmpsKelvinMolesCandelas")] - public void GetUnitNameComposesFactorsInSiOrder(int length, int mass, int time, int electricCurrent, int temperature, int amountOfSubstance, int luminousIntensity, string expected) - { - Dimension original = new(length, mass, time, electricCurrent, temperature, amountOfSubstance, luminousIntensity); - string result = DimensionNameGenerator.GetUnitName(original); - - Assert.Equal(expected, result); - } - [Theory] [InlineData(1, 2, "SquareRootCurrent")] [InlineData(1, 3, "CubeRootCurrent")] @@ -130,28 +79,6 @@ public void GetQuantityNameFormatsReducedRationalPowers(int numerator, int denom Assert.Equal(expected, result); } - [Theory] - [InlineData(1, 2, "SquareRootAmps")] - [InlineData(1, 3, "CubeRootAmps")] - [InlineData(3, 2, "SquareRootAmpsCubed")] - [InlineData(2, 3, "CubeRootAmpsSquared")] - [InlineData(2, 4, "SquareRootAmps")] - [InlineData(4, 2, "AmpsSquared")] - [InlineData(-3, 2, "InverseSquareRootAmpsCubed")] - [InlineData(1, -2, "InverseSquareRootAmps")] - [InlineData(1, 12, "Root12Amps")] - [InlineData(1, 4, "Root4Amps")] - [InlineData(1, 5, "Root5Amps")] - [InlineData(4, 5, "Root5Amps4")] - [InlineData(1, 45, "Root45Amps")] - public void GetUnitNameFormatsReducedRationalPowers(int numerator, int denominator, string expected) - { - Dimension original = new(ElectricCurrent: new(numerator, denominator)); - string result = DimensionNameGenerator.GetUnitName(original); - - Assert.Equal(expected, result); - } - [Fact] public void GetQuantityNameKeepsFractionalPowersAttachedToTheirFactors() { @@ -162,16 +89,6 @@ public void GetQuantityNameKeepsFractionalPowersAttachedToTheirFactors() Assert.Equal(expected, result); } - [Fact] - public void GetUnitNameKeepsFractionalPowersAttachedToTheirFactors() - { - Dimension original = new(Length: new(1, 2), Time: new(-3, 2), ElectricCurrent: 1); - string result = DimensionNameGenerator.GetUnitName(original); - string expected = "SquareRootMetresAmpsPerSquareRootSecondsCubed"; - - Assert.Equal(expected, result); - } - [Theory] [InlineData(1, 0, 0, 0, 0, 0, 0, "m")] [InlineData(0, 1, 0, 0, 0, 0, 0, "kg")] @@ -226,7 +143,6 @@ public void NamingMethodsRejectDimensionlessQuantities() Assert.Multiple( () => Assert.Throws(() => DimensionNameGenerator.GetQuantityName(original)), - () => Assert.Throws(() => DimensionNameGenerator.GetUnitName(original)), () => Assert.Throws(() => DimensionNameGenerator.GetUnitExpression(original))); } } diff --git a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs index b97d5b6..221f677 100644 --- a/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs +++ b/src/DimensionalQuantities.Tests/Units/UnitExpressionTests.cs @@ -36,6 +36,31 @@ public void GetNameFormatsFractionalPowers(int numerator, int denominator, strin Assert.Equal(expected, result); } + [Theory] + [InlineData(1, 12, "Root12Amps")] + [InlineData(4, 5, "Root5Power4Amps")] + [InlineData(-3, 2, "InverseSquareRootCubicAmp")] + public void GetNameFormatsHigherAndInverseRationalPowers(int numerator, int denominator, string expected) + { + UnitExpression original = new(new UnitTerm(AtomicUnits.Amp, new Rational(numerator, denominator))); + string result = original.GetName(); + + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameKeepsFractionalPowersWithTheirFactorsInOrderedCompound() + { + UnitExpression original = new( + new UnitTerm(AtomicUnits.Metre, new Rational(1, 2)), + new UnitTerm(AtomicUnits.Amp), + new UnitTerm(AtomicUnits.Second, new Rational(-3, 2))); + string result = original.GetName(); + string expected = "SquareRootMetreAmpsPerSquareRootCubicSecond"; + + Assert.Equal(expected, result); + } + [Fact] public void GetNameFormatsCompoundNumeratorAndDenominator() { diff --git a/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs index bbc7aa6..da013ca 100644 --- a/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs +++ b/src/DimensionalQuantities/Naming/DimensionNameGenerator.cs @@ -8,10 +8,6 @@ namespace DimensionalQuantities.Naming; internal static class DimensionNameGenerator { - public static string GetQuantityName(Dimension dimension) => GetName(dimension, static component => component.Unit.QuantityName); - - public static string GetUnitName(Dimension dimension) => GetName(dimension, static component => component.Unit.Expression.GetName()); - public static string GetUnitExpression(Dimension dimension) { DimensionComponent[] components = dimension.GetComponents().ToArray(); @@ -33,7 +29,7 @@ public static string GetUnitExpression(Dimension dimension) return expression.ToString(); } - private static string GetName(Dimension dimension, Func getFactorName) + public static string GetQuantityName(Dimension dimension) { DimensionComponent[] components = dimension.GetComponents().ToArray(); if (components.Length == 0) @@ -51,11 +47,11 @@ private static string GetName(Dimension dimension, Func 0) { - AppendFactor(numerator, getFactorName(component), exponent); + AppendFactor(numerator, component.Unit.Name, exponent); } else { - AppendFactor(denominator, getFactorName(component), -exponent); + AppendFactor(denominator, component.Unit.Name, -exponent); } } diff --git a/src/DimensionalQuantities/Naming/DimensionNameParser.cs b/src/DimensionalQuantities/Naming/DimensionNameParser.cs index 69a18e8..3671853 100644 --- a/src/DimensionalQuantities/Naming/DimensionNameParser.cs +++ b/src/DimensionalQuantities/Naming/DimensionNameParser.cs @@ -148,5 +148,5 @@ private static bool StartsWithAt(string text, int position, string prefix) => te && string.Compare(text, position, prefix, 0, prefix.Length, StringComparison.Ordinal) == 0; private static (string Name, Dimension Basis) Basis(Dimension dimension) => - (dimension.GetComponents().Single().Unit.QuantityName, dimension); + (dimension.GetComponents().Single().Unit.Name, dimension); } diff --git a/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs b/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs index f9e1819..96859e6 100644 --- a/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs +++ b/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs @@ -1,13 +1,10 @@ namespace DimensionalQuantities.Units.Declarations; /// Associates a unit declaration with a quantity type. -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public sealed class QuantityUnitAttribute : Attribute { public QuantityUnitAttribute(Type quantityType) => QuantityType = quantityType; public Type QuantityType { get; } - - /// Offset added after scaling a magnitude into coherent SI units. - public double Offset { get; set; } } diff --git a/src/DimensionalQuantities/Units/Declarations/UnitConversionAttribute.cs b/src/DimensionalQuantities/Units/Declarations/UnitConversionAttribute.cs new file mode 100644 index 0000000..31fab0f --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/UnitConversionAttribute.cs @@ -0,0 +1,9 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Sets conversion options shared by the quantity associations on a unit declaration. +[AttributeUsage(AttributeTargets.Class)] +public sealed class UnitConversionAttribute : Attribute +{ + /// Offset added after scaling a magnitude into coherent SI units. + public double Offset { get; set; } +} diff --git a/src/DimensionalQuantities/Units/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs index d7d3a54..8665d52 100644 --- a/src/DimensionalQuantities/Units/UnitDefinition.cs +++ b/src/DimensionalQuantities/Units/UnitDefinition.cs @@ -2,17 +2,17 @@ namespace DimensionalQuantities.Units; internal readonly record struct UnitDefinition { - public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null, double offset = 0) + public UnitDefinition(string name, UnitExpression expression, UnitExpression[]? additionalConversions = null, double offset = 0) { - QuantityName = quantityName; + Name = name; Expression = expression; AdditionalConversions = additionalConversions ?? []; Offset = offset; } - public UnitDefinition(string quantityName, UnitTerm[] terms) : this(quantityName, new UnitExpression(terms)) { } + public UnitDefinition(string name, UnitTerm[] terms) : this(name, new UnitExpression(terms)) { } - public string QuantityName { get; } + public string Name { get; } public UnitExpression Expression { get; } public UnitExpression[] AdditionalConversions { get; } public double Offset { get; }