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
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ The solution is split into four main projects plus corresponding test projects:

**CompositeKey** (`src/CompositeKey/`) — Public API surface. Contains `[CompositeKey]` attribute, `[CompositeKeyConstructor]` attribute, and the `IPrimaryKey<TSelf>` / `ICompositePrimaryKey<TSelf>` interfaces. This is the NuGet package users install; analyzers and source generator ship embedded inside it.

**CompositeKey.SourceGeneration** (`src/CompositeKey.SourceGeneration/`) — Incremental source generator implementing `IIncrementalGenerator`. Three key phases:
**CompositeKey.SourceGeneration** (`src/CompositeKey.SourceGeneration/`) — Incremental source generator implementing `IIncrementalGenerator`. Key files:

- `SourceGenerator.cs` — Entry point; uses `ForAttributeWithMetadataName` for incremental pipeline
- `SourceGenerator.Parser.cs` — Extracts attribute data, validates types, builds `GenerationSpec` model
- `SourceGenerator.Emitter.cs` — Generates `ToString()`, `Parse()`, `TryParse()`, `ToPartitionKeyString()`, `ToSortKeyString()`, partial formatting methods, and `ISpanParsable<TSelf>` implementations
- `Parser.cs` — Extracts attribute data, validates types, builds `GenerationSpec` model; `Parse` returns a `(GenerationSpec?, Diagnostics)` tuple
- `DiagnosticContext.cs` — Carries `CurrentLocation`, accumulated diagnostics, and `ReportDiagnostic` with source-tree fallback logic; created per `Parse` invocation
- `Emitter.cs` — Generates `ToString()`, `Parse()`, `TryParse()`, `ToPartitionKeyString()`, `ToSortKeyString()`, partial formatting methods, and `ISpanParsable<TSelf>` implementations

**CompositeKey.Analyzers.Common** (`src/CompositeKey.Analyzers.Common/`) — Shared validation logic used by both the source generator and IDE analyzers. Contains `TemplateStringTokenizer`, type/template/property validation, and all `DiagnosticDescriptor` definitions (COMPOSITE0001–COMPOSITE0008+).

Expand Down
141 changes: 141 additions & 0 deletions src/CompositeKey.SourceGeneration.UnitTests/DiagnosticContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using CompositeKey.SourceGeneration.Core;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;

namespace CompositeKey.SourceGeneration.UnitTests;

public static class DiagnosticContextTests
{
#pragma warning disable RS2008 // Enable analyzer release tracking
private static readonly DiagnosticDescriptor TestDescriptor = new(
id: "TEST0001",
title: "Test Diagnostic",
messageFormat: "Test message: {0}",
category: "Test",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
#pragma warning restore RS2008

private static Compilation CreateTestCompilation()
{
return CompilationHelper.CreateCompilation("public class Placeholder { }");
}

private static Location CreateLocationInCompilation(Compilation compilation)
{
var tree = compilation.SyntaxTrees.First();
return Location.Create(tree, TextSpan.FromBounds(0, 1));
}

private static Location CreateLocationOutsideCompilation()
{
var externalTree = CSharpSyntaxTree.ParseText("public class External { }");
return Location.Create(externalTree, TextSpan.FromBounds(0, 1));
}

[Fact]
public static void ReportDiagnostic_WithValidLocation_UsesProvidedLocation()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
var currentLocation = CreateLocationInCompilation(compilation);
context.CurrentLocation = currentLocation;

var providedLocation = CreateLocationInCompilation(compilation);
context.ReportDiagnostic(TestDescriptor, providedLocation, "arg1");

context.Diagnostics.Count.ShouldBe(1);
context.Diagnostics[0].Location.ShouldNotBeNull();
context.Diagnostics[0].Location!.SourceSpan.ShouldBe(providedLocation.SourceSpan);
}

[Fact]
public static void ReportDiagnostic_WithNullLocation_FallsBackToCurrentLocation()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
var currentLocation = CreateLocationInCompilation(compilation);
context.CurrentLocation = currentLocation;

context.ReportDiagnostic(TestDescriptor, null, "arg1");

context.Diagnostics.Count.ShouldBe(1);
context.Diagnostics[0].Location.ShouldNotBeNull();
context.Diagnostics[0].Location!.SourceSpan.ShouldBe(currentLocation.SourceSpan);
}

[Fact]
public static void ReportDiagnostic_WithLocationFromUnknownTree_FallsBackToCurrentLocation()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
var currentLocation = CreateLocationInCompilation(compilation);
context.CurrentLocation = currentLocation;

var externalLocation = CreateLocationOutsideCompilation();
context.ReportDiagnostic(TestDescriptor, externalLocation, "arg1");

context.Diagnostics.Count.ShouldBe(1);
context.Diagnostics[0].Location.ShouldNotBeNull();
context.Diagnostics[0].Location!.SourceSpan.ShouldBe(currentLocation.SourceSpan);
}

[Fact]
public static void ReportDiagnostic_AccumulatesMultipleDiagnostics()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
context.CurrentLocation = CreateLocationInCompilation(compilation);

context.ReportDiagnostic(TestDescriptor, null, "first");
context.ReportDiagnostic(TestDescriptor, null, "second");
context.ReportDiagnostic(TestDescriptor, null, "third");

context.Diagnostics.Count.ShouldBe(3);
}

[Fact]
public static void ReportDiagnostic_PassesMessageArgsCorrectly()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
context.CurrentLocation = CreateLocationInCompilation(compilation);

context.ReportDiagnostic(TestDescriptor, null, "test-value");

context.Diagnostics.Count.ShouldBe(1);
var diagnostic = context.Diagnostics[0].CreateDiagnostic();
diagnostic.GetMessage().ShouldBe("Test message: test-value");
}

[Fact]
public static void Diagnostics_IsEmptyByDefault()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);

context.Diagnostics.Count.ShouldBe(0);
}

[Fact]
public static void CurrentLocation_IsNullByDefault()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);

context.CurrentLocation.ShouldBeNull();
}

[Fact]
public static void CurrentLocation_CanBeSet()
{
var compilation = CreateTestCompilation();
var context = new DiagnosticContext(compilation);
var location = CreateLocationInCompilation(compilation);

context.CurrentLocation = location;

context.CurrentLocation.ShouldBe(location);
}
}
27 changes: 27 additions & 0 deletions src/CompositeKey.SourceGeneration/DiagnosticContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Diagnostics;
using CompositeKey.SourceGeneration.Core;
using Microsoft.CodeAnalysis;

namespace CompositeKey.SourceGeneration;

internal sealed class DiagnosticContext(Compilation compilation)
{
private readonly Compilation _compilation = compilation;
private readonly List<DiagnosticInfo> _diagnostics = [];

public Location? CurrentLocation { get; set; }

public IReadOnlyList<DiagnosticInfo> Diagnostics => _diagnostics;

public void ReportDiagnostic(DiagnosticDescriptor descriptor, Location? location, params object?[]? messageArgs)
{
Debug.Assert(CurrentLocation != null);

if (location is null || (location.SourceTree is not null && !_compilation.ContainsSyntaxTree(location.SourceTree)))
location = CurrentLocation;

_diagnostics.Add(DiagnosticInfo.Create(descriptor, location, messageArgs));
}

public ImmutableEquatableArray<DiagnosticInfo> ToImmutableDiagnostics() => _diagnostics.ToImmutableEquatableArray();
}
Loading
Loading