diff --git a/.gitignore b/.gitignore index b319cb4b8b..e5bd1ed8e2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.user *.userosscache *.sln.docstates +.temp/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/CodeGen/Helpers/PrefixBuilder/UnitPrefixBuilder.cs b/CodeGen/Helpers/PrefixBuilder/UnitPrefixBuilder.cs index 7ef1a63139..54e9c85a6a 100644 --- a/CodeGen/Helpers/PrefixBuilder/UnitPrefixBuilder.cs +++ b/CodeGen/Helpers/PrefixBuilder/UnitPrefixBuilder.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.RegularExpressions; +using CodeGen.Exceptions; using CodeGen.JsonTypes; namespace CodeGen.Helpers.PrefixBuilder; @@ -18,6 +20,21 @@ namespace CodeGen.Helpers.PrefixBuilder; /// internal class UnitPrefixBuilder { + /// + /// Matches abbreviations whose leading unit token is raised to the second, third, or fourth power using a + /// superscript or caret, such as , ft³/s, or m^4. + /// + /// + /// This is intentionally not a general unit-expression parser. It does not match a power in a denominator or later + /// compound term (kg/m³, m/s², or N·m²), a leading numeric scale factor + /// (10³·m³), parenthesized or implicit powers, or powers other than two through four. These cases do not put + /// a metric prefix directly before an explicitly powered leading unit token, or are outside the scope of this + /// heuristic. Unit names starting with Square or Cubic are checked separately. + /// + private static readonly Regex LeadingPoweredUnitAbbreviationRegex = new( + @"^[^\s/·*()\-\d]+(?:[²³⁴]|\^[234])", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly BaseUnitPrefixes _prefixes; /// @@ -45,6 +62,9 @@ public UnitPrefixBuilder(BaseUnitPrefixes prefixes) /// /// Thrown when an error occurs while processing a prefix for a unit, such as an invalid prefix or unit configuration. /// + /// + /// Thrown when prefixes are configured for a unit that looks like a powered unit. + /// /// /// This method iterates through the existing units of the specified and applies each defined /// prefix to generate new prefixed units. It ensures that the singular and plural names, conversion functions, @@ -54,34 +74,86 @@ public List GeneratePrefixUnits(Quantity quantity) { var unitsToAdd = new List(); foreach (Unit unit in quantity.Units) - foreach (Prefix prefix in unit.Prefixes) { - try + if (!unit.Prefixes.Any()) { - PrefixInfo prefixInfo = PrefixInfo.Entries[prefix]; - - unitsToAdd.Add(new Unit - { - SingularName = $"{prefix}{unit.SingularName.ToCamelCase()}", // "Kilo" + "NewtonPerMeter" => "KilonewtonPerMeter" - PluralName = $"{prefix}{unit.PluralName.ToCamelCase()}", // "Kilo" + "NewtonsPerMeter" => "KilonewtonsPerMeter" - BaseUnits = GetPrefixedBaseUnits(quantity.BaseDimensions, unit.BaseUnits, prefixInfo), - FromBaseToUnitFunc = $"({unit.FromBaseToUnitFunc}) / {prefixInfo.Factor}", - FromUnitToBaseFunc = $"({unit.FromUnitToBaseFunc}) * {prefixInfo.Factor}", - Localization = GetLocalizationForPrefixUnit(unit.Localization, prefixInfo), - ObsoleteText = unit.ObsoleteText, - SkipConversionGeneration = unit.SkipConversionGeneration, - AllowAbbreviationLookup = unit.AllowAbbreviationLookup - }); + continue; } - catch (Exception e) + + ThrowIfPrefixesAreUnsafeForPoweredUnit(quantity, unit); + + foreach (Prefix prefix in unit.Prefixes) { - throw new Exception($"Error parsing prefix {prefix} for unit {quantity.Name}.{unit.SingularName}.", e); + try + { + PrefixInfo prefixInfo = PrefixInfo.Entries[prefix]; + + unitsToAdd.Add(new Unit + { + SingularName = $"{prefix}{unit.SingularName.ToCamelCase()}", // "Kilo" + "NewtonPerMeter" => "KilonewtonPerMeter" + PluralName = $"{prefix}{unit.PluralName.ToCamelCase()}", // "Kilo" + "NewtonsPerMeter" => "KilonewtonsPerMeter" + BaseUnits = GetPrefixedBaseUnits(quantity.BaseDimensions, unit.BaseUnits, prefixInfo), + FromBaseToUnitFunc = $"({unit.FromBaseToUnitFunc}) / {prefixInfo.Factor}", + FromUnitToBaseFunc = $"({unit.FromUnitToBaseFunc}) * {prefixInfo.Factor}", + Localization = GetLocalizationForPrefixUnit(unit.Localization, prefixInfo), + ObsoleteText = unit.ObsoleteText, + SkipConversionGeneration = unit.SkipConversionGeneration, + AllowAbbreviationLookup = unit.AllowAbbreviationLookup + }); + } + catch (Exception e) + { + throw new Exception($"Error parsing prefix {prefix} for unit {quantity.Name}.{unit.SingularName}.", e); + } } } return unitsToAdd; } + /// + /// Prevents automatic prefixes from being applied to units that appear to represent a directly powered unit. + /// + /// The quantity that defines the unit. + /// The unit configured with one or more automatic prefixes. + /// + /// Thrown when the unit name starts with Square or Cubic, or when one of its abbreviations starts with + /// a unit token raised to the second, third, or fourth power. + /// + /// + /// Mechanically prefixing a powered unit can produce a misleading abbreviation. For example, prefixing + /// CubicMeter with Kilo produces km³, which means cubic kilometer rather than one thousand + /// cubic meters. This guard uses unit names and as a focused + /// heuristic. It intentionally does not inspect base dimensions, since valid derived units such as watt and joule + /// have powered dimensions but can safely use automatic prefixes. + /// + private static void ThrowIfPrefixesAreUnsafeForPoweredUnit(Quantity quantity, Unit unit) + { + if (!LooksLikePoweredUnit(unit)) + { + return; + } + + throw new UnitsNetCodeGenException( + $"Prefixes cannot be used on {quantity.Name}.{unit.SingularName} because it looks like a powered unit. " + + "Define explicit units instead, such as CubicKilometer for km³ or ThousandCubicMeter for 1000 m³."); + } + + private static bool LooksLikePoweredUnit(Unit unit) + { + // This intentionally checks naming conventions rather than dimensions, since derived units such as Watt, Joule, + // and Ohm have powered base dimensions and safely support prefixes. + if (unit.SingularName.StartsWith("Square", StringComparison.Ordinal) || + unit.SingularName.StartsWith("Cubic", StringComparison.Ordinal)) + { + return true; + } + + return unit.Localization + .SelectMany(localization => localization.Abbreviations) + .Any(abbreviation => LeadingPoweredUnitAbbreviationRegex.IsMatch(abbreviation)); + } + /// /// Applies a metric prefix to the specified base units based on the given dimensions and prefix information. /// diff --git a/Docs/quantity-and-unit-definition-schema.md b/Docs/quantity-and-unit-definition-schema.md index 4d156cbb5b..d35e4f67e6 100644 --- a/Docs/quantity-and-unit-definition-schema.md +++ b/Docs/quantity-and-unit-definition-schema.md @@ -206,6 +206,16 @@ instead. For example, define `CubicMillimeterPerKilogram` for `mm³/kg` instead `MillicubicMeterPerKilogram`, and define `ThousandCubicMeter` with an abbreviation such as `10³·m³` when the intended unit is 1000 cubic meters rather than cubic kilometers. +CodeGen fails when `Prefixes` is used on a unit that looks like the powered unit itself, such as a unit name starting +with `Square` or `Cubic`, or an abbreviation starting with `m²`, `m³`, `ft³`, or similar powered unit symbols. For +example, generating `Kilo` from `SquareMeter` would produce `km²`, which means one square kilometer (`1e6 m²`), not +one thousand square meters. Generating it from `CubicMeter` would produce `km³`, which means one cubic kilometer +(`1e9 m³`), not one thousand cubic meters. + +This is a naming and abbreviation heuristic, not a dimensional-analysis check. Derived units such as `Watt`, `Joule`, +and `Ohm` have powered SI base dimensions, but can safely use `Prefixes` because their own abbreviations are not +powered unit symbols. They produce unambiguous units such as `MW`, `MJ`, and `MΩ`. + ## Localization object Each `Localization` entry configures abbreviations for one culture.