Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona
| `Semantics.Music` | Immutable musical value types (`Pitch`, `Interval`, `Scale`, `Chord`, `Key`, `Duration`, `TimeSignature`) plus an analysis aggregate layer (`Progression`, `Section`, `Arrangement`, `Form`) computing roman numerals, cadences, key inference, chromatic identification, and named forms. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. |
| `Semantics.Color` | Physically-grounded color types. Canonical linear-RGB `Color` hub plus color-space satellites (`Srgb`, `Hsl`, `Hsv`, `Oklab`, `Oklch`); every type converts to and from every other, routed through the nearest shared hub (`Srgb` within the sRGB family, `Oklab` within the perceptual family, linear `Color` across families) so no conversion takes a redundant gamma round-trip. Also WCAG accessibility tooling, HSL/perceptual adjustment operations (lighten/saturate/hue/invert), and `NamedColors`. Targets `net8.0`–`net10.0` + `netstandard2.0`/`netstandard2.1`. |
| `Semantics.Quantities` | Hand-written runtime types (`PhysicalQuantity<TSelf, T>`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. |
| `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. |
| `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. `CodeGen/` holds the parts that are not specific to physics — the metadata-driven generator base, metadata loading, the diagnostic catalogue — and is being extracted into a shared toolkit (#181). |
| `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<double>`. |
| `Semantics.Test` | MSTest project covering all of the above. |

Expand Down Expand Up @@ -168,6 +168,7 @@ var converted = sourceString.As<SourceType, TargetType>();

### Working with the source generator

- A generator declares the metadata files it reads via `MetadataFileNames` and derives from `SemanticsGenerator<T>` (one file) or `SemanticsMultiFileGenerator` (several). Neither needs to override `Initialize`.
- Edit `Semantics.SourceGenerators/Metadata/dimensions.json` to add a dimension, vector form, semantic overload, or relationship.
- Rebuild `Semantics.SourceGenerators` and the consuming `Semantics.Quantities` project; emitted files appear in `Semantics.Quantities/Generated/Semantics.SourceGenerators/<GeneratorName>/`.
- Treat generator output as committed source. Diff it before commit so accidental regressions are visible.
Expand All @@ -179,6 +180,9 @@ var converted = sourceString.As<SourceType, TargetType>();
- **SEM003** — a relationship's explicit `forms` list references a vector form not declared on a participating dimension. Use `forms` to constrain a relationship to specific vector forms (e.g. `crossProducts: [{ "other": "Length", "result": "Torque", "forms": [3] }]`); when omitted, the legacy "emit at every common form" behaviour is preserved.
- **SEM004** — a dimension's `availableUnits` array references a unit name that isn't declared anywhere in `units.json`. Without the diagnostic the generator silently emits an identity-conversion `From{Unit}` factory, which is wrong for any non-base unit; SEM004 catches the typo at build time.
- **SEM005** — schema-level validation issue in `logarithmic.json` (missing or duplicate scale names, a conversion with no linear type).
- **SEM006** — a metadata file a generator declared in `MetadataFileNames` was not supplied as an `AdditionalFile`. Previously this produced no output and no explanation, which is indistinguishable from a generator that simply had nothing to emit.
- **SEM007** — a metadata file could not be parsed. Replaces the base generator's `CONV001` in category `SourceGenerator`, and covers the path that used to swallow the exception, where a malformed `units.json` silently produced factories with no scale factor.
- Descriptors are allocated from `SemanticsDiagnostics`, which is the one place to add a new one. `AnalyzerReleaseTrackingTests` fails if the identifier is missing from `AnalyzerReleases.Unshipped.md`, so RS2008 no longer surfaces only after a push.
- See `docs/physics-generator.md` for the full schema and an end-to-end "add a dimension" walk-through.

This file is the entry point. For deeper material:
Expand Down
2 changes: 2 additions & 0 deletions Semantics.SourceGenerators/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ SEM002 | Semantics.SourceGenerators | Warning | Reports schema-level validation
SEM003 | Semantics.SourceGenerators | Warning | Reports a relationship whose explicit `forms` list references a vector form not declared on a participating dimension.
SEM004 | Semantics.SourceGenerators | Warning | Reports a `dimensions.json` `availableUnits` entry that doesn't match any unit declared in `units.json`.
SEM005 | Semantics.SourceGenerators | Warning | Reports schema-level validation issues in logarithmic.json (missing or duplicate scale names, conversions with no linear type).
SEM006 | Semantics.SourceGenerators | Warning | Reports a metadata file a generator declared that was not supplied as an AdditionalFile.
SEM007 | Semantics.SourceGenerators | Error | Reports a metadata file that could not be parsed. Replaces the base generator's CONV001.
21 changes: 21 additions & 0 deletions Semantics.SourceGenerators/CodeGen/CSharpKeywords.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace Semantics.SourceGenerators.CodeGen;

/// <summary>
/// C# vocabulary a generator emits, named so a typo in a keyword is a compile error rather than
/// malformed generated source.
/// </summary>
/// <remarks>
/// Nothing here is specific to any one generator; it is part of the reusable layer. The XML
/// documentation delimiters that used to live alongside these are gone: documentation is written
/// through the template model's <c>DocComment</c>, which owns the tags and escapes their content.
/// </remarks>
internal static class CSharpKeywords
{
/// <summary>The <c>public</c> modifier.</summary>
internal const string Public = "public";

/// <summary>The <c>static</c> modifier.</summary>
internal const string Static = "static";
}
103 changes: 103 additions & 0 deletions Semantics.SourceGenerators/CodeGen/DiagnosticCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace Semantics.SourceGenerators.CodeGen;

using System.Collections.Generic;
using System.Globalization;
using Microsoft.CodeAnalysis;

/// <summary>
/// Declares and reports a generator's diagnostics under one identifier prefix and category.
/// </summary>
/// <remarks>
/// Every generator that reads metadata needs the same handful of diagnostics, and hand-rolling a
/// <see cref="DiagnosticDescriptor"/> plus a private reporting helper per generator is how the
/// identifiers drift: the base generator used to report parse failures as <c>CONV001</c> in category
/// <c>SourceGenerator</c> while everything derived from it used <c>SEM00x</c> in its own category.
/// Allocating them from one catalogue keeps the scheme consistent and gives
/// <c>AnalyzerReleaseTrackingTests</c> something to enumerate, so a descriptor that is missing from
/// <c>AnalyzerReleases.Unshipped.md</c> fails a test rather than RS2008 at build time.
/// <para>
/// Nothing in this type is specific to any one generator; it is part of the reusable layer.
/// </para>
/// </remarks>
/// <param name="idPrefix">The identifier prefix, for example <c>SEM</c>.</param>
/// <param name="category">The category reported on every descriptor.</param>
public sealed class DiagnosticCatalog(string idPrefix, string category)
{
private readonly List<DiagnosticDescriptor> descriptors = [];

/// <summary>Gets the category every descriptor in this catalogue is reported under.</summary>
public string Category { get; } = category;

/// <summary>
/// Gets every descriptor allocated from this catalogue, in allocation order.
/// </summary>
public IReadOnlyList<DiagnosticDescriptor> Descriptors => descriptors;

/// <summary>
/// Allocates a warning descriptor.
/// </summary>
/// <param name="number">The numeric part of the identifier, formatted to three digits.</param>
/// <param name="title">The diagnostic title.</param>
/// <param name="messageFormat">The message format string.</param>
/// <returns>The descriptor, also recorded in <see cref="Descriptors"/>.</returns>
public DiagnosticDescriptor Warning(int number, string title, string messageFormat) =>
Add(number, title, messageFormat, DiagnosticSeverity.Warning);

/// <summary>
/// Allocates an error descriptor.
/// </summary>
/// <param name="number">The numeric part of the identifier, formatted to three digits.</param>
/// <param name="title">The diagnostic title.</param>
/// <param name="messageFormat">The message format string.</param>
/// <returns>The descriptor, also recorded in <see cref="Descriptors"/>.</returns>
public DiagnosticDescriptor Error(int number, string title, string messageFormat) =>
Add(number, title, messageFormat, DiagnosticSeverity.Error);

private DiagnosticDescriptor Add(int number, string title, string messageFormat, DiagnosticSeverity severity)
{
DiagnosticDescriptor descriptor = new(
id: idPrefix + number.ToString("D3", CultureInfo.InvariantCulture),
title: title,
messageFormat: messageFormat,
category: Category,
defaultSeverity: severity,
isEnabledByDefault: true);

descriptors.Add(descriptor);
return descriptor;
}
}

/// <summary>
/// Reporting helpers that keep a diagnostic to one call at the site that found the problem.
/// </summary>
public static class DiagnosticReporting
{
/// <summary>
/// Reports a diagnostic with no source location.
/// </summary>
/// <param name="context">The source production context to report to.</param>
/// <param name="descriptor">The descriptor to report.</param>
/// <param name="messageArgs">Arguments for the descriptor's message format.</param>
public static void Report(
this SourceProductionContext context,
DiagnosticDescriptor descriptor,
params object?[] messageArgs) =>
context.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, messageArgs));

/// <summary>
/// Reports a diagnostic pointing at a position in a metadata file.
/// </summary>
/// <param name="context">The source production context to report to.</param>
/// <param name="descriptor">The descriptor to report.</param>
/// <param name="location">Where in the metadata the problem is, or <see langword="null"/>.</param>
/// <param name="messageArgs">Arguments for the descriptor's message format.</param>
public static void ReportAt(
this SourceProductionContext context,
DiagnosticDescriptor descriptor,
Location? location,
params object?[] messageArgs) =>
context.ReportDiagnostic(Diagnostic.Create(descriptor, location ?? Location.None, messageArgs));
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace Semantics.SourceGenerators;
namespace Semantics.SourceGenerators.CodeGen;

using System;
using Microsoft.CodeAnalysis;
Expand Down
196 changes: 196 additions & 0 deletions Semantics.SourceGenerators/CodeGen/GeneratorBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace Semantics.SourceGenerators.CodeGen;

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using ktsu.CodeBlocker;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Semantics.SourceGenerators.Templates;

/// <summary>
/// Base class for a generator driven by one or more JSON metadata files supplied as
/// <c>AdditionalFiles</c>.
/// </summary>
/// <remarks>
/// Nothing here is specific to any one generator: it finds the declared metadata files, hands them
/// to <see cref="Generate"/>, and reports a diagnostic for anything missing or malformed. Declaring
/// more than one file is the normal case rather than a reason to reimplement
/// <see cref="Initialize"/>.
/// </remarks>
public abstract class GeneratorBase : IIncrementalGenerator
{
/// <summary>
/// Gets the names of the metadata files this generator reads, without their directories.
/// </summary>
protected abstract IReadOnlyList<string> MetadataFileNames { get; }

/// <summary>
/// Gets the catalogue this generator's diagnostics are allocated from.
/// </summary>
protected abstract DiagnosticCatalog Diagnostics { get; }

/// <summary>
/// Gets the descriptor reported when a declared metadata file is not in the compilation.
/// </summary>
protected abstract DiagnosticDescriptor MetadataFileMissing { get; }

/// <summary>
/// Gets the descriptor reported when a metadata file cannot be parsed. Its message format takes
/// the file name and the reason.
/// </summary>
protected abstract DiagnosticDescriptor MetadataParseFailed { get; }

/// <inheritdoc/>
public void Initialize(IncrementalGeneratorInitializationContext context)
{
IReadOnlyList<string> wanted = MetadataFileNames;

IncrementalValueProvider<ImmutableArray<MetadataFile>> metadataFiles = context.AdditionalTextsProvider
.Where(file => wanted.Any(name => IsNamed(file.Path, name)))
.Select((file, cancellationToken) =>
{
SourceText? sourceText = file.GetText(cancellationToken);
return new MetadataFile(NameOf(file.Path), sourceText?.ToString() ?? string.Empty, sourceText, file.Path);
})
.Where(file => file.Text.Length > 0)
.Collect();

context.RegisterSourceOutput(metadataFiles, (productionContext, files) =>
{
// A duplicate name means the same metadata reached the compilation twice; the first
// wins, which is what the old EndsWith-plus-FirstOrDefault matching did implicitly.
Dictionary<string, MetadataFile> byName = files
.GroupBy(file => file.FileName, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);

// Previously a missing file produced no output and no explanation.
foreach (string name in wanted.Where(name => !byName.ContainsKey(name)))
{
productionContext.Report(MetadataFileMissing, name);
}

Generate(productionContext, new MetadataSet(byName));
});
}

/// <summary>
/// Emits this generator's sources.
/// </summary>
/// <param name="context">The source production context to add sources to.</param>
/// <param name="metadata">The metadata files this generator declared.</param>
protected abstract void Generate(SourceProductionContext context, MetadataSet metadata);

/// <summary>
/// Creates a <see cref="CodeBlocker"/> configured the way generated sources are written.
/// </summary>
/// <returns>A new <see cref="CodeBlocker"/>.</returns>
protected static CodeBlocker CreateCodeBlocker() => CodeBlocker.Create();

/// <summary>
/// Writes the header every generated file starts with.
/// </summary>
/// <param name="codeBlocker">The <see cref="CodeBlocker"/> to write to.</param>
/// <param name="copyright">
/// The copyright line written above the generated-file marker, without its <c>//</c> prefix.
/// <see langword="null"/> or empty writes only the marker.
/// </param>
/// <remarks>
/// A parameter rather than the literal this used to hard-code, so a consuming repository can
/// keep the header in step with its own file header template from one place.
/// <para>
/// Assembly-private only because <c>SourceFileTemplate</c> still lives in this project. Both
/// this and <see cref="WriteSourceFile"/> become <c>protected</c> once the template model comes
/// from <c>ktsu.CodeBlocker</c>, where it is already public.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="codeBlocker"/> is <see langword="null"/>.</exception>
private protected static void WriteFileHeader(CodeBlocker codeBlocker, string? copyright)
{
if (codeBlocker is null)
{
throw new ArgumentNullException(nameof(codeBlocker));
}

if (!string.IsNullOrEmpty(copyright))
{
codeBlocker.WriteLine($"// {copyright}");
}

codeBlocker.WriteLine("// <auto-generated />");
codeBlocker.NewLine();
}

/// <summary>
/// Writes a whole source file, header included.
/// </summary>
/// <param name="codeBlocker">The <see cref="CodeBlocker"/> to write to.</param>
/// <param name="sourceFileTemplate">The file to write.</param>
/// <param name="copyright">The copyright line, as for <see cref="WriteFileHeader"/>.</param>
private protected static void WriteSourceFile(CodeBlocker codeBlocker, SourceFileTemplate sourceFileTemplate, string? copyright)
{
WriteFileHeader(codeBlocker, copyright);
codeBlocker.AddSourceFile(sourceFileTemplate);
}

/// <summary>
/// Whether a path names the given file.
/// </summary>
/// <param name="path">The additional file's path.</param>
/// <param name="fileName">The file name to match.</param>
/// <returns>True when the path's last segment is exactly <paramref name="fileName"/>.</returns>
/// <remarks>
/// Matched on the whole last segment rather than with <c>EndsWith</c>, which also matched
/// anything whose name merely ended with the wanted one.
/// </remarks>
private static bool IsNamed(string path, string fileName) =>
string.Equals(NameOf(path), fileName, StringComparison.Ordinal);

/// <summary>
/// The last segment of a path, handling either directory separator so the generator behaves the
/// same wherever it runs.
/// </summary>
/// <param name="path">The path to take the name of.</param>
/// <returns>The path's last segment.</returns>
private static string NameOf(string path)
{
int separator = path.LastIndexOfAny(['/', '\\']);
return separator < 0 ? path : path.Substring(separator + 1);
}
}

/// <summary>
/// Base class for a generator driven by exactly one JSON metadata file.
/// </summary>
/// <typeparam name="T">The shape the metadata file deserializes into.</typeparam>
/// <param name="metadataFileName">The metadata file's name, without its directory.</param>
public abstract class GeneratorBase<T>(string metadataFileName) : GeneratorBase
where T : class
{
/// <inheritdoc/>
protected sealed override IReadOnlyList<string> MetadataFileNames => [metadataFileName];

/// <inheritdoc/>
protected sealed override void Generate(SourceProductionContext context, MetadataSet metadata)
{
T? deserialized = metadata[metadataFileName]?.Deserialize<T>(context, MetadataParseFailed);
if (deserialized is null)
{
return;
}

using CodeBlocker codeBlocker = CreateCodeBlocker();
Generate(context, deserialized, codeBlocker);
}

/// <summary>
/// Emits this generator's sources from its metadata.
/// </summary>
/// <param name="context">The source production context to add sources to.</param>
/// <param name="metadata">The deserialized metadata.</param>
/// <param name="codeBlocker">A <see cref="CodeBlocker"/> to build the output in.</param>
protected abstract void Generate(SourceProductionContext context, T metadata, CodeBlocker codeBlocker);
}
Loading
Loading