diff --git a/.github/scripts/check-package.py b/.github/scripts/check-package.py index a4c2d14..68276f7 100644 --- a/.github/scripts/check-package.py +++ b/.github/scripts/check-package.py @@ -1,19 +1,87 @@ 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(".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}") + +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..ff59b85 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); @@ -162,13 +170,53 @@ 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(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)] + 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;`. 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. 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 +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.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..651797b --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric.Tests/MetricConversionsTests.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using DimensionalQuantities.Extensions.Metric; +using Xunit; + +namespace DimensionalQuantities.Extensions.Metric.Tests; + +public sealed class MetricConversionsTests +{ + [Fact] + public void CatalogueFactoriesConvertToExpectedSiValues() + { + double[] result = + [ + 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(), + ]; + + 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] + 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(), + 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] + public void NamedConversionsApplyMetricScale() + { + Assert.Multiple( + () => Assert.Equal(0.0025, Voltage.FromSiBaseUnit(2.5).AsKiloVolts()), + () => 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()), + () => 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 = Assembly.Load("DimensionalQuantities.Extensions.Metric").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..d52a031 --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/DimensionalQuantities.Extensions.Metric.csproj @@ -0,0 +1,35 @@ + + + netstandard2.0 + GoSim + MIT + README.md + https://github.com/GoSimulate/DimensionalQuantities + Named metric conversions for dimensional quantities. + git + true + $(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/generated + true + snupkg + true + true + true + portable + + + + + + + + + + + <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)" + Condition="'%(_ProjectReferencesWithVersions.Filename)' == 'DimensionalQuantities'"> + [$(PackageVersion)] + + + + 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 new file mode 100644 index 0000000..b57291b --- /dev/null +++ b/src/DimensionalQuantities.Extensions.Metric/MetricUnits.cs @@ -0,0 +1,109 @@ +using DimensionalQuantities.Units.Declarations; +using DimensionalQuantities.Units.Symbols; + +namespace DimensionalQuantities.Extensions.Metric; + +[UnitProvider(GenerateSiPrefixes = true, GeneratePrefixConversions = true)] +internal static class MetricUnits +{ + [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(MetricSymbols.Hertz))] + private sealed class FrequencyUnit { } + [QuantityUnit(typeof(AngularVelocity))] + [UnitFactor(typeof(MetricSymbols.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))] + [QuantityUnit(typeof(Stress))] + [QuantityUnit(typeof(YoungsModulus))] + [UnitFactor(typeof(MetricSymbols.Pascal))] + private sealed class PascalUnit { } + [QuantityUnit(typeof(Energy))] + [UnitFactor(typeof(MetricSymbols.Joule))] + private sealed class EnergyUnit { } + [QuantityUnit(typeof(Torque))] + [UnitFactor(typeof(MetricSymbols.Newton))] + [UnitFactor(typeof(Metre))] + private sealed class TorqueUnit { } + [QuantityUnit(typeof(Power))] + [UnitFactor(typeof(MetricSymbols.Watt))] + private sealed class PowerUnit { } + [QuantityUnit(typeof(SpecificPower))] + [QuantityUnit(typeof(SpecificPowerLoss))] + [UnitFactor(typeof(MetricSymbols.Watt))] + [UnitFactor(typeof(Gram), Power = -1, PrefixExponent = 3)] + private sealed class SpecificPowerUnit { } + [QuantityUnit(typeof(TemperatureCoefficient))] + [UnitFactor(typeof(Kelvin), Power = -1)] + private sealed class TemperatureCoefficientUnit { } + [QuantityUnit(typeof(PowerDensity))] + [QuantityUnit(typeof(VolumetricPowerLoss))] + [UnitFactor(typeof(MetricSymbols.Watt))] + [UnitFactor(typeof(Metre), Power = -3)] + private sealed class PowerDensityUnit { } + [QuantityUnit(typeof(Voltage))] + [UnitFactor(typeof(MetricSymbols.Volt))] + private sealed class VoltageUnit { } + [QuantityUnit(typeof(Resistance))] + [UnitFactor(typeof(MetricSymbols.Ohm))] + private sealed class ResistanceUnit { } + [QuantityUnit(typeof(Inductance))] + [UnitFactor(typeof(MetricSymbols.Henry))] + private sealed class InductanceUnit { } + [QuantityUnit(typeof(MagneticFlux))] + [QuantityUnit(typeof(FluxLinkage))] + [UnitFactor(typeof(MetricSymbols.Weber))] + private sealed class MagneticFluxUnit { } + [QuantityUnit(typeof(MagneticFluxDensity))] + [QuantityUnit(typeof(MagneticPolarisation))] + [QuantityUnit(typeof(RemanentFluxDensity))] + [UnitFactor(typeof(MetricSymbols.Tesla))] + private sealed class MagneticFluxDensityUnit { } + [QuantityUnit(typeof(MagneticFieldStrength))] + [QuantityUnit(typeof(CoerciveFieldStrength))] + [UnitFactor(typeof(Amp))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class MagneticFieldStrengthUnit { } + [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(MetricSymbols.Henry))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class PermeabilityUnit { } + [QuantityUnit(typeof(Resistivity))] + [UnitFactor(typeof(MetricSymbols.Ohm))] + [UnitFactor(typeof(Metre))] + private sealed class ResistivityUnit { } + [QuantityUnit(typeof(Conductivity))] + [UnitFactor(typeof(MetricSymbols.Siemens))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class ConductivityUnit { } + [QuantityUnit(typeof(MagneticReluctivity))] + [UnitFactor(typeof(Metre))] + [UnitFactor(typeof(MetricSymbols.Henry), Power = -1)] + private sealed class MagneticReluctivityUnit { } + [QuantityUnit(typeof(MagneticVectorPotential))] + [UnitFactor(typeof(MetricSymbols.Weber))] + [UnitFactor(typeof(Metre), Power = -1)] + private sealed class MagneticVectorPotentialUnit { } +} diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs deleted file mode 100644 index b3b1cee..0000000 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System.Reflection; -using Microsoft.Build.Evaluation; -using Microsoft.Build.Execution; -using Microsoft.Build.Locator; -using Microsoft.Build.Utilities.ProjectCreation; -using Xunit; - -namespace DimensionalQuantities.IntegrationTests.GeneratedQuantityTests; - -[Collection("MSBuild")] -public sealed class PackagedGeneratorTests : IDisposable -{ - private readonly string _scratch = Directory.CreateTempSubdirectory("dimensional-quantities-").FullName; - - static PackagedGeneratorTests() - { - // MSBuild must be located before any Microsoft.Build type is loaded. - MSBuildLocator.RegisterDefaults(); - } - - [Fact] - public void PackagedGeneratorConvertsVoltageInConsumer() - { - 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); - - 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; - - public static class Consumer - { - public static double ConvertVoltage(double volts) - { - Voltage voltage = Voltage.FromVolts(volts); - return voltage.AsVolts(); - } - - public static double MultiplyVoltageCurrent(double volts, double amps) - { - Voltage voltage = Voltage.FromVolts(volts); - Current current = Current.FromAmps(amps); - Power power = voltage * current; - return power.AsWatts(); - } - } - """); - ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "netstandard2.0") - .Property("LangVersion", "preview") - .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") - .ItemPackageReference(package) - .Save(); - - creator.TryBuild(restore: true, out bool built, out BuildOutput output); - using (output) - { - Assert.True(built, $"Consumer build failed.{Environment.NewLine}{output.GetConsoleLog()}"); - } - - 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); - - 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 Measurement Measure() => new(); - } - public sealed class Measurement - { - public LengthCurrent 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); - - ProjectCreator projectB = CreateProject("ProjectB", """ - 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) - { - double total = 0; - foreach (LengthCurrent value in values) - { - total += value.AsSiBaseUnit(); - } - 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); - - ProjectCreator projectC = CreateProject("ProjectC", """ - 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(); - } - """, quantities, packageA, packageB); - projectC.TryBuild(restore: true, out bool builtC, out BuildOutput outputC); - using (outputC) - { - Assert.True(builtC, outputC.GetConsoleLog()); - } - - string outputDirectory = Path.Combine(_scratch, "ProjectC", "bin", "Debug", "netstandard2.0"); - using PackageAssemblyContext context = new(outputDirectory); - Assembly assemblyA = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectA.dll")); - Assembly assemblyB = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectB.dll")); - Assembly assemblyC = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectC.dll")); - Type producer = assemblyA.GetType("PackageA.Producer", throwOnError: true)!; - Type consumer = assemblyB.GetType("PackageB.Consumer", throwOnError: true)!; - Type application = assemblyC.GetType("Application", throwOnError: true)!; - 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)!; - - 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)); - } - - private ProjectCreator CreateProject(string name, string source, params Package[] packages) - { - string directory = Path.Combine(_scratch, name); - Directory.CreateDirectory(directory); - File.WriteAllText(Path.Combine(directory, name + ".cs"), source); - ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: Path.Combine(directory, name + ".csproj"), targetFramework: "netstandard2.0") - .Property("LangVersion", "preview") - .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") - .Property("Version", "1.0.0") - .Property("PackageOutputPath", Path.Combine(directory, "packages")) - .Property("CopyLocalLockFileAssemblies", "true"); - foreach (Package package in packages) - { - creator.ItemPackageReference(package); - } - return creator.Save(); - } - - private sealed class PackageAssemblyContext : System.Runtime.Loader.AssemblyLoadContext, IDisposable - { - private readonly string _directory; - - public PackageAssemblyContext(string directory) : base(isCollectible: true) - { - _directory = directory; - } - - protected override Assembly? Load(AssemblyName assemblyName) - { - string path = Path.Combine(_directory, assemblyName.Name + ".dll"); - return File.Exists(path) ? LoadFromAssemblyPath(path) : null; - } - - public void Dispose() => Unload(); - } - - 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/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/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/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs new file mode 100644 index 0000000..d251bd5 --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/PackagedGeneratorTests.cs @@ -0,0 +1,279 @@ +using System.Reflection; +using Microsoft.Build.Locator; +using Microsoft.Build.Utilities.ProjectCreation; +using Xunit; + +namespace DimensionalQuantities.IntegrationTests.Packaging; + +[Collection("MSBuild")] +public sealed class PackagedGeneratorTests : IDisposable +{ + private readonly string _scratch = Directory.CreateTempSubdirectory("dimensional-quantities-").FullName; + + static PackagedGeneratorTests() + { + // MSBuild must be located before any Microsoft.Build type is loaded. + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + } + + [Fact] + public void CorePackageProvidesBaseUnitsCatalogueAliasesAndArithmetic() + { + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); + 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"))); + } + + [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.FromVolts(volts).AsVolts(); + public static double MultiplyVoltageCurrent(double volts, double amps) + { + Power power = Voltage.FromVolts(volts) * Current.FromAmps(amps); + return power.AsWatts(); + } + } + """, metric); + Build(consumer); + + 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) + { + 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")); + } + } + + [Fact] + public void IndependentPackagesShareQuantitiesAndConsumerCanExtendArithmetic() + { + using PackageRepository repository = PackageRepository.Create(_scratch, new Uri("https://api.nuget.org/v3/index.json")); + Package core = PackCore(repository); + Package metric = PackMetric(repository); + + ProjectCreator projectA = CreateProject("ProjectA", """ + namespace PackageA; + public static class Producer + { + 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 Voltage Value => Producer.Create(); + } + """, core); + Pack(projectA, "ProjectA", repository, out Package packageA); + + ProjectCreator projectB = CreateProject("ProjectB", """ + using DimensionalQuantities.Extensions.Metric; + namespace PackageB; + public static class Consumer + { + 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 (Voltage value in values) + { + total += value.AsVolts(); + } + return total; + } + } + """, 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); + 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(); + } + """, metric, packageA, packageB); + Build(projectC); + + 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")); + Assembly assemblyC = context.LoadFromAssemblyPath(Path.Combine(outputDirectory, "ProjectC.dll")); + Type producer = assemblyA.GetType("PackageA.Producer", throwOnError: true)!; + Type consumer = assemblyB.GetType("PackageB.Consumer", throwOnError: true)!; + Type application = assemblyC.GetType("Application", throwOnError: true)!; + Type producedType = producer.GetMethod("Create")!.ReturnType; + Type independentlyProducedType = consumer.GetMethod("Create")!.ReturnType; + Type acceptedType = consumer.GetMethod("Read")!.GetParameters().Single().ParameterType; + 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(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) + { + if (!built) + { + throw new InvalidOperationException(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) + { + if (!built) + { + throw new InvalidOperationException(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) + => (double)type.GetMethod(method)!.Invoke(null, arguments)!; + + private ProjectCreator CreateProject(string name, string source, params Package[] packages) + { + string directory = Path.Combine(_scratch, name); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, name + ".cs"), source); + ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: Path.Combine(directory, name + ".csproj"), targetFramework: "netstandard2.0") + .Property("LangVersion", "preview") + .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") + .Property("Version", "1.0.0") + .Property("PackageOutputPath", Path.Combine(directory, "packages")) + .Property("CopyLocalLockFileAssemblies", "true"); + foreach (Package package in packages) + { + creator.ItemPackageReference(package); + } + return creator.Save(); + } + + private sealed class PackageAssemblyContext : System.Runtime.Loader.AssemblyLoadContext, IDisposable + { + private readonly string _directory; + + public PackageAssemblyContext(string directory) : base(isCollectible: true) + { + _directory = directory; + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + string path = Path.Combine(_directory, assemblyName.Name + ".dll"); + return File.Exists(path) ? LoadFromAssemblyPath(path) : null; + } + + public void Dispose() => Unload(); + } + + public void Dispose() + { + Directory.Delete(_scratch, recursive: true); + } + +} diff --git a/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs new file mode 100644 index 0000000..1f7ec60 --- /dev/null +++ b/src/DimensionalQuantities.IntegrationTests/Packaging/SymbolPackageTests.cs @@ -0,0 +1,167 @@ +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", + "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), + () => 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 = assembly.ReadDebugDirectory().Single(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("DimensionalQuantities.Extensions.Metric.MetricUnits.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.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.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/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/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..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() @@ -60,43 +55,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,93 +67,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() - { - 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); - } - - [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) - { - double result = Evaluate(expression); - Assert.Equal(expected, result); - } - private static T Evaluate(string expression) { string source = $$""" @@ -206,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) @@ -218,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; } @@ -229,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/UnitConversionSourceEmitterTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.cs new file mode 100644 index 0000000..a57cfb3 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitConversionSourceEmitterTests.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 UnitConversionSourceEmitterTests +{ + 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; + + {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("UnitConversionSourceEmitterConsumer", + [ + 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.Tests/Generators/UnitProviderGenerationTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs new file mode 100644 index 0000000..7030cfa --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderGenerationTests.cs @@ -0,0 +1,356 @@ +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 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 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() + { + const string source = """ + using DimensionalQuantities.Units.Declarations; + namespace Independent.Units; + [UnitProvider] + internal static class CustomUnits + { + [QuantityUnit(typeof(Temperature))] + [UnitConversion(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..2131d20 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Generators/UnitProviderReaderTests.cs @@ -0,0 +1,125 @@ +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)); + } + + [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); + 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)); + 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); + 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/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..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.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 76e7a30..5f6b978 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.Name)], 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/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/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/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/QuantityGenerator.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantityGenerator.cs index 55a51f7..81bc678 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) { + IncrementalValuesProvider providers = UnitProviderGenerationCoordinator.FindProviders(context); QuantityGenerationPlan defaultPlan = DefaultQuantityDiscovery.Discover(); context.RegisterPostInitializationOutput(output => { @@ -27,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 = UnitProviderGenerationCoordinator.Generate(original, input.Right, output.ReportDiagnostic); + foreach (GeneratedSource source in providerSources) + { + output.AddSource(source.HintName, SourceText.From(source.Text, Encoding.UTF8)); + } + + CSharpCompilation withProviders = original.AddGeneratedSources(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); } diff --git a/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/QuantitySourceEmitter.cs index 653129c..131bc30 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 = UnitConversionSourceEmitter.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/UnitConversionSourceEmitter.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs new file mode 100644 index 0000000..4c5086f --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSourceEmitter.cs @@ -0,0 +1,168 @@ +using System.Globalization; +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Units; +using Microsoft.CodeAnalysis.CSharp; + +namespace DimensionalQuantities.Roslyn.Generators; + +internal static class UnitConversionSourceEmitter +{ + 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, classNamePrefix))); + if (namespaceName.Length == 0) + { + return extensions; + } + + return $""" + namespace {namespaceName}; + + {extensions} + """; + } + + 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), + canonicalExpression.GetScale(), quantity.Units[0].Offset); + ConversionDefinition[] named = quantity.IncludeNamed + ? 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 = quantity.GeneratePrefixConversions ? [generic, .. named] : named; + 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.ExtensionName} 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 {{className}} + { + extension({{quantity.ReceiverType}}) + { + {{factories}} + } + + extension({{quantity.ReceiverType}} {{variableName}}) + { + {{accessors}} + } + } + """; + } + + private static IReadOnlyDictionary CollectConversions( + IReadOnlyList units, int[] generatedPrefixes) + { + 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 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, (exponent, scale, offset)); + } + } + + return conversions; + } + + 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 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 || generatedPrefixes.Length == 0) + { + yield return (expression.GetName(), expression.GetSiScaleExponent(), expression.GetScale(), unit.Offset); + yield break; + } + + foreach (int prefix in generatedPrefixes.Prepend(0)) + { + UnitExpression prefixed = expression.WithPrefix(firstNumerator, prefix); + yield return (prefixed.GetName(expression, firstNumerator), prefixed.GetSiScaleExponent(), prefixed.GetScale(), unit.Offset); + } + } + + 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)}")); + 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)}")); + 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, 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 new file mode 100644 index 0000000..410f91a --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitConversionSpec.cs @@ -0,0 +1,7 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Generators; + +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/UnitProviderGenerationCoordinator.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderGenerationCoordinator.cs new file mode 100644 index 0000000..d5bf835 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderGenerationCoordinator.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 UnitProviderGenerationCoordinator +{ + 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 Generate(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" + UnitConversionSourceEmitter.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.Roslyn/Generators/UnitProviderReader.cs b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs new file mode 100644 index 0000000..4de97fe --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Generators/UnitProviderReader.cs @@ -0,0 +1,236 @@ +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Naming; +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 _conversionAttribute; + 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)); + _conversionAttribute = compilation.GetRequiredType(typeof(UnitConversionAttribute)); + _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()) + { + 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; + } + + int index = groups.FindIndex(group => SymbolEqualityComparer.Default.Equals(group.Type, quantityType)); + if (index < 0) + { + groups.Add((quantityType, unit.Name, [unit])); + } + else + { + groups[index].Units.Add(unit); + } + } + } + + private string GetDimensionName(ITypeSymbol quantityType) + { + _quantityReader.TryRead(quantityType, out DimensionalQuantities.Dimensions.Dimension dimension); + return dimension == default ? "Dimensionless" : DimensionNameGenerator.GetQuantityName(dimension); + } + + private bool TryReadUnitDefinition(INamedTypeSymbol declaration, INamedTypeSymbol provider, + Action report, out UnitDefinition unit) + { + unit = default; + if (!TryReadExpression(declaration, report, out UnitExpression expression)) + { + return false; + } + + AttributeData? conversion = Find(declaration, _conversionAttribute); + double offset = conversion is null ? 0d : GetNamed(conversion, nameof(UnitConversionAttribute.Offset), 0d); + if (!IsFinite(offset)) + { + Report(report, conversion, $"{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, Find(declaration, _quantityAttribute), "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.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 74b66e5..221f677 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")] @@ -33,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() { @@ -46,7 +74,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 +227,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 +261,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 +271,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; @@ -262,4 +290,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.slnx b/src/DimensionalQuantities.slnx index ddef88c..bb1d34b 100644 --- a/src/DimensionalQuantities.slnx +++ b/src/DimensionalQuantities.slnx @@ -3,6 +3,10 @@ + + + + diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index bb64c9f..7cc0794 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -1,38 +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/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/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/AtomicUnits.cs b/src/DimensionalQuantities/Units/AtomicUnits.cs index f114a19..c13bb71 100644 --- a/src/DimensionalQuantities/Units/AtomicUnits.cs +++ b/src/DimensionalQuantities/Units/AtomicUnits.cs @@ -2,23 +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 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"); + 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/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..96859e6 --- /dev/null +++ b/src/DimensionalQuantities/Units/Declarations/QuantityUnitAttribute.cs @@ -0,0 +1,10 @@ +namespace DimensionalQuantities.Units.Declarations; + +/// Associates a unit declaration with a quantity type. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class QuantityUnitAttribute : Attribute +{ + public QuantityUnitAttribute(Type quantityType) => QuantityType = quantityType; + + public Type QuantityType { get; } +} 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/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/EngineeringCatalogue.cs b/src/DimensionalQuantities/Units/EngineeringCatalogue.cs deleted file mode 100644 index 5133e39..0000000 --- a/src/DimensionalQuantities/Units/EngineeringCatalogue.cs +++ /dev/null @@ -1,53 +0,0 @@ -using DimensionalQuantities.Dimensions; - -namespace DimensionalQuantities.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 _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(AtomicUnits.Hertz)]), new("AngularVelocity", [new(AtomicUnits.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(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, 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(Mass: 1, Time: -2, ElectricCurrent: -1), - [ - new("MagneticFluxDensity", [new(AtomicUnits.Tesla)]), - new("MagneticPolarisation", [new(AtomicUnits.Tesla)]), - new("RemanentFluxDensity", [new(AtomicUnits.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)])] }, - }; -} 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/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() { } +} diff --git a/src/DimensionalQuantities/Units/UnitDefinition.cs b/src/DimensionalQuantities/Units/UnitDefinition.cs index b8d7454..8665d52 100644 --- a/src/DimensionalQuantities/Units/UnitDefinition.cs +++ b/src/DimensionalQuantities/Units/UnitDefinition.cs @@ -2,16 +2,18 @@ namespace DimensionalQuantities.Units; internal readonly record struct UnitDefinition { - public UnitDefinition(string quantityName, UnitExpression expression, UnitExpression[]? additionalConversions = null) + 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; } } 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); }