From 1855a5e748a2badb6fa048398a2526e9166dcb7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 12:36:06 +0000 Subject: [PATCH 1/2] Add Semantics.Cpp: the C++ projection of the quantity vocabulary [minor] The .NET side of this library has always been generated from dimensions.json. Holotype needs the same vocabulary in C++, and the alternative to generating it is that someone writes 162 classes by hand and they drift - which is the failure this repository exists to avoid, in a second language. Two layers, because neither can do the other's job. The structural layer is Quantity over a Dimension of eight integer exponents. It is shipped as a prelude rather than generated: no part of it is derived from the metadata, and eight integers and the four ways to combine them are the same whatever dimensions.json says. What it buys is that a product nobody declared still has a type. The nominal layer is one class per dimension and per named overload, and it is what the exponents cannot do. Measured against the real metadata, 72 dimensions share 63 exponent vectors: Area and NuclearCrossSection, Torque and Energy, AbsorbedDose and EquivalentDose are each one vector between two names, and only naming them separates them. The metadata gains an angle exponent, carried by AngularDisplacement, AngularVelocity, AngularAcceleration and AngularJerk. 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. Nothing on the .NET side depends on it yet; it travels through DimensionInfo, which is why the generated C# moves. 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 turns every claim in `integrals` and `derivatives` into something checkable rather than something asserted. Four are refused, each by name with both dimensions written out: Torque * AngularDisplacement -> Energy MomentOfInertia * AngularVelocity -> AngularMomentum MomentOfInertia * AngularAcceleration -> Torque Sensitivity * Pressure -> ElectricPotential The first three are not fixable by choosing different angle exponents, and that is provable rather than a matter of taste: Torque * AngularDisplacement -> Energy forces torque's angle exponent to -1, and Force x Length -> Torque forces it to 0. It is the r x F versus tau . theta contradiction, and it is why SI keeps the radian dimensionless. The fourth has nothing to do with angle and was already wrong: Sensitivity is declared as A/Pa while the relationship treats it as V/Pa. One of the two is a mistake and which one is a physics call, so it is reported rather than guessed at. Nothing had ever multiplied the exponents out before, so nothing had noticed. How the generated code is written is measured rather than chosen, and the header of CppQuantityGenerator says so: 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. Sixteen tests, two of which are the ones that matter: the whole vocabulary is compiled with g++ -std=c++20 -Wall -Wextra, and a product whose exponents do not match its result type is required to be rejected by the compiler. Without the second, the first would only prove the headers parse. Two things are deliberately not here. The vector forms are distinct classes too - 122 dimension-and-form entries against the 72 magnitudes projected here - and they need componentwise operations, which have a rule of their own; half-doing them would be worse than not starting. And the magnitude guard rides in the member initialiser rather than the constructor body, because ktsu.Coder has no expression-statement node, so a call made for its effect is not something the AST can currently say. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- CLAUDE.md | 54 +++ Directory.Packages.props | 4 + .../CppQuantityGeneratorTests.cs | 229 +++++++++ .../GeneratedCppCompilesTests.cs | 144 ++++++ Semantics.Cpp.Test/Semantics.Cpp.Test.csproj | 27 ++ Semantics.Cpp/CppQuantityGenerator.cs | 441 ++++++++++++++++++ Semantics.Cpp/CppQuantityOptions.cs | 38 ++ Semantics.Cpp/DimensionVector.cs | 169 +++++++ Semantics.Cpp/Prelude/dimension.hpp | 72 +++ Semantics.Cpp/Prelude/quantity.hpp | 147 ++++++ Semantics.Cpp/QuantityMetadata.cs | 141 ++++++ Semantics.Cpp/QuantityVocabulary.cs | 243 ++++++++++ Semantics.Cpp/Semantics.Cpp.csproj | 28 ++ .../PhysicalDimensions.g.cs | 8 +- .../Metadata/dimensions.json | 16 +- Semantics.sln | 28 ++ 16 files changed, 1777 insertions(+), 12 deletions(-) create mode 100644 Semantics.Cpp.Test/CppQuantityGeneratorTests.cs create mode 100644 Semantics.Cpp.Test/GeneratedCppCompilesTests.cs create mode 100644 Semantics.Cpp.Test/Semantics.Cpp.Test.csproj create mode 100644 Semantics.Cpp/CppQuantityGenerator.cs create mode 100644 Semantics.Cpp/CppQuantityOptions.cs create mode 100644 Semantics.Cpp/DimensionVector.cs create mode 100644 Semantics.Cpp/Prelude/dimension.hpp create mode 100644 Semantics.Cpp/Prelude/quantity.hpp create mode 100644 Semantics.Cpp/QuantityMetadata.cs create mode 100644 Semantics.Cpp/QuantityVocabulary.cs create mode 100644 Semantics.Cpp/Semantics.Cpp.csproj 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..acd8db82 --- /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.Combine(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..20533d63 --- /dev/null +++ b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Cpp.Test; + +using System; +using System.Diagnostics; +using System.IO; + +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.Combine(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.Combine(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.Combine(Path.GetTempPath(), $"semantics-cpp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + CppQuantityOutput output = new CppQuantityGenerator(new CppQuantityOptions { Namespace = "holo" }) + .Generate(QuantityMetadata.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "Metadata", "dimensions.json")))); + + foreach ((string name, string text) in output.Files) + { + File.WriteAllText(Path.Combine(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) + { + string[] directories = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + foreach (string directory in directories) + { + string candidate = Path.Combine(directory, executable); + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } +} 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..46ba46fe --- /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. + 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)))) + { + if (resolved is not null) + { + 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 From efe430c1037db1891cbd924e003923d194e95976 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Fri, 11 Sep 2026 12:42:44 +0000 Subject: [PATCH 2/2] Address code-quality findings on the C++ projection The `Path.Combine` calls are the substantive one: `Combine` silently discards every argument before a rooted one, so a name that turned out to be rooted -- a generated file name, a PATH entry -- would write or read somewhere other than where the call reads as writing. `Path.Join` concatenates instead, which is what all of these actually mean. `Find` takes the executable's file name for the same reason, since a PATH entry is the directory. `ResolveRelationships` filters with `OfType` rather than an `if` inside the loop, which drops the null and the null-forgiving both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGHDsYaaTQdzVR4XBR6miu --- .../CppQuantityGeneratorTests.cs | 2 +- .../GeneratedCppCompilesTests.cs | 33 +++++++++---------- Semantics.Cpp/QuantityVocabulary.cs | 12 +++---- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs index acd8db82..a6171a8b 100644 --- a/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs +++ b/Semantics.Cpp.Test/CppQuantityGeneratorTests.cs @@ -25,7 +25,7 @@ public sealed class CppQuantityGeneratorTests new CppQuantityOptions { Namespace = "holo" }).Generate(Metadata()); private static QuantityMetadata Metadata() => - QuantityMetadata.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Metadata", "dimensions.json"))); + 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. diff --git a/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs index 20533d63..5f39b335 100644 --- a/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs +++ b/Semantics.Cpp.Test/GeneratedCppCompilesTests.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Cpp.Test; using System; using System.Diagnostics; using System.IO; +using System.Linq; using ktsu.Semantics.Cpp; @@ -35,7 +36,7 @@ public sealed class GeneratedCppCompilesTests public void TheWholeVocabularyCompiles() { string directory = Emit(); - File.WriteAllText(Path.Combine(directory, "main.cpp"), "#include \"quantities.hpp\"\nint main() { return 0; }\n"); + File.WriteAllText(Path.Join(directory, "main.cpp"), "#include \"quantities.hpp\"\nint main() { return 0; }\n"); (int exitCode, string output) = Compile(directory, "main.cpp"); @@ -57,7 +58,7 @@ public void TheWholeVocabularyCompiles() public void AProductWithTheWrongDimensionDoesNotCompile() { string directory = Emit(); - File.WriteAllText(Path.Combine(directory, "wrong.cpp"), """ + File.WriteAllText(Path.Join(directory, "wrong.cpp"), """ #include "Length.hpp" #include "Duration.hpp" #include "Speed.hpp" @@ -80,16 +81,16 @@ public void AProductWithTheWrongDimensionDoesNotCompile() private static string Emit() { - string directory = Path.Combine(Path.GetTempPath(), $"semantics-cpp-{Guid.NewGuid():N}"); + 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.Combine(AppContext.BaseDirectory, "Metadata", "dimensions.json")))); + Path.Join(AppContext.BaseDirectory, "Metadata", "dimensions.json")))); foreach ((string name, string text) in output.Files) { - File.WriteAllText(Path.Combine(directory, name), text); + File.WriteAllText(Path.Join(directory, name), text); } return directory; @@ -127,18 +128,14 @@ private static (int ExitCode, string Output) Compile(string directory, string fi private static string? Find(string executable) { - string[] directories = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); - - foreach (string directory in directories) - { - string candidate = Path.Combine(directory, executable); - if (File.Exists(candidate)) - { - return candidate; - } - } - - return null; + // 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/QuantityVocabulary.cs b/Semantics.Cpp/QuantityVocabulary.cs index 46ba46fe..2dbc081f 100644 --- a/Semantics.Cpp/QuantityVocabulary.cs +++ b/Semantics.Cpp/QuantityVocabulary.cs @@ -176,15 +176,15 @@ private static IEnumerable ResolveRelationships( // 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. - foreach (QuantityRelationship? resolved in dimension.Integrals + // 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)))) + .Select(relationship => Resolve(dimension, relationship, RelationshipKind.Quotient, byDimensionName, baseTypeOf, refused))) + .OfType()) { - if (resolved is not null) - { - yield return resolved; - } + yield return resolved; } } }