diff --git a/CLAUDE.md b/CLAUDE.md index ccd5bcb1..681191f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,8 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona | `Semantics.Quantities` | Hand-written runtime types (`IPhysicalQuantity`, `PhysicalQuantityCore`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. Every generated quantity is a `readonly record struct`. | | `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. Only the physics-specific half lives here — `Models/`, `Metadata/`, `Generators/`, and the bindings in `SemanticsGenerator`/`SemanticsDiagnostics`/`Emit`. The C# syntax templates come from `ktsu.CodeBlocker.Templates`; the metadata-driven generator base, metadata loading and the diagnostic catalogue come from `ktsu.SourceGeneratorToolkit` (#181, #192). | | `Semantics.Quantities.{Double,Float,Decimal}` | Props-only satellite packages. Each ships a `buildTransitive` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass`. | +| `Semantics.Cpp` | The C++ projection of the quantity vocabulary, in its own project because `ktsu.Coder` ships no `net8.0`. Reads `dimensions.json` and emits one C++ class per dimension and per named overload, plus the declared relationships as operators. | +| `Semantics.Cpp.Test` | Its tests, including one that compiles the whole generated vocabulary with `g++`/`clang++` and one that checks a dimensionally wrong product is refused by the compiler. | | `Semantics.Test` | MSTest project covering all of the above. | ## Semantic quantities architecture (the unified vector model) @@ -76,6 +78,58 @@ These are now baked into the generator and enforced by tests. **Do not reopen wi 4. **Physical constraints are enforced structurally via the V0 (magnitude) form.** `Vector0` factories run `Vector0Guards.EnsureNonNegative` and throw `ArgumentException` on a negative value. That covers absolute zero (Temperature is V0, so Kelvin must be ≥ 0), non-negative frequency, non-negative absolute pressure, etc. A V0 *overload* can opt into a stricter rule by declaring `physicalConstraints: { "minExclusive": "0" }` in `dimensions.json` (#51); the generator then emits `Vector0Guards.EnsurePositive` and rejects zero too. Used today for `Wavelength`, `Period`, and `HalfLife` — quantities for which zero is unphysical. 5. **Logarithmic-scale quantities are generated from `logarithmic.json`, not declared as dimensions.** Decibel scales (`Decibels`, `SoundPressureLevel`, `SoundIntensityLevel`, `SoundPowerLevel`, `DirectionalityIndex`), pitch intervals (`Cents`, `Semitones`), and `PH` don't obey linear arithmetic, so they are emitted by `LogarithmicScalesGenerator` as standalone `readonly partial record struct`s built around `scale = multiplier · log_base(linear / reference)`, converting to and from their linear generated counterparts (`Gain`, `Ratio`, `SoundPressure`, `SoundIntensity`, `SoundPower`, `Concentration`). Bespoke members (named constants like `PH.Neutral`, cross-scale conversions like `Cents`↔`Semitones`) live in hand-written partials next to the metadata-generated core. Adding a new log-scale quantity means adding a `logarithmic.json` entry, plus a partial only if it needs bespoke members. +### The C++ projection + +`Semantics.Cpp` emits the same vocabulary as C++, for consumers that are not .NET — Holotype is the +one driving it. Two layers, and both earn their place: + +- **Structural.** `Quantity` over a `Dimension` of eight integer exponents. Shipped as a prelude + rather than generated, because none of it is derived from the metadata. It is what gives a + product nobody declared a type at all. +- **Nominal.** One class per dimension and per named overload — `Length`, `Speed`, `Weight`. This is + what the exponents cannot do: **72 dimensions share 63 exponent vectors**, so `Area` and + `NuclearCrossSection`, `Torque` and `Energy`, `AbsorbedDose` and `EquivalentDose` are each one + vector between two names. + +**Eight axes, not the seven in `dimensionalFormula` before.** `angle` is carried by +`AngularDisplacement`, `AngularVelocity`, `AngularAcceleration` and `AngularJerk`, and that is the +whole of it. Without it an angle is the same type as a ratio and an angular speed the same type as +a frequency; with it, 61 distinct exponent vectors become 63. It is read by the C++ projection and +carried through `DimensionInfo` on the .NET side, where nothing depends on it yet. + +**A relationship is checked before it is emitted.** The operator is written as +`Result{ lhs.value() * rhs.value() }`, so the exponents have to agree with the declared result or it +does not compile — which makes every claim in `integrals` and `derivatives` checkable. A claim they +contradict is refused by name, with both dimensions written out, rather than emitted as something +broken. Four are refused as the metadata stands: + +| Refused | Why | +|---|---| +| `Torque * AngularDisplacement -> Energy` | rotational cluster | +| `MomentOfInertia * AngularVelocity -> AngularMomentum` | rotational cluster | +| `MomentOfInertia * AngularAcceleration -> Torque` | rotational cluster | +| `Sensitivity * Pressure -> ElectricPotential` | **pre-existing metadata bug** | + +The first three are not fixable by choosing different angle exponents, and that is provable rather +than a matter of opinion: `Torque * AngularDisplacement -> Energy` forces torque's angle exponent to +−1, and `Force x Length -> Torque` forces it to 0. It is the classic r×F versus τ·θ contradiction, +and is why SI keeps the radian dimensionless. The nominal layer is what separates torque from +energy; the exponents cannot. + +The fourth is unrelated to angle and was already wrong: `Sensitivity` is declared as A/Pa +(`M⁻¹L⁻¹T²I`) while the relationship treats it as V/Pa. One of the two is wrong and it is a physics +call, so it is reported rather than guessed at. + +**How the generated code is written is measured, not chosen.** See the header of +`CppQuantityGenerator` — the same vocabulary written two ways measured 0.9896 and 1.4004 against +bare floats on MSVC while GCC and clang folded both away, so the wrong formulation passes on three +compilers of four. + +**Not generated yet:** the vector forms. `dimensions.json` declares 122 dimension-and-form entries +and this projects the 72 magnitude forms plus their 90 overloads. The vector forms are distinct +classes too and need componentwise operations, which have their own rule (expand at compile time, +never loop over an index) — so they are deliberately not half-done. + ### Physical constants `PhysicalConstants` is **generated** from `domains.json`. Public surface: diff --git a/Directory.Packages.props b/Directory.Packages.props index b0017be4..563856b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,10 @@ + + diff --git a/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs new file mode 100644 index 00000000..a6171a8b --- /dev/null +++ b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp.Test; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using ktsu.Semantics.Cpp; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Covers the C++ projection of the quantity vocabulary, driven by the real metadata. +/// +[TestClass] +public sealed class CppQuantityGeneratorTests +{ + /// + /// Generated once. Every test below reads the same output, because generating it is the + /// expensive part and none of them change it. + /// + private static CppQuantityOutput Output { get; } = new CppQuantityGenerator( + new CppQuantityOptions { Namespace = "holo" }).Generate(Metadata()); + + private static QuantityMetadata Metadata() => + QuantityMetadata.Parse(File.ReadAllText(Path.Join(AppContext.BaseDirectory, "Metadata", "dimensions.json"))); + + /// + /// The metadata is read at all, which is the one thing every other test rests on. + /// + [TestMethod] + public void ReadsTheMetadata() + { + QuantityMetadata metadata = Metadata(); + + Assert.IsGreaterThan(60, metadata.PhysicalDimensions.Count); + Assert.IsTrue( + metadata.PhysicalDimensions.Any(dimension => string.Equals(dimension.Name, "Length", StringComparison.Ordinal)), + "Length is the dimension everything else is checked against; the file should declare it."); + } + + /// + /// A class per dimension and per named overload, plus the prelude and the two roll-ups. + /// + [TestMethod] + public void GeneratesAHeaderPerQuantity() + { + Assert.Contains("Length.hpp", Output.Files.Keys); + Assert.Contains("Speed.hpp", Output.Files.Keys); + Assert.Contains("Weight.hpp", Output.Files.Keys, "an overload is a type of its own, not an alias"); + Assert.Contains("dimension.hpp", Output.Files.Keys); + Assert.Contains("quantity.hpp", Output.Files.Keys); + Assert.Contains("relationships.hpp", Output.Files.Keys); + Assert.Contains("quantities.hpp", Output.Files.Keys); + } + + /// + /// The target's namespace reaches the prelude as well as the generated classes: the prelude is + /// substituted rather than fixed, which is the one thing about it that is not literal. + /// + [TestMethod] + public void PutsEverythingInTheTargetsNamespace() + { + Assert.Contains("namespace holo", Output.Files["quantity.hpp"], StringComparison.Ordinal); + Assert.Contains("namespace holo", Output.Files["Length.hpp"], StringComparison.Ordinal); + Assert.DoesNotContain("@NAMESPACE@", Output.Files["dimension.hpp"], StringComparison.Ordinal); + Assert.DoesNotContain("@BANNER@", Output.Files["dimension.hpp"], StringComparison.Ordinal); + } + + /// + /// A quantity is a distinct class over a Quantity of its exponents, and the exponents + /// are written as the significant prefix rather than as eight numbers. + /// + [TestMethod] + public void WritesADimensionAsItsSignificantPrefix() + { + Assert.Contains("using underlying = Quantity>;", Output.Files["Length.hpp"], StringComparison.Ordinal); + + // Velocity is L T⁻¹, so the prefix runs to the third axis and stops. + Assert.Contains("using underlying = Quantity>;", Output.Files["Speed.hpp"], StringComparison.Ordinal); + } + + /// + /// The eighth axis is what the four angular dimensions are for, and this is the pair that + /// justified adding it: an angular speed and a frequency are both per-second and must not be + /// the same type. + /// + [TestMethod] + public void SeparatesAnAngularSpeedFromAFrequency() + { + string angular = Output.Files["AngularSpeed.hpp"]; + string frequency = Output.Files["Frequency.hpp"]; + + Assert.Contains("Quantity>", angular, StringComparison.Ordinal); + Assert.Contains("Quantity>", frequency, StringComparison.Ordinal); + } + + /// + /// An overload widens implicitly into its base and narrows back explicitly, which is the + /// convention ktsu.Schema holds for a semantic type and this library holds for an + /// overload. The two agreed about it independently. + /// + [TestMethod] + public void WidensImplicitlyAndNarrowsExplicitly() + { + string weight = Output.Files["Weight.hpp"]; + + Assert.Contains("operator ForceMagnitude()", weight, StringComparison.Ordinal); + Assert.Contains("static constexpr Weight from(ForceMagnitude value)", weight, StringComparison.Ordinal); + Assert.Contains("using refines = ForceMagnitude;", weight, StringComparison.Ordinal); + } + + /// + /// A magnitude cannot be negative, and the check is compiled out of the build the zero-cost + /// claim is measured in. + /// + [TestMethod] + public void GuardsAMagnitudeInDebugOnly() + { + Assert.Contains("assert(value.count() >= 0", Output.Files["Length.hpp"], StringComparison.Ordinal); + } + + /// + /// Three overloads declare that zero is unphysical for them too, and they get the stricter + /// comparison rather than the shared one. + /// + [TestMethod] + public void GuardsAStrictlyPositiveQuantityMoreTightly() + { + Assert.Contains("assert(value.count() > 0", Output.Files["Wavelength.hpp"], StringComparison.Ordinal); + Assert.Contains("assert(value.count() >= 0", Output.Files["Length.hpp"], StringComparison.Ordinal); + } + + /// + /// Rule two of the four the zero-cost measurement produced: an accessor returns a reference. + /// + /// + /// Returning the component by value instead was one of the three differences that took the + /// generated shape from 0.9896 to 1.4004 on MSVC while GCC and clang folded both away. + /// + [TestMethod] + public void ReturnsTheUnderlyingValueByReference() + { + Assert.Contains("const underlying& value() const", Output.Files["Length.hpp"], StringComparison.Ordinal); + } + + /// + /// Rule three: the arithmetic stays in Quantity space rather than unwrapping to a + /// number and rewrapping the result. + /// + /// + /// This is also what makes a declared relationship checkable. value() returns a + /// Quantity, so its product carries the summed exponents and only converts to the + /// declared result when they agree. + /// + [TestMethod] + public void KeepsRelationshipArithmeticInQuantitySpace() + { + string relationships = Output.Files["relationships.hpp"]; + + Assert.Contains("lhs.value() * rhs.value()", relationships, StringComparison.Ordinal); + Assert.DoesNotContain("lhs.count()", relationships, StringComparison.Ordinal); + } + + /// + /// The operators the metadata declares are generated as free functions. + /// + [TestMethod] + public void GeneratesTheDeclaredRelationships() + { + string relationships = Output.Files["relationships.hpp"]; + + // Speed integrated over time is a length, which is the relationship every other one is + // checked by analogy with. + Assert.Contains("Length operator*(Speed lhs, Duration rhs)", relationships, StringComparison.Ordinal); + } + + /// + /// A relationship the exponents contradict is refused by name rather than emitted as something + /// that cannot compile. + /// + /// + /// Four are refused on the metadata as it stands. Three are the rotational cluster, where no + /// assignment of angle exponents can satisfy both Force x Length -> Torque and + /// Torque * AngularDisplacement -> Energy -- the two force the same exponent to be 0 + /// and -1. The fourth was already wrong before angle existed. + /// + [TestMethod] + public void RefusesARelationshipTheExponentsContradict() + { + IReadOnlyList refused = Output.Refused; + + Assert.IsTrue( + refused.Any(issue => issue.Contains("Sensitivity * Pressure", StringComparison.Ordinal)), + $"expected the pre-existing metadata error to be caught; got: {string.Join(" | ", refused)}"); + + Assert.IsTrue( + refused.All(issue => issue.Contains("is not dimensionally true", StringComparison.Ordinal) + || issue.Contains("does not declare", StringComparison.Ordinal)), + $"every refusal should say which of the two things went wrong; got: {string.Join(" | ", refused)}"); + } + + /// + /// Nothing refused is also generated: a refusal has to mean the operator is absent, or it is + /// only a log line. + /// + [TestMethod] + public void DoesNotGenerateWhatItRefused() + { + string relationships = Output.Files["relationships.hpp"]; + + Assert.DoesNotContain("ElectricPotential operator*(Sensitivity", relationships, StringComparison.Ordinal); + Assert.DoesNotContain("Torque operator*(MomentOfInertia", relationships, StringComparison.Ordinal); + } + + /// + /// Every generated file says where it came from and that editing it is pointless. + /// + [TestMethod] + public void SaysItIsGenerated() + { + foreach ((string name, string text) in Output.Files) + { + Assert.Contains("Do not edit", text, StringComparison.Ordinal, $"{name} should say it is generated"); + } + } +} diff --git a/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs new file mode 100644 index 00000000..5f39b335 --- /dev/null +++ b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp.Test; + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; + +using ktsu.Semantics.Cpp; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Compiles what the generator emits. +/// +/// +/// Asserting on the text says the generator wrote what was expected; only a compiler says the +/// expectation was right. The whole vocabulary is put through one translation unit with warnings +/// on, which is the cheapest check that catches a missing include, a name that collides at +/// namespace scope, or a dimension written with the wrong number of arguments. +/// +/// Skipped where no compiler is on PATH, which is how this behaves on a Windows runner. It is not +/// skipped silently: a run that never compiled anything reports inconclusive rather than green. +/// +/// +[TestClass] +public sealed class GeneratedCppCompilesTests +{ + private static string? Compiler => Find("g++") ?? Find("clang++"); + + /// + /// The whole vocabulary compiles, warnings included. + /// + [TestMethod] + public void TheWholeVocabularyCompiles() + { + string directory = Emit(); + File.WriteAllText(Path.Join(directory, "main.cpp"), "#include \"quantities.hpp\"\nint main() { return 0; }\n"); + + (int exitCode, string output) = Compile(directory, "main.cpp"); + + Assert.AreEqual(0, exitCode, $"the generated vocabulary should compile clean:\n{output}"); + } + + /// + /// The structural layer actually checks the nominal one, rather than being carried along + /// beside it. + /// + /// + /// This is the negative half of the claim the generator rests on. A relationship is emitted as + /// Result{ lhs.value() * rhs.value() }, so the exponents have to agree with the declared + /// result -- which is only worth saying if a disagreement really is a compile error. Here the + /// product of two quantities is handed to a third whose dimension is something else, and the + /// test fails if that is accepted. + /// + [TestMethod] + public void AProductWithTheWrongDimensionDoesNotCompile() + { + string directory = Emit(); + File.WriteAllText(Path.Join(directory, "wrong.cpp"), """ + #include "Length.hpp" + #include "Duration.hpp" + #include "Speed.hpp" + + // A length times a duration is L T, and Speed is L T⁻¹. If this compiles, the + // exponents are decoration and every relationship the generator "checked" was + // checked against nothing. + holo::Speed wrong(holo::Length l, holo::Duration d) + { + return holo::Speed{ l.value() * d.value() }; + } + + int main() { return 0; } + """); + + (int exitCode, _) = Compile(directory, "wrong.cpp"); + + Assert.AreNotEqual(0, exitCode, "a product whose exponents do not match the result type should be refused"); + } + + private static string Emit() + { + string directory = Path.Join(Path.GetTempPath(), $"semantics-cpp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + CppQuantityOutput output = new CppQuantityGenerator(new CppQuantityOptions { Namespace = "holo" }) + .Generate(QuantityMetadata.Parse(File.ReadAllText( + Path.Join(AppContext.BaseDirectory, "Metadata", "dimensions.json")))); + + foreach ((string name, string text) in output.Files) + { + File.WriteAllText(Path.Join(directory, name), text); + } + + return directory; + } + + private static (int ExitCode, string Output) Compile(string directory, string file) + { + string? compiler = Compiler; + if (compiler is null) + { + Assert.Inconclusive("no C++ compiler on PATH, so the generated headers were not compiled."); + } + + using Process process = new() + { + StartInfo = new ProcessStartInfo(compiler!) + { + WorkingDirectory = directory, + RedirectStandardError = true, + RedirectStandardOutput = true, + }, + }; + + foreach (string argument in (string[])["-std=c++20", "-Wall", "-Wextra", "-fsyntax-only", "-I.", file]) + { + process.StartInfo.ArgumentList.Add(argument); + } + + process.Start(); + string output = process.StandardError.ReadToEnd() + process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + return (process.ExitCode, output); + } + + private static string? Find(string executable) + { + // The name alone, and joined rather than combined: a PATH entry is the directory, so a + // candidate that turned out to be rooted would silently be the answer instead of a + // directory's file. + string name = Path.GetFileName(executable); + + return (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => Path.Join(directory, name)) + .FirstOrDefault(File.Exists); + } +} diff --git a/Semantics.Cpp.Test/Semantics.Cpp.Test.csproj b/Semantics.Cpp.Test/Semantics.Cpp.Test.csproj new file mode 100644 index 00000000..fe1a6609 --- /dev/null +++ b/Semantics.Cpp.Test/Semantics.Cpp.Test.csproj @@ -0,0 +1,27 @@ + + + + + + true + + + + + + + + + + + + + + + + net10.0;net9.0 + + diff --git a/Semantics.Cpp/CppQuantityGenerator.cs b/Semantics.Cpp/CppQuantityGenerator.cs new file mode 100644 index 00000000..e012c596 --- /dev/null +++ b/Semantics.Cpp/CppQuantityGenerator.cs @@ -0,0 +1,441 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reflection; + +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; + +/// +/// Projects the quantity metadata into C++. +/// +/// +/// The vocabulary is two layers and this generates the upper one. Underneath is +/// Quantity<D> over an eight-exponent Dimension, which is shipped rather than +/// generated because no part of it is derived from the metadata. On top is one class per dimension +/// and per named overload -- Length, Speed, Weight -- because the exponents +/// cannot tell every pair of quantities apart: 72 dimensions share 63 exponent vectors, so +/// Area and NuclearCrossSection are one vector between two names, and naming them is +/// the only thing that separates them. +/// +/// How the generated code is written is not a matter of taste, and this is the part to read +/// before changing anything here. The same vocabulary written two ways measured 0.9896 and +/// 1.4004 against bare floats on MSVC, while GCC and clang folded both away completely -- so the +/// wrong formulation passes on three compilers of four, and a generator emits whichever one it was +/// written to emit, once per type, hundreds of times. Four rules came out of measuring that: +/// +/// +/// A produced value is built in its own initialiser, never default-constructed and then +/// assigned into. The second shape cost 22% on MSVC on its own. +/// An accessor returns a reference, not a copy. +/// Arithmetic stays in Quantity space rather than unwrapping to a number and +/// rewrapping the result, which is work the optimiser then has to undo. +/// A componentwise operation expands at compile time rather than looping over an index. +/// That one applies to the vector forms, which this does not generate yet. +/// +/// +/// Rule three is also why a relationship is checked before it is emitted. The operator is written +/// as Energy{ f.value() * d.value() }, so the exponents have to agree with the declared +/// result or it does not compile -- which makes the metadata's claims checkable, and means a claim +/// that is not true is refused by name rather than emitted as something broken. See +/// . +/// +/// +/// What the target says about how it wants this spelled. +public sealed class CppQuantityGenerator(CppQuantityOptions options) +{ + private const string UnderlyingAlias = "underlying"; + private const string ValueField = "value_"; + private const string ValueName = "value"; + private const string DimensionTemplate = "Dimension"; + private const string QuantityTemplate = "Quantity"; + private const string PreludeNamespaceToken = "@NAMESPACE@"; + private const string PreludeBannerToken = "@BANNER@"; + + /// + /// Initializes a new instance of the class with the + /// defaults. + /// + public CppQuantityGenerator() + : this(new CppQuantityOptions()) + { + } + + /// Gets what the target said. + public CppQuantityOptions Options { get; } = options; + + /// + /// Generates the whole vocabulary. + /// + /// The deserialised dimensions.json. + /// Every file to write, keyed by name, and everything the metadata asked for that + /// could not be honoured. + public CppQuantityOutput Generate(QuantityMetadata metadata) + { + Ensure.NotNull(metadata); + + QuantityVocabulary vocabulary = QuantityVocabulary.FromMetadata(metadata); + Dictionary files = []; + + foreach ((string name, string text) in Prelude()) + { + files[name] = text; + } + + CppGenerator writer = new(); + + foreach (QuantityType type in vocabulary.Types) + { + files[$"{type.Name}{Options.HeaderExtension}"] = writer.Generate(Quantity(type)); + } + + files[$"relationships{Options.HeaderExtension}"] = writer.Generate(Relationships(vocabulary)); + files[$"quantities{Options.HeaderExtension}"] = writer.Generate(Umbrella(vocabulary)); + + return new CppQuantityOutput( + new ReadOnlyDictionary(files), + [.. vocabulary.Refused.Select(issue => issue.ToString())]); + } + + /// + /// The two headers that are shipped rather than generated, with the target's namespace put in. + /// + private IEnumerable<(string Name, string Text)> Prelude() + { + Assembly assembly = typeof(CppQuantityGenerator).Assembly; + + foreach (string resource in assembly.GetManifestResourceNames().Where(name => name.EndsWith(".hpp", StringComparison.Ordinal))) + { + using Stream stream = assembly.GetManifestResourceStream(resource)!; + using StreamReader reader = new(stream); + + string text = reader.ReadToEnd() + .Replace(PreludeNamespaceToken, Options.Namespace, StringComparison.Ordinal) + .Replace(PreludeBannerToken, Banner(), StringComparison.Ordinal); + + // The resource name is the project's default namespace, a folder and the file name; + // only the last two parts are the file. + string[] parts = resource.Split('.'); + yield return ($"{parts[^2]}.{parts[^1]}", text.ReplaceLineEndings("\n")); + } + } + + private string Banner() => $"Generated by {Options.GeneratedBy}. Do not edit."; + + /// + /// One quantity class: a distinct type wrapping a Quantity of its dimension. + /// + private SourceFile Quantity(QuantityType type) + { + ClassDeclaration declaration = new(type.Name); + + if (!string.IsNullOrEmpty(type.Description)) + { + declaration.Documentation.Add(type.Description); + } + + declaration.Documentation.Add($"dimension: {type.Dimension}"); + + SortedSet includes = [$"\"quantity{Options.HeaderExtension}\""]; + + if (type.Magnitude is not Magnitude.Signed) + { + includes.Add(""); + } + + declaration.Members.Add(new UsingAlias( + UnderlyingAlias, + $"{QuantityTemplate}<{type.Dimension.ToCpp(DimensionTemplate)}>")); + + if (type.Refines is not null) + { + includes.Add($"\"{type.Refines}{Options.HeaderExtension}\""); + declaration.Members.Add(new UsingAlias("refines", type.Refines)); + } + + declaration.Members.Add(new FunctionDeclaration(type.Name) + { + Kind = FunctionKind.Constructor, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + Definition = FunctionDefinition.Defaulted, + }); + + declaration.Members.Add(FromUnderlying(type)); + declaration.Members.Add(Accessor()); + + if (type.Refines is not null) + { + declaration.Members.Add(Widening(type)); + declaration.Members.Add(Narrowing(type)); + } + + declaration.Members.Add(Comparison(type.Name, "==", "bool")); + declaration.Members.Add(Comparison(type.Name, "<=>", "auto")); + declaration.Members.Add(new FieldDeclaration(ValueField, UnderlyingAlias) { Visibility = Visibility.Private }); + + SourceFile file = new(type.Name) { IsHeader = true }; + Preamble(file, includes); + file.Members.Add(Namespaced(declaration)); + return file; + } + + /// + /// The explicit constructor, which is also where a magnitude's floor is enforced. + /// + /// + /// Debug only. A magnitude that goes negative is a bug upstream of here, so the build people + /// work in should say so and the build the zero-cost claim is measured in should have nothing + /// to elide. NDEBUG is what tells the two apart, and assert is already compiled + /// out by it, so no guard of our own is needed around it. + /// + private static FunctionDeclaration FromUnderlying(QuantityType type) + { + FunctionDeclaration constructor = new(type.Name) + { + Kind = FunctionKind.Constructor, + IsExplicit = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + }; + + constructor.Documentation.Add($"Explicit: a bare value never becomes {Article(type.Name)} {type.Name} by accident."); + constructor.Parameters.Add(new Parameter(ValueName, UnderlyingAlias)); + constructor.Initialisers.Add(new MemberInitialiser(ValueField, new VariableReference(Guarded(type)))); + + return constructor; + } + + /// + /// The member initialiser, with a magnitude's floor checked on the way past. + /// + /// + /// The check rides in the initialiser rather than sitting in the constructor's body, and that + /// is a limitation rather than a preference: ktsu.Coder has no expression-statement + /// node, so a call made for its effect -- assert(...), and every other void call -- is + /// not something the AST can currently say. A comma expression in the initialiser is the + /// recognised way to check a precondition in a constexpr constructor, so this is a + /// legitimate spelling rather than a workaround wearing a disguise, but the reason it was + /// chosen is that the alternative could not be written. + /// + /// assert is already compiled out by NDEBUG, so no guard of our own is needed + /// around it: the build people work in says so, and the build the zero-cost claim is measured + /// in has nothing to elide. + /// + /// + private static string Guarded(QuantityType type) + { + if (type.Magnitude is Magnitude.Signed) + { + return ValueName; + } + + string comparison = type.Magnitude == Magnitude.Positive ? ">" : ">="; + string says = type.Magnitude == Magnitude.Positive + ? $"{Article(type.Name)} {type.Name} of zero is not a physical value" + : $"{Article(type.Name)} {type.Name} cannot be negative"; + + return $"(assert({ValueName}.count() {comparison} 0 && \"{says}\"), {ValueName})"; + } + + private static FunctionDeclaration Accessor() + { + FunctionDeclaration accessor = new(ValueName) + { + // A reference rather than a copy, which on MSVC is the difference between a register + // and a spill. Rule two. + ReturnType = $"const {UnderlyingAlias}&", + IsPure = true, + IsCompileTimeEvaluable = true, + IsReadOnly = true, + IsNoThrow = true, + }; + + accessor.Documentation.Add("Named, because getting the value back out is a decision too."); + accessor.Body.Add(new ReturnStatement(new VariableReference(ValueField))); + return accessor; + } + + private static FunctionDeclaration Widening(QuantityType type) + { + FunctionDeclaration widening = new(type.Refines!) + { + Kind = FunctionKind.ConversionOperator, + ReturnType = type.Refines, + IsPure = true, + IsCompileTimeEvaluable = true, + IsReadOnly = true, + IsNoThrow = true, + }; + + widening.Documentation.Add($"Widening is implicit: this is {Article(type.Refines!)} {type.Refines}."); + widening.Body.Add(new ReturnStatement( + new ConstructionExpression(type.Refines) { Arguments = { new VariableReference(ValueField) } })); + + return widening; + } + + private static FunctionDeclaration Narrowing(QuantityType type) + { + FunctionDeclaration narrowing = new("from") + { + ReturnType = type.Name, + IsPure = true, + IsStatic = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + }; + + narrowing.Documentation.Add( + $"Narrowing is explicit and named: not every {type.Refines} is {Article(type.Name)} {type.Name}."); + narrowing.Parameters.Add(new Parameter(ValueName, type.Refines!)); + narrowing.Body.Add(new ReturnStatement(new ConstructionExpression(type.Name) + { + Arguments = { new VariableReference($"{ValueName}.{ValueName}()") }, + })); + + return narrowing; + } + + private static FunctionDeclaration Comparison(string name, string symbol, string returnType) + { + FunctionDeclaration comparison = new(symbol) + { + Kind = FunctionKind.Operator, + ReturnType = returnType, + IsPure = true, + IsFriend = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + Definition = FunctionDefinition.Defaulted, + }; + + comparison.Parameters.Add(new Parameter(string.Empty, name)); + comparison.Parameters.Add(new Parameter(string.Empty, name)); + return comparison; + } + + /// + /// The operators the metadata declares, as free functions. + /// + /// + /// In one file rather than beside their operands, because a relationship belongs to neither of + /// the two types it joins and putting it in one of their headers would decide arbitrarily + /// which of them a program has to include to multiply. + /// + private SourceFile Relationships(QuantityVocabulary vocabulary) + { + SourceFile file = new("relationships") { IsHeader = true }; + + SortedSet includes = []; + List operators = []; + + foreach (QuantityRelationship relationship in vocabulary.Relationships) + { + foreach (string named in (string[])[relationship.Left, relationship.Right, relationship.Result]) + { + includes.Add($"\"{named}{Options.HeaderExtension}\""); + } + + FunctionDeclaration declaration = new(relationship.Symbol) + { + Kind = FunctionKind.Operator, + ReturnType = relationship.Result, + IsPure = true, + IsCompileTimeEvaluable = true, + IsNoThrow = true, + }; + + declaration.Documentation.Add(relationship.ToString()); + declaration.Parameters.Add(new Parameter("lhs", relationship.Left)); + declaration.Parameters.Add(new Parameter("rhs", relationship.Right)); + + // Rule three: the arithmetic stays in Quantity space. That is also what makes the + // declared relationship checkable -- if the exponents disagreed with the result type + // this would not compile, which is why a relationship that disagrees is never + // generated in the first place. + declaration.Body.Add(new ReturnStatement(new ConstructionExpression(relationship.Result) + { + Arguments = { new VariableReference($"lhs.{ValueName}() {relationship.Symbol} rhs.{ValueName}()") }, + })); + + operators.Add(declaration); + } + + Preamble(file, includes); + file.HeaderComment.Add(string.Empty); + file.HeaderComment.Add($"{vocabulary.Relationships.Count} relationships, from the integrals and derivatives"); + file.HeaderComment.Add("dimensions.json declares. Each is checked against the exponents before it is"); + file.HeaderComment.Add("written, so every operator here is one the dimensions agree with."); + + file.Members.Add(Namespaced([.. operators])); + return file; + } + + /// + /// One header that includes the whole vocabulary, for a program that does not want to track + /// which quantity lives where. + /// + private SourceFile Umbrella(QuantityVocabulary vocabulary) + { + SourceFile file = new("quantities") { IsHeader = true }; + + SortedSet includes = [$"\"relationships{Options.HeaderExtension}\""]; + foreach (QuantityType type in vocabulary.Types) + { + includes.Add($"\"{type.Name}{Options.HeaderExtension}\""); + } + + Preamble(file, includes); + file.HeaderComment.Add(string.Empty); + file.HeaderComment.Add($"{vocabulary.Types.Count} quantity types over {DistinctDimensions(vocabulary)} distinct dimensions."); + return file; + } + + private static int DistinctDimensions(QuantityVocabulary vocabulary) => + vocabulary.Types.Select(type => type.Dimension).Distinct().Count(); + + private void Preamble(SourceFile file, IEnumerable includes) + { + file.HeaderComment.Add(Banner()); + file.HeaderComment.Add(string.Empty); + file.HeaderComment.Add("Editing this file is editing the wrong thing: it is derived from the quantity"); + file.HeaderComment.Add("metadata, and the next build overwrites it. Change dimensions.json instead."); + + foreach (string include in includes) + { + file.Imports.Add(include); + } + } + + private NamespaceDeclaration Namespaced(params AstNode[] members) + { + NamespaceDeclaration space = new(Options.Namespace); + foreach (AstNode member in members) + { + space.Members.Add(member); + } + + return space; + } + + private static string Article(string name) => + "AEIOU".Contains(char.ToUpperInvariant(name[0]), StringComparison.Ordinal) ? "an" : "a"; +} + +/// +/// What the generator produced, and what it would not. +/// +/// Every file to write, keyed by name. +/// +/// What the metadata asked for that the exponents contradict, each named with both dimensions +/// written out. Empty is the expected state; anything here is a metadata bug rather than a +/// generator limitation. +/// +public sealed record CppQuantityOutput(IReadOnlyDictionary Files, IReadOnlyList Refused); diff --git a/Semantics.Cpp/CppQuantityOptions.cs b/Semantics.Cpp/CppQuantityOptions.cs new file mode 100644 index 00000000..ef1a7e25 --- /dev/null +++ b/Semantics.Cpp/CppQuantityOptions.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp; + +/// +/// What a target says about how it wants the quantity vocabulary spelled. +/// +/// +/// Deliberately short. Almost nothing here is a choice: the names come from the metadata, the +/// arithmetic comes from the prelude, and the shape of a generated class is settled by what it has +/// to compile to rather than by taste. What is left is where the types live and what the file says +/// at the top. +/// +public sealed record CppQuantityOptions +{ + /// + /// Gets the namespace the vocabulary is generated into. + /// + /// + /// A target puts these types in its own namespace rather than one this library picks, because + /// a program reading Length should see its own engine's name in front of it. + /// + public string Namespace { get; init; } = "ktsu"; + + /// + /// Gets the extension a generated header is given, including its leading dot. + /// + public string HeaderExtension { get; init; } = ".hpp"; + + /// + /// Gets the line a generated file's banner opens with. + /// + /// + /// The reader of a generated file wants the name of the thing they would run again, which is + /// not necessarily this library: a target that drives it from its own build step says so. + /// + public string GeneratedBy { get; init; } = "ktsu.Semantics.Cpp"; +} diff --git a/Semantics.Cpp/DimensionVector.cs b/Semantics.Cpp/DimensionVector.cs new file mode 100644 index 00000000..df375da7 --- /dev/null +++ b/Semantics.Cpp/DimensionVector.cs @@ -0,0 +1,169 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp; + +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +/// +/// The eight exponents a dimension is, and the arithmetic over them. +/// +/// +/// This is the C# side of the Dimension template the prelude ships, and it exists so the +/// generator can answer one question the metadata does not: whether a declared relationship is +/// dimensionally true. Force * Length -> Torque is a claim, and the exponents are what +/// check it. +/// +/// Eight axes rather than SI's seven, because angle is the one quantity this system is stricter +/// about than SI: without it an angle and a ratio are one type, and an angular velocity and a +/// frequency are one type. +/// +/// +internal readonly record struct DimensionVector +{ + /// + /// The axis names, in the order the Dimension template takes them. + /// + /// + /// These are the keys dimensions.json writes, except angle, which the metadata + /// did not have until the C++ projection needed it. The order is the template's parameter + /// order and so is load-bearing: a dimension is written as its non-zero prefix, and a prefix + /// only means anything if everyone agrees where each axis sits. + /// + internal static readonly string[] Axes = + [ + "length", "mass", "time", "angle", "electricCurrent", "temperature", "amountOfSubstance", "luminousIntensity", + ]; + + private readonly int[] exponents; + + private DimensionVector(int[] exponents) => this.exponents = exponents; + + /// + /// Reads a dimension out of a metadata entry's dimensionalFormula. + /// + /// The axis-to-exponent map, which names only its non-zero axes. + /// The exponents, with every axis the map omits at zero. + internal static DimensionVector FromFormula(IReadOnlyDictionary formula) + { + int[] values = new int[Axes.Length]; + for (int axis = 0; axis < Axes.Length; axis++) + { + values[axis] = formula.TryGetValue(Axes[axis], out int exponent) ? exponent : 0; + } + + return new DimensionVector(values); + } + + /// Gets the exponent on one axis. + internal int this[int axis] => exponents is null ? 0 : exponents[axis]; + + /// Adds two dimensions, which is what multiplying two quantities does. + public static DimensionVector operator +(DimensionVector left, DimensionVector right) => + Combine(left, right, static (a, b) => a + b); + + /// Subtracts two dimensions, which is what dividing two quantities does. + public static DimensionVector operator -(DimensionVector left, DimensionVector right) => + Combine(left, right, static (a, b) => a - b); + + private static DimensionVector Combine(DimensionVector left, DimensionVector right, System.Func how) + { + int[] values = new int[Axes.Length]; + for (int axis = 0; axis < Axes.Length; axis++) + { + values[axis] = how(left[axis], right[axis]); + } + + return new DimensionVector(values); + } + + /// + public bool Equals(DimensionVector other) + { + for (int axis = 0; axis < Axes.Length; axis++) + { + if (this[axis] != other[axis]) + { + return false; + } + } + + return true; + } + + /// + public override int GetHashCode() + { + System.HashCode hash = new(); + for (int axis = 0; axis < Axes.Length; axis++) + { + hash.Add(this[axis]); + } + + return hash.ToHashCode(); + } + + /// + /// Writes the dimension as the C++ template argument list, trimmed to its significant prefix. + /// + /// + /// Every exponent defaults to zero in the template, so Dimension<1, 0, -2> says + /// what Dimension<1, 0, -2, 0, 0, 0, 0, 0> says with five fewer numbers to read + /// past. A dimension whose exponents are all zero is the empty list. + /// + /// How the target spells the dimension template. + /// The type, as C++. + internal string ToCpp(string @template) + { + int significant = Axes.Length; + while (significant > 0 && this[significant - 1] == 0) + { + significant--; + } + + if (significant == 0) + { + // Written out rather than left as `Dimension<>` so the degenerate case reads as a + // deliberate choice rather than an argument list someone forgot to fill in. + return $"{@template}<0>"; + } + + List arguments = []; + for (int axis = 0; axis < significant; axis++) + { + arguments.Add(this[axis].ToString(CultureInfo.InvariantCulture)); + } + + return $"{@template}<{string.Join(", ", arguments)}>"; + } + + /// + /// Writes the dimension the way a physicist would, for a comment. + /// + /// Something like M L T⁻², or 1 when there is nothing to say. + public override string ToString() + { + string[] symbols = ["L", "M", "T", "A", "I", "Θ", "N", "J"]; + List parts = []; + for (int axis = 0; axis < Axes.Length; axis++) + { + int exponent = this[axis]; + if (exponent == 0) + { + continue; + } + + parts.Add(exponent == 1 ? symbols[axis] : $"{symbols[axis]}{Superscript(exponent)}"); + } + + return parts.Count == 0 ? "1" : string.Join(" ", parts); + } + + private static string Superscript(int exponent) + { + string digits = System.Math.Abs(exponent).ToString(CultureInfo.InvariantCulture); + string raised = string.Concat(digits.Select(digit => "⁰¹²³⁴⁵⁶⁷⁸⁹"[digit - '0'])); + return exponent < 0 ? $"⁻{raised}" : raised; + } +} diff --git a/Semantics.Cpp/Prelude/dimension.hpp b/Semantics.Cpp/Prelude/dimension.hpp new file mode 100644 index 00000000..65b8cba0 --- /dev/null +++ b/Semantics.Cpp/Prelude/dimension.hpp @@ -0,0 +1,72 @@ +// @BANNER@ +// +// The exponent vector every quantity is tagged with, and the arithmetic over it. +// +// Nothing here is derived from the metadata, which is why it is shipped rather than generated: +// eight integers and the four ways to combine them are the same whatever dimensions.json says. +// `Semantics.Cpp` writes this file out beside the vocabulary it does generate. +// +// Eight axes, not seven. SI has seven bases and files the radian under dimensionless, and this +// system keeps angle as an eighth because the alternative is that an angle is the same type as a +// ratio and an angular velocity the same type as a frequency -- so adding a heading to a ratio +// would compile. Measured against the real metadata, the eighth axis is what separates +// AngularDisplacement from Dimensionless and AngularVelocity from Frequency, and nothing else in +// the vocabulary changes. +// +// It does not separate everything, and it is not meant to: 72 dimensions share 63 exponent +// vectors, so Area and NuclearCrossSection, Torque and Energy, AbsorbedDose and EquivalentDose are +// each one vector between two names. Telling those apart is the generated classes' job. This layer +// exists for the other direction -- so that a product nobody declared still has a type. + +#pragma once + +namespace @NAMESPACE@ +{ + + // Every exponent defaults to zero, so a dimension is written as its non-zero prefix -- + // Dimension<1, 0, -2> is an acceleration -- rather than as eight numbers of which five are + // noise. + template + struct Dimension + { + static constexpr int length = Length; + static constexpr int mass = Mass; + static constexpr int time = Time; + static constexpr int angle = Angle; + static constexpr int current = Current; + static constexpr int temperature = Temperature; + static constexpr int amount = Amount; + static constexpr int luminous = Luminous; + }; + + template + using DimensionProduct = + Dimension; + + template + using DimensionQuotient = + Dimension; + + template + using DimensionInverse = Dimension<-A::length, -A::mass, -A::time, -A::angle, -A::current, -A::temperature, + -A::amount, -A::luminous>; + + // Halving exponents is only meaningful when every one of them is even. The square root of an + // area is a length; the square root of a length is not anything these base units can name. + template + inline constexpr bool dimension_has_integer_root = + (A::length % 2 == 0) && (A::mass % 2 == 0) && (A::time % 2 == 0) && (A::angle % 2 == 0) && + (A::current % 2 == 0) && (A::temperature % 2 == 0) && (A::amount % 2 == 0) && (A::luminous % 2 == 0); + + template + using DimensionSquareRoot = Dimension; + + using Dimensionless = Dimension<0>; + +} // namespace @NAMESPACE@ diff --git a/Semantics.Cpp/Prelude/quantity.hpp b/Semantics.Cpp/Prelude/quantity.hpp new file mode 100644 index 00000000..6efd5781 --- /dev/null +++ b/Semantics.Cpp/Prelude/quantity.hpp @@ -0,0 +1,147 @@ +// @BANNER@ +// +// A value tagged with a dimension, and the arithmetic that combines them. +// +// Shipped rather than generated, for the same reason as dimension.hpp: none of it is derived from +// the metadata. What is generated sits on top of this -- one class per dimension and per named +// overload, each wrapping one of these. +// +// Every operation here is constexpr and trivially inlinable, and that is load-bearing rather than +// stylistic. The whole claim is that this costs nothing at run time; it has been measured against +// bare floats at 0.9896 on MSVC, 0.999 on GCC and 0.953 on clang. An earlier formulation of the +// generated layer above it measured 1.40 on MSVC while both others folded it away entirely, so the +// rules the generator follows when it writes that layer are not negotiable -- see the header of +// CppQuantityGenerator. + +#pragma once + +#include +#include +#include + +#include "dimension.hpp" + +namespace @NAMESPACE@ +{ + + // `Rep` is the underlying storage, so a component can use a narrower representation without + // changing its meaning. + template + class Quantity + { + public: + using dimension = D; + using rep = Rep; + + constexpr Quantity() noexcept = default; + + // Explicit: a bare number never becomes a quantity by accident. Crossing into the type + // system is a decision, and it should be visible at the point where it happens. + explicit constexpr Quantity(Rep value) noexcept + : value_(value) + { + } + + // Widening the representation of the same dimension is safe and implicit. + template + requires(!std::is_same_v && std::is_convertible_v) + constexpr Quantity(Quantity other) noexcept + : value_(static_cast(other.count())) + { + } + + // Leaving the type system is also explicit, by being a named call. + [[nodiscard]] constexpr Rep count() const noexcept { return value_; } + + constexpr Quantity& operator+=(Quantity rhs) noexcept + { + value_ += rhs.value_; + return *this; + } + + constexpr Quantity& operator-=(Quantity rhs) noexcept + { + value_ -= rhs.value_; + return *this; + } + + constexpr Quantity& operator*=(Rep scale) noexcept + { + value_ *= scale; + return *this; + } + + constexpr Quantity& operator/=(Rep scale) noexcept + { + value_ /= scale; + return *this; + } + + [[nodiscard]] friend constexpr Quantity operator+(Quantity lhs, Quantity rhs) noexcept + { + return Quantity{ lhs.value_ + rhs.value_ }; + } + + [[nodiscard]] friend constexpr Quantity operator-(Quantity lhs, Quantity rhs) noexcept + { + return Quantity{ lhs.value_ - rhs.value_ }; + } + + [[nodiscard]] friend constexpr Quantity operator-(Quantity q) noexcept { return Quantity{ -q.value_ }; } + + // Scaling by a bare number preserves the dimension. + [[nodiscard]] friend constexpr Quantity operator*(Quantity q, Rep scale) noexcept + { + return Quantity{ q.value_ * scale }; + } + + [[nodiscard]] friend constexpr Quantity operator*(Rep scale, Quantity q) noexcept + { + return Quantity{ scale * q.value_ }; + } + + [[nodiscard]] friend constexpr Quantity operator/(Quantity q, Rep scale) noexcept + { + return Quantity{ q.value_ / scale }; + } + + [[nodiscard]] friend constexpr bool operator==(Quantity, Quantity) noexcept = default; + [[nodiscard]] friend constexpr auto operator<=>(Quantity, Quantity) noexcept = default; + + private: + Rep value_{}; + }; + + // Multiplying and dividing quantities combines their dimensions. These live outside the class + // so both operands participate in deduction. + template + [[nodiscard]] constexpr auto operator*(Quantity a, Quantity b) noexcept + { + return Quantity, Rep>{ a.count() * b.count() }; + } + + template + [[nodiscard]] constexpr auto operator/(Quantity a, Quantity b) noexcept + { + return Quantity, Rep>{ a.count() / b.count() }; + } + + // A bare number over a quantity yields the inverse dimension: 1 / Seconds is Hz. + template + [[nodiscard]] constexpr auto operator/(Rep scale, Quantity q) noexcept + { + return Quantity, Rep>{ scale / q.count() }; + } + + // The square root of a quantity halves its dimension, which only exists when every exponent is + // even. `sqrt(SquareMetres)` is a length; `sqrt(Metres)` is rejected at compile time. + template + [[nodiscard]] auto sqrt(Quantity q) noexcept + { + static_assert(dimension_has_integer_root, + "sqrt() is undefined for this dimension: halving its exponents does not yield a whole " + "combination of base units"); + return Quantity, Rep>{ static_cast(std::sqrt(q.count())) }; + } + +} // namespace @NAMESPACE@ diff --git a/Semantics.Cpp/QuantityMetadata.cs b/Semantics.Cpp/QuantityMetadata.cs new file mode 100644 index 00000000..8c7f2ee6 --- /dev/null +++ b/Semantics.Cpp/QuantityMetadata.cs @@ -0,0 +1,141 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp; + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +/// +/// As much of dimensions.json as the C++ projection reads. +/// +/// +/// A second reader of the same file rather than a share of +/// Semantics.SourceGenerators's model, and deliberately. +/// +/// The shared thing is the JSON. That project's model is a Roslyn component's internal shape: it +/// carries every field the C# generators need, validates for their diagnostics, and lives in a +/// netstandard2.0 assembly that brings Microsoft.CodeAnalysis with it. Referencing it drags all of +/// that into a project that wants none of it, and compiling its source in here subjects a file +/// another project owns to this one's analysis rules. Reading the file directly costs the few +/// declarations below and leaves the two consumers independent -- which is what they are. +/// +/// +/// The cost of the duplication is a field added to the metadata and read by only one side. That is +/// caught rather than hoped for: this reader ignores what it does not know, and +/// refuses anything it cannot make sense of by name. +/// +/// +[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] +public sealed class QuantityMetadata +{ + /// Gets the dimensions the file declares, in the order it declares them. + [JsonPropertyName("physicalDimensions")] + public Collection PhysicalDimensions { get; } = []; + + /// + /// Reads the metadata. + /// + /// The contents of dimensions.json. + /// The dimensions it declares. + /// The document is not the shape this expects. + public static QuantityMetadata Parse(string json) => + JsonSerializer.Deserialize(json, Options) + ?? throw new JsonException("dimensions.json is empty."); + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; +} + +/// One physical dimension. +/// +/// The collection properties are get-only and populated in place, which is what +/// asks for: a settable +/// List<T> would deserialise without it and is what the analyzers object to. +/// +[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] +public sealed class MetadataDimension +{ + /// Gets or sets what the dimension is called. + public string Name { get; set; } = string.Empty; + + /// + /// Gets the exponents, keyed by axis, naming only the axes that are not zero. + /// + [JsonPropertyName("dimensionalFormula")] + public Dictionary DimensionalFormula { get; } = []; + + /// Gets or sets the vector forms this dimension has. + public MetadataForms Quantities { get; set; } = new(); + + /// Gets what this dimension multiplied by another produces. + public Collection Integrals { get; } = []; + + /// Gets what this dimension divided by another produces. + public Collection Derivatives { get; } = []; +} + +/// The vector forms a dimension declares. Only the magnitude form is read so far. +public sealed class MetadataForms +{ + /// Gets or sets the magnitude form, or null when the dimension has none. + [JsonPropertyName("vector0")] + public MetadataForm? Vector0 { get; set; } +} + +/// One vector form: a base type and the names that refine it. +/// +/// The collection properties are get-only and populated in place, which is what +/// asks for: a settable +/// List<T> would deserialise without it and is what the analyzers object to. +/// +[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] +public sealed class MetadataForm +{ + /// Gets or sets what the base type is called. + public string Base { get; set; } = string.Empty; + + /// Gets the named refinements of that base. + public Collection Overloads { get; } = []; +} + +/// A named refinement of a base quantity. +public sealed class MetadataOverload +{ + /// Gets or sets what it is called. + public string Name { get; set; } = string.Empty; + + /// Gets or sets what it is. + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the stricter bound this refinement opts into, when it has one. + /// + /// + /// Only a strict-positive floor is modelled, because it is the only one declared: a wavelength, + /// a period and a half-life are quantities for which zero is unphysical rather than merely + /// small. + /// + public MetadataConstraints? PhysicalConstraints { get; set; } +} + +/// A bound tighter than the magnitude form's own. +public sealed class MetadataConstraints +{ + /// Gets or sets the value the quantity must exceed. + public string MinExclusive { get; set; } = string.Empty; +} + +/// One declared relationship between dimensions. +public sealed class MetadataRelationship +{ + /// Gets or sets the dimension on the other side of the operator. + public string Other { get; set; } = string.Empty; + + /// Gets or sets the dimension the operator produces. + public string Result { get; set; } = string.Empty; +} diff --git a/Semantics.Cpp/QuantityVocabulary.cs b/Semantics.Cpp/QuantityVocabulary.cs new file mode 100644 index 00000000..2dbc081f --- /dev/null +++ b/Semantics.Cpp/QuantityVocabulary.cs @@ -0,0 +1,243 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp; + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +/// +/// How a quantity is bounded below. +/// +internal enum Magnitude +{ + /// A signed value: a component of a vector, or a scale that runs both ways. + Signed, + + /// A magnitude, which cannot be negative. + NonNegative, + + /// A magnitude for which zero is unphysical too, like a wavelength or a half-life. + Positive, +} + +/// +/// One generated quantity class. +/// +/// What it is called, which is what dimensions.json calls it. +/// What it is, for the generated documentation comment. +/// Its exponents. +/// The base it widens into, or null when it is the base. +/// How it is bounded below. +internal sealed record QuantityType( + string Name, + string Description, + DimensionVector Dimension, + string? Refines, + Magnitude Magnitude); + +/// +/// What combining two quantities produces. +/// +internal enum RelationshipKind +{ + /// The left operand multiplied by the right. + Product, + + /// The left operand divided by the right. + Quotient, +} + +/// +/// One generated operator: two quantities in, a named quantity out. +/// +internal sealed record QuantityRelationship(string Left, string Right, string Result, RelationshipKind Kind) +{ + /// Gets the operator as C++ spells it. + internal string Symbol => Kind == RelationshipKind.Product ? "*" : "/"; + + /// + public override string ToString() => $"{Left} {Symbol} {Right} -> {Result}"; +} + +/// +/// Something the metadata says that the generator will not emit, and why. +/// +/// What was refused, named the way the metadata names it. +/// Why, in terms a person editing the metadata can act on. +internal sealed record VocabularyIssue(string Subject, string Reason) +{ + /// + public override string ToString() => $"{Subject}: {Reason}"; +} + +/// +/// The metadata, resolved into what the C++ projection needs, with everything it cannot honour +/// separated out rather than silently dropped. +/// +/// +/// The separation is the point. A relationship is a claim -- Force * Length -> Torque -- +/// and the exponents are what check it. A claim the exponents contradict is not emitted, because +/// the generated operator builds its result out of Quantity arithmetic and would simply +/// fail to compile; refusing it by name, with the two dimensions written out, is the same house +/// style the rest of this stack uses for something it cannot express. +/// +/// That check earns its keep immediately: on the metadata as it stands it refuses four +/// relationships, one of which (Sensitivity * Pressure -> ElectricPotential) was already +/// wrong before angle existed and had never been noticed, because nothing had ever multiplied the +/// exponents out. +/// +/// +internal sealed class QuantityVocabulary +{ + private QuantityVocabulary( + IReadOnlyList types, + IReadOnlyList relationships, + IReadOnlyList refused) + { + Types = types; + Relationships = relationships; + Refused = refused; + } + + /// Gets every class to generate, in the order the metadata declares them. + internal IReadOnlyList Types { get; } + + /// Gets every operator to generate. + internal IReadOnlyList Relationships { get; } + + /// Gets what the metadata asked for and did not get. + internal IReadOnlyList Refused { get; } + + /// + /// Resolves the metadata. + /// + /// The deserialised dimensions.json. + /// The vocabulary, and everything refused. + internal static QuantityVocabulary FromMetadata(QuantityMetadata metadata) + { + List types = []; + List refused = []; + + Dictionary byDimensionName = []; + Dictionary baseTypeOf = []; + + foreach (MetadataDimension dimension in metadata.PhysicalDimensions) + { + DimensionVector exponents = DimensionVector.FromFormula(dimension.DimensionalFormula); + byDimensionName[dimension.Name] = exponents; + + // Only the magnitude form is projected so far. The vector forms are distinct classes + // too, and they are the next thing this generator grows; they need componentwise + // operations, which have their own rules, so they are deliberately not half-done here. + MetadataForm? magnitude = dimension.Quantities.Vector0; + if (magnitude is null || string.IsNullOrEmpty(magnitude.Base)) + { + refused.Add(new VocabularyIssue(dimension.Name, "has no vector0 form, so it has no magnitude type to generate.")); + continue; + } + + baseTypeOf[dimension.Name] = magnitude.Base; + types.Add(new QuantityType( + magnitude.Base, + $"The magnitude of {Article(dimension.Name)} {Spaced(dimension.Name)}.", + exponents, + Refines: null, + Magnitude.NonNegative)); + + foreach (MetadataOverload overload in magnitude.Overloads) + { + types.Add(new QuantityType( + overload.Name, + overload.Description, + exponents, + Refines: magnitude.Base, + overload.PhysicalConstraints is null ? Magnitude.NonNegative : Magnitude.Positive)); + } + } + + List relationships = + [.. ResolveRelationships(metadata, byDimensionName, baseTypeOf, refused)]; + + return new QuantityVocabulary( + new ReadOnlyCollection(types), + new ReadOnlyCollection(relationships), + new ReadOnlyCollection(refused)); + } + + private static IEnumerable ResolveRelationships( + QuantityMetadata metadata, + IReadOnlyDictionary byDimensionName, + IReadOnlyDictionary baseTypeOf, + List refused) + { + foreach (MetadataDimension dimension in metadata.PhysicalDimensions) + { + // A dot or a cross product is a statement about vector forms rather than magnitudes, + // so neither belongs here: `dot` on two magnitudes is just their product, and a cross + // product of two magnitudes is not defined at all. + // A relationship Resolve refused is null, and has already said why in `refused`; OfType + // drops those and hands the loop a relationship that is there. + foreach (QuantityRelationship resolved in dimension.Integrals + .Select(relationship => Resolve(dimension, relationship, RelationshipKind.Product, byDimensionName, baseTypeOf, refused)) + .Concat(dimension.Derivatives + .Select(relationship => Resolve(dimension, relationship, RelationshipKind.Quotient, byDimensionName, baseTypeOf, refused))) + .OfType()) + { + yield return resolved; + } + } + } + + private static QuantityRelationship? Resolve( + MetadataDimension dimension, + MetadataRelationship relationship, + RelationshipKind kind, + IReadOnlyDictionary byDimensionName, + IReadOnlyDictionary baseTypeOf, + List refused) + { + string subject = $"{dimension.Name} {(kind == RelationshipKind.Product ? "*" : "/")} {relationship.Other} -> {relationship.Result}"; + + foreach (string named in (string[])[relationship.Other, relationship.Result]) + { + if (!byDimensionName.ContainsKey(named)) + { + // The same gap SEM001 reports on the .NET side, seen from here. + refused.Add(new VocabularyIssue(subject, $"names '{named}', which dimensions.json does not declare.")); + return null; + } + } + + DimensionVector left = byDimensionName[dimension.Name]; + DimensionVector right = byDimensionName[relationship.Other]; + DimensionVector result = byDimensionName[relationship.Result]; + + DimensionVector combined = kind == RelationshipKind.Product ? left + right : left - right; + if (!combined.Equals(result)) + { + refused.Add(new VocabularyIssue( + subject, + $"is not dimensionally true: {left} {(kind == RelationshipKind.Product ? "*" : "/")} {right} is {combined}, and {relationship.Result} is {result}.")); + return null; + } + + return new QuantityRelationship( + baseTypeOf[dimension.Name], + baseTypeOf[relationship.Other], + baseTypeOf[relationship.Result], + kind); + } + + /// + /// Splits a PascalCase dimension name for prose, so a comment reads "an angular velocity" + /// rather than "an AngularVelocity". + /// + private static string Spaced(string name) => + string.Concat(name.Select((character, index) => + index > 0 && char.IsUpper(character) && !char.IsUpper(name[index - 1]) + ? $" {char.ToLowerInvariant(character)}" + : $"{(index == 0 ? char.ToLowerInvariant(character) : character)}")); + + private static string Article(string name) => "AEIOU".Contains(name[0]) ? "an" : "a"; +} diff --git a/Semantics.Cpp/Semantics.Cpp.csproj b/Semantics.Cpp/Semantics.Cpp.csproj new file mode 100644 index 00000000..cf68acdc --- /dev/null +++ b/Semantics.Cpp/Semantics.Cpp.csproj @@ -0,0 +1,28 @@ + + + + + + + net10.0;net9.0 + + + + + + + + + + + + + + + + + diff --git a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.DimensionsGenerator/PhysicalDimensions.g.cs b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.DimensionsGenerator/PhysicalDimensions.g.cs index 677df295..5ab302d0 100644 --- a/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.DimensionsGenerator/PhysicalDimensions.g.cs +++ b/Semantics.Quantities/Generated/Semantics.SourceGenerators/Semantics.SourceGenerators.DimensionsGenerator/PhysicalDimensions.g.cs @@ -30,19 +30,19 @@ public static class PhysicalDimensions public static readonly DimensionInfo AmountOfSubstance = new("AmountOfSubstance", "N", new Dictionary { ["amountOfSubstance"] = 1 }, new List { "AmountOfSubstance" }); /// Physical dimension: AngularAcceleration - public static readonly DimensionInfo AngularAcceleration = new("AngularAcceleration", "T⁻²", new Dictionary { ["time"] = -2 }, new List { "AngularAccelerationMagnitude", "AngularAcceleration1D", "AngularAcceleration3D" }); + public static readonly DimensionInfo AngularAcceleration = new("AngularAcceleration", "A T⁻²", new Dictionary { ["angle"] = 1, ["time"] = -2 }, new List { "AngularAccelerationMagnitude", "AngularAcceleration1D", "AngularAcceleration3D" }); /// Physical dimension: AngularDisplacement - public static readonly DimensionInfo AngularDisplacement = new("AngularDisplacement", "1", new Dictionary(), new List { "Angle", "FieldOfView", "ApertureAngle", "SignedAngle", "Rotation", "Phase", "Bearing", "Heading", "AngularDisplacement3D" }); + public static readonly DimensionInfo AngularDisplacement = new("AngularDisplacement", "A", new Dictionary { ["angle"] = 1 }, new List { "Angle", "FieldOfView", "ApertureAngle", "SignedAngle", "Rotation", "Phase", "Bearing", "Heading", "AngularDisplacement3D" }); /// Physical dimension: AngularJerk - public static readonly DimensionInfo AngularJerk = new("AngularJerk", "T⁻³", new Dictionary { ["time"] = -3 }, new List { "AngularJerkMagnitude", "AngularJerk1D", "AngularJerk3D" }); + public static readonly DimensionInfo AngularJerk = new("AngularJerk", "A T⁻³", new Dictionary { ["angle"] = 1, ["time"] = -3 }, new List { "AngularJerkMagnitude", "AngularJerk1D", "AngularJerk3D" }); /// Physical dimension: AngularMomentum public static readonly DimensionInfo AngularMomentum = new("AngularMomentum", "M L² T⁻¹", new Dictionary { ["mass"] = 1, ["length"] = 2, ["time"] = -1 }, new List { "AngularMomentumMagnitude", "AngularMomentum1D", "AngularMomentum3D" }); /// Physical dimension: AngularVelocity - public static readonly DimensionInfo AngularVelocity = new("AngularVelocity", "T⁻¹", new Dictionary { ["time"] = -1 }, new List { "AngularSpeed", "AngularVelocity1D", "AngularVelocity3D" }); + public static readonly DimensionInfo AngularVelocity = new("AngularVelocity", "A T⁻¹", new Dictionary { ["angle"] = 1, ["time"] = -1 }, new List { "AngularSpeed", "AngularVelocity1D", "AngularVelocity3D" }); /// Physical dimension: Area public static readonly DimensionInfo Area = new("Area", "L²", new Dictionary { ["length"] = 2 }, new List { "Area", "SurfaceArea", "CrossSectionalArea" }); diff --git a/Semantics.SourceGenerators/Metadata/dimensions.json b/Semantics.SourceGenerators/Metadata/dimensions.json index 375bf800..09b70dd5 100644 --- a/Semantics.SourceGenerators/Metadata/dimensions.json +++ b/Semantics.SourceGenerators/Metadata/dimensions.json @@ -400,8 +400,8 @@ }, { "name": "AngularDisplacement", - "symbol": "1", - "dimensionalFormula": {}, + "symbol": "A", + "dimensionalFormula": { "angle": 1 }, "availableUnits": ["Radian", "Degree", "Gradian", "Revolution", "Milliradian"], "quantities": { "vector0": { @@ -431,8 +431,8 @@ }, { "name": "AngularVelocity", - "symbol": "T⁻¹", - "dimensionalFormula": { "time": -1 }, + "symbol": "A T⁻¹", + "dimensionalFormula": { "angle": 1, "time": -1 }, "availableUnits": ["RadianPerSecond", "RevolutionPerMinute"], "quantities": { "vector0": { "base": "AngularSpeed" }, @@ -450,8 +450,8 @@ }, { "name": "AngularAcceleration", - "symbol": "T⁻²", - "dimensionalFormula": { "time": -2 }, + "symbol": "A T⁻²", + "dimensionalFormula": { "angle": 1, "time": -2 }, "availableUnits": ["RadianPerSecondSquared"], "quantities": { "vector0": { "base": "AngularAccelerationMagnitude" }, @@ -469,8 +469,8 @@ }, { "name": "AngularJerk", - "symbol": "T⁻³", - "dimensionalFormula": { "time": -3 }, + "symbol": "A T⁻³", + "dimensionalFormula": { "angle": 1, "time": -3 }, "availableUnits": ["RadianPerSecondCubed"], "quantities": { "vector0": { "base": "AngularJerkMagnitude" }, diff --git a/Semantics.sln b/Semantics.sln index 974a0cd4..3fe8046c 100644 --- a/Semantics.sln +++ b/Semantics.sln @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Color", "Semantic EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Strings.Identifiers", "Semantics.Strings.Identifiers\Semantics.Strings.Identifiers.csproj", "{7FC2C45E-9291-4014-B29F-38D9D1FDD41A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Cpp", "Semantics.Cpp\Semantics.Cpp.csproj", "{86A0B111-2E85-4EC5-9B94-DA5A122699E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Cpp.Test", "Semantics.Cpp.Test\Semantics.Cpp.Test.csproj", "{2E8EAA2E-59FB-410D-9DB1-C9850311B029}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -167,6 +171,30 @@ Global {7FC2C45E-9291-4014-B29F-38D9D1FDD41A}.Release|x64.Build.0 = Release|Any CPU {7FC2C45E-9291-4014-B29F-38D9D1FDD41A}.Release|x86.ActiveCfg = Release|Any CPU {7FC2C45E-9291-4014-B29F-38D9D1FDD41A}.Release|x86.Build.0 = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|x64.Build.0 = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Debug|x86.Build.0 = Debug|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|Any CPU.Build.0 = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|x64.ActiveCfg = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|x64.Build.0 = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|x86.ActiveCfg = Release|Any CPU + {86A0B111-2E85-4EC5-9B94-DA5A122699E4}.Release|x86.Build.0 = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|x64.Build.0 = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Debug|x86.Build.0 = Debug|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|Any CPU.Build.0 = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|x64.ActiveCfg = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|x64.Build.0 = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|x86.ActiveCfg = Release|Any CPU + {2E8EAA2E-59FB-410D-9DB1-C9850311B029}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE