From 3c8ef527ef1e9d186ed950edd97c29cd8d5c012a Mon Sep 17 00:00:00 2001 From: Max Obrist Date: Mon, 13 Apr 2026 01:34:39 +0700 Subject: [PATCH] feat(scribe): sdk rework and first draft collection and extraction pipeline --- .gitignore | 6 +- Directory.Build.props | 19 +- Directory.Build.targets | 2 +- Scribe.Ink/Scribe.Ink.csproj | 46 ++ Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs | 169 ++++++ .../Shapes/Fixes/AddAbstractModifierFix.cs | 47 ++ Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs | 57 ++ .../Shapes/Fixes/AddInterfaceToBaseListFix.cs | 51 ++ .../Shapes/Fixes/AddPartialModifierFix.cs | 35 ++ .../Shapes/Fixes/AddSealedModifierFix.cs | 49 ++ Scribe.Ink/Shapes/IShapeFix.cs | 21 + Scribe.Ink/Shapes/ShapeCodeFixProvider.cs | 78 +++ Scribe.Ink/Shapes/ShapeInkExtensions.cs | 37 ++ Scribe.Scriptorium/Scribe.Scriptorium.csproj | 29 + .../ShapeBuilderVariantsGenerator.cs | 332 ++++++++++++ Scribe.Sdk/Scribe.Sdk.csproj | 12 +- Scribe.Sdk/Sdk/Phases/Analyzer.targets | 45 ++ Scribe.Sdk/Sdk/Phases/Classification.targets | 24 + Scribe.Sdk/Sdk/Phases/Companion.targets | 77 +++ Scribe.Sdk/Sdk/Phases/Defaults.props | 43 ++ Scribe.Sdk/Sdk/Phases/Library.targets | 80 +++ Scribe.Sdk/Sdk/Phases/Meta.targets | 116 ++++ Scribe.Sdk/Sdk/Phases/Stubs.targets | 27 + Scribe.Sdk/Sdk/Phases/Validation.targets | 23 + Scribe.Sdk/Sdk/Sdk.props | 80 ++- Scribe.Sdk/Sdk/Sdk.targets | 67 +-- .../Attributes/AttributeSchemaTests.cs | 365 +++++++++++++ Scribe.Tests/Cache/DiagnosticInfoTests.cs | 102 ++++ Scribe.Tests/Cache/EquatableArrayTests.cs | 211 ++++++++ Scribe.Tests/Cache/InternPoolTests.cs | 42 ++ Scribe.Tests/Cache/LocationInfoTests.cs | 86 +++ Scribe.Tests/Cache/WellKnownTypesTests.cs | 76 +++ Scribe.Tests/Scribe.Tests.csproj | 3 + .../Shapes/CacheCorrectnessAnalyzerTests.cs | 222 ++++++++ .../Shapes/RelationPairIncrementalityTests.cs | 194 +++++++ Scribe.Tests/Shapes/RelationPairTests.cs | 199 +++++++ Scribe.Tests/Shapes/ShapeAnalyzerTests.cs | 156 ++++++ Scribe.Tests/Shapes/ShapeBuilderTests.cs | 286 ++++++++++ Scribe.Tests/Shapes/ShapeFixProviderTests.cs | 146 +++++ .../Shapes/ShapeSeverityVariantsTests.cs | 141 +++++ Scribe.Tests/Shapes/ShapeToProviderTests.cs | 181 +++++++ Scribe.slnx | 2 + Scribe/Attributes/AttributeReader.cs | 264 ++++++++++ Scribe/Attributes/AttributeSchema.cs | 150 ++++++ Scribe/Cache/DiagnosticInfo.cs | 50 ++ Scribe/Cache/EquatableArray.cs | 171 ++++++ Scribe/Cache/InternPool.cs | 46 ++ Scribe/Cache/LocationInfo.cs | 53 ++ Scribe/Cache/WellKnownTypes.cs | 83 +++ Scribe/Scribe.csproj | 9 + Scribe/Shapes/DiagnosticSpec.cs | 21 + Scribe/Shapes/FixSpec.cs | 49 ++ Scribe/Shapes/Relation.cs | 338 ++++++++++++ Scribe/Shapes/SeverityVariants.cs | 28 + Scribe/Shapes/Shape.cs | 38 ++ Scribe/Shapes/ShapeBuilder.cs | 497 ++++++++++++++++++ Scribe/Shapes/ShapeCheck.cs | 24 + Scribe/Shapes/ShapeProjectionContext.cs | 60 +++ Scribe/Shapes/Shape_T.ToAnalyzer.cs | 177 +++++++ Scribe/Shapes/Shape_T.cs | 201 +++++++ Scribe/Shapes/ShapedPair.cs | 16 + Scribe/Shapes/ShapedSymbol.cs | 21 + Scribe/Shapes/SquiggleAt.cs | 33 ++ Scribe/Shapes/SquiggleLocator.cs | 76 +++ Scribe/Shapes/TypeKindFilter.cs | 15 + Scribe/build/Scribe.LocalDev.props | 128 +++-- Scribe/build/Scribe.LocalDev.targets | 149 ++++-- Scribe/build/Scribe.SolutionAnalyzer.props | 99 ---- Scribe/build/Scribe.SolutionAnalyzer.targets | 58 -- docs/architecture-infrastructure.md | 64 ++- docs/project-setup.md | 29 +- docs/solution-local-analyzers.md | 260 --------- 72 files changed, 6549 insertions(+), 642 deletions(-) create mode 100644 Scribe.Ink/Scribe.Ink.csproj create mode 100644 Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs create mode 100644 Scribe.Ink/Shapes/IShapeFix.cs create mode 100644 Scribe.Ink/Shapes/ShapeCodeFixProvider.cs create mode 100644 Scribe.Ink/Shapes/ShapeInkExtensions.cs create mode 100644 Scribe.Scriptorium/Scribe.Scriptorium.csproj create mode 100644 Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs create mode 100644 Scribe.Sdk/Sdk/Phases/Analyzer.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Classification.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Companion.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Defaults.props create mode 100644 Scribe.Sdk/Sdk/Phases/Library.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Meta.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Stubs.targets create mode 100644 Scribe.Sdk/Sdk/Phases/Validation.targets create mode 100644 Scribe.Tests/Attributes/AttributeSchemaTests.cs create mode 100644 Scribe.Tests/Cache/DiagnosticInfoTests.cs create mode 100644 Scribe.Tests/Cache/EquatableArrayTests.cs create mode 100644 Scribe.Tests/Cache/InternPoolTests.cs create mode 100644 Scribe.Tests/Cache/LocationInfoTests.cs create mode 100644 Scribe.Tests/Cache/WellKnownTypesTests.cs create mode 100644 Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs create mode 100644 Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs create mode 100644 Scribe.Tests/Shapes/RelationPairTests.cs create mode 100644 Scribe.Tests/Shapes/ShapeAnalyzerTests.cs create mode 100644 Scribe.Tests/Shapes/ShapeBuilderTests.cs create mode 100644 Scribe.Tests/Shapes/ShapeFixProviderTests.cs create mode 100644 Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs create mode 100644 Scribe.Tests/Shapes/ShapeToProviderTests.cs create mode 100644 Scribe/Attributes/AttributeReader.cs create mode 100644 Scribe/Attributes/AttributeSchema.cs create mode 100644 Scribe/Cache/DiagnosticInfo.cs create mode 100644 Scribe/Cache/EquatableArray.cs create mode 100644 Scribe/Cache/InternPool.cs create mode 100644 Scribe/Cache/LocationInfo.cs create mode 100644 Scribe/Cache/WellKnownTypes.cs create mode 100644 Scribe/Shapes/DiagnosticSpec.cs create mode 100644 Scribe/Shapes/FixSpec.cs create mode 100644 Scribe/Shapes/Relation.cs create mode 100644 Scribe/Shapes/SeverityVariants.cs create mode 100644 Scribe/Shapes/Shape.cs create mode 100644 Scribe/Shapes/ShapeBuilder.cs create mode 100644 Scribe/Shapes/ShapeCheck.cs create mode 100644 Scribe/Shapes/ShapeProjectionContext.cs create mode 100644 Scribe/Shapes/Shape_T.ToAnalyzer.cs create mode 100644 Scribe/Shapes/Shape_T.cs create mode 100644 Scribe/Shapes/ShapedPair.cs create mode 100644 Scribe/Shapes/ShapedSymbol.cs create mode 100644 Scribe/Shapes/SquiggleAt.cs create mode 100644 Scribe/Shapes/SquiggleLocator.cs create mode 100644 Scribe/Shapes/TypeKindFilter.cs delete mode 100644 Scribe/build/Scribe.SolutionAnalyzer.props delete mode 100644 Scribe/build/Scribe.SolutionAnalyzer.targets delete mode 100644 docs/solution-local-analyzers.md diff --git a/.gitignore b/.gitignore index cbca5af..f7684e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -# Scribe LocalDev sentinel -.localscribe +# Scribe LocalDev sentinels — per-producer (.scribe.user, .hermetic.local, ...) +# Either suffix activates producer mode; both are supported (see Scribe docs). +*.user +*.local # Build output bin/ diff --git a/Directory.Build.props b/Directory.Build.props index a3f7760..e48862f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,17 +12,20 @@ true false - - $(NoWarn);CA1707 + + $(NoWarn);CA1707;RS2008 + so we set them here and import the props file directly. + $(ScribesName) drives sentinel detection (.scribe.user or .scribe.local), + auto-pack, and override-file naming. --> $(MSBuildThisFileDirectory).. - Scribe - BulletsForHumanity.Scribe + Scribe @@ -63,9 +66,9 @@ ============================================================ --> - $(ArtifactsPath)packages/ + When this producer is in local mode, Scribe.LocalDev.props redirects the + trigger project to the shared artifacts directory — don't overwrite that. --> + $(ArtifactsPath)packages/ Max Obrist Bullets for Humanity LICENSE diff --git a/Directory.Build.targets b/Directory.Build.targets index edc0cb5..1847a58 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -15,7 +15,7 @@ Name="_ScribeLocalDevTimestampVersion" AfterTargets="GetBuildVersion" BeforeTargets="Build;Pack;GenerateNuspec" - Condition="'$(IsLocalScribe)' == 'true' and '$(IsPackable)' != 'false'" + Condition="'$(IsLocalProducer)' == 'true' and '$(IsPackable)' != 'false'" > <_DevTimestamp>$([System.DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss-fff")) diff --git a/Scribe.Ink/Scribe.Ink.csproj b/Scribe.Ink/Scribe.Ink.csproj new file mode 100644 index 0000000..986d483 --- /dev/null +++ b/Scribe.Ink/Scribe.Ink.csproj @@ -0,0 +1,46 @@ + + + + netstandard2.0 + 14 + true + false + Analyzer + false + embedded + false + true + + $(NoWarn);NU5128;RS1038;RS2008 + BulletsForHumanity.Scribe.Ink + Roslyn analyzers, code fix providers, and source generators for Scribe shapes. The scribe dips the quill in ink to mark, correct, and emend. + roslyn;analyzer;code-fix;source-generator;scribe + + + + + + + + + + + + + + + + + + + + diff --git a/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs new file mode 100644 index 0000000..33b6d0d --- /dev/null +++ b/Scribe.Ink/Shapes/CacheCorrectnessAnalyzer.cs @@ -0,0 +1,169 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Scribe.Ink.Shapes; + +/// +/// SCRIBE101 — forbid Roslyn reference types on any TModel passed to +/// Shape<TModel>.Project<TModel>. Holding an ISymbol, +/// SyntaxNode, Compilation, SemanticModel, SyntaxTree, +/// Location, or AttributeData in a cached model defeats the +/// incremental generator cache: those objects identity-compare per compilation. +/// Extract the primitive data you need into strings, equatable arrays, or +/// instead. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CacheCorrectnessAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "SCRIBE101"; + + private static readonly DiagnosticDescriptor Descriptor = new( + id: DiagnosticId, + title: "Cache-hostile type in Shape projection model", + messageFormat: "Member '{0}' of type '{1}' is a Roslyn reference type ('{2}') — storing it in a Shape projection model defeats incremental caching. Extract primitives or use LocationInfo instead.", + category: "Scribe.Cache", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics { get; } + = ImmutableArray.Create(Descriptor); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + var shapeBuilder = context.Compilation.GetTypeByMetadataName("Scribe.Shapes.ShapeBuilder"); + if (shapeBuilder is null) + { + return; + } + + var forbidden = CollectForbiddenTypes(context.Compilation); + if (forbidden.IsEmpty) + { + return; + } + + context.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, shapeBuilder, forbidden), + OperationKind.Invocation); + } + + private static ImmutableArray CollectForbiddenTypes(Compilation compilation) + { + var builder = ImmutableArray.CreateBuilder(); + void Add(string metadataName) + { + var t = compilation.GetTypeByMetadataName(metadataName); + if (t is not null) + { + builder.Add(t); + } + } + + Add("Microsoft.CodeAnalysis.ISymbol"); + Add("Microsoft.CodeAnalysis.SyntaxNode"); + Add("Microsoft.CodeAnalysis.SyntaxTree"); + Add("Microsoft.CodeAnalysis.SemanticModel"); + Add("Microsoft.CodeAnalysis.Compilation"); + Add("Microsoft.CodeAnalysis.Location"); + Add("Microsoft.CodeAnalysis.AttributeData"); + + return builder.ToImmutable(); + } + + private static void AnalyzeInvocation( + OperationAnalysisContext context, + INamedTypeSymbol shapeBuilder, + ImmutableArray forbidden) + { + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + var method = invocation.TargetMethod; + if (method.Name != "Project" + || method.TypeArguments.Length != 1 + || !SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, shapeBuilder)) + { + return; + } + + if (method.TypeArguments[0] is not INamedTypeSymbol model) + { + return; + } + + foreach (var member in model.GetMembers()) + { + if (member.IsStatic) + { + continue; + } + + var memberType = member switch + { + IFieldSymbol f when !f.IsConst && !f.IsImplicitlyDeclared => f.Type, + IPropertySymbol p when !p.IsIndexer => p.Type, + _ => null, + }; + + if (memberType is null) + { + continue; + } + + var offender = FindForbidden(memberType, forbidden); + if (offender is null) + { + continue; + } + + var location = member.Locations.Length > 0 ? member.Locations[0] : invocation.Syntax.GetLocation(); + context.ReportDiagnostic(Diagnostic.Create( + Descriptor, + location, + member.Name, + model.Name, + offender.ToDisplayString())); + } + } + + private static ITypeSymbol? FindForbidden(ITypeSymbol type, ImmutableArray forbidden) + { + var current = type; + while (current is not null) + { + foreach (var banned in forbidden) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, banned)) + { + return banned; + } + } + + foreach (var iface in current.AllInterfaces) + { + foreach (var banned in forbidden) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, banned)) + { + return banned; + } + } + } + + current = current.BaseType; + } + + return null; + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs new file mode 100644 index 0000000..9b73985 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddAbstractModifierFix.cs @@ -0,0 +1,47 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class AddAbstractModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Add 'abstract' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + if (typeDecl.Modifiers.Any(SyntaxKind.AbstractKeyword)) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var abstractToken = SyntaxFactory.Token(SyntaxKind.AbstractKeyword) + .WithTrailingTrivia(SyntaxFactory.Space); + + var modifiers = typeDecl.Modifiers; + var insertAt = modifiers.Count; + for (var i = 0; i < modifiers.Count; i++) + { + if (modifiers[i].IsKind(SyntaxKind.PartialKeyword)) + { + insertAt = i; + break; + } + } + + var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, abstractToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs b/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs new file mode 100644 index 0000000..d7ed531 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddAttributeFix.cs @@ -0,0 +1,57 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class AddAttributeFix : IShapeFix +{ + public string Title(Diagnostic diagnostic) + { + var name = AttributeName(diagnostic); + return name is null ? "Add required attribute" : $"Add '[{Shorten(name)}]'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + var attributeName = AttributeName(diagnostic); + if (attributeName is null) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var attr = SyntaxFactory.Attribute(SyntaxFactory.ParseName(Shorten(attributeName))); + var attrList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attr)); + + var newTypeDecl = typeDecl.WithAttributeLists(typeDecl.AttributeLists.Add(attrList)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + private static string? AttributeName(Diagnostic diagnostic) + { + return diagnostic.Properties.TryGetValue("attribute", out var name) && !string.IsNullOrEmpty(name) + ? name + : null; + } + + // Strip trailing "Attribute" for the bracket form; keep full name otherwise. + private static string Shorten(string name) + { + const string Suffix = "Attribute"; + return name.EndsWith(Suffix, System.StringComparison.Ordinal) + ? name.Substring(0, name.Length - Suffix.Length) + : name; + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs b/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs new file mode 100644 index 0000000..0f76201 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddInterfaceToBaseListFix.cs @@ -0,0 +1,51 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class AddInterfaceToBaseListFix : IShapeFix +{ + public string Title(Diagnostic diagnostic) + { + var name = InterfaceName(diagnostic); + return name is null ? "Add required interface" : $"Implement '{name}'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + var interfaceName = InterfaceName(diagnostic); + if (interfaceName is null) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var baseType = SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(interfaceName)); + + var newTypeDecl = typeDecl.BaseList is null + ? typeDecl.WithBaseList(SyntaxFactory.BaseList( + SyntaxFactory.SingletonSeparatedList(baseType))) + : typeDecl.WithBaseList(typeDecl.BaseList.AddTypes(baseType)); + + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + private static string? InterfaceName(Diagnostic diagnostic) + { + return diagnostic.Properties.TryGetValue("interface", out var name) && !string.IsNullOrEmpty(name) + ? name + : null; + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs new file mode 100644 index 0000000..5659f0f --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddPartialModifierFix.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class AddPartialModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Add 'partial' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + if (typeDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var partialToken = SyntaxFactory.Token(SyntaxKind.PartialKeyword) + .WithTrailingTrivia(SyntaxFactory.Space); + var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Add(partialToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs new file mode 100644 index 0000000..9eec62d --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddSealedModifierFix.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class AddSealedModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Add 'sealed' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + if (typeDecl.Modifiers.Any(SyntaxKind.SealedKeyword)) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var sealedToken = SyntaxFactory.Token(SyntaxKind.SealedKeyword) + .WithTrailingTrivia(SyntaxFactory.Space); + + // Insert 'sealed' before 'partial' / 'abstract' / type keyword for idiomatic ordering. + var modifiers = typeDecl.Modifiers; + var insertAt = modifiers.Count; + for (var i = 0; i < modifiers.Count; i++) + { + if (modifiers[i].IsKind(SyntaxKind.PartialKeyword) + || modifiers[i].IsKind(SyntaxKind.AbstractKeyword)) + { + insertAt = i; + break; + } + } + + var newTypeDecl = typeDecl.WithModifiers(modifiers.Insert(insertAt, sealedToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/IShapeFix.cs b/Scribe.Ink/Shapes/IShapeFix.cs new file mode 100644 index 0000000..f8253a5 --- /dev/null +++ b/Scribe.Ink/Shapes/IShapeFix.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes; + +/// +/// A single code-fix action that operates on a type declaration. Each +/// maps to one implementation. +/// +internal interface IShapeFix +{ + string Title(Diagnostic diagnostic); + + Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct); +} diff --git a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs new file mode 100644 index 0000000..ca00f35 --- /dev/null +++ b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Scribe.Ink.Shapes.Fixes; +using Scribe.Shapes; + +namespace Scribe.Ink.Shapes; + +/// +/// Dispatching for Shape-emitted diagnostics. +/// Reads the fixKind property written by the analyzer and delegates +/// to the matching implementation. +/// +internal sealed class ShapeCodeFixProvider : CodeFixProvider +{ + private readonly ImmutableArray _ids; + + internal ShapeCodeFixProvider(ImmutableArray diagnosticIds) => _ids = diagnosticIds; + + public override ImmutableArray FixableDiagnosticIds => _ids; + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Properties.TryGetValue("fixKind", out var fixKindStr) + || string.IsNullOrEmpty(fixKindStr) + || !Enum.TryParse(fixKindStr, out var fixKind) + || fixKind == FixKind.None) + { + continue; + } + + var fix = ResolveFix(fixKind); + if (fix is null) + { + continue; + } + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var typeDecl = node.AncestorsAndSelf().OfType().FirstOrDefault(); + if (typeDecl is null) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: fix.Title(diagnostic), + createChangedDocument: ct => fix.FixAsync(context.Document, typeDecl, diagnostic, ct), + equivalenceKey: fixKind.ToString()), + diagnostic); + } + } + + private static IShapeFix? ResolveFix(FixKind kind) => kind switch + { + FixKind.AddPartialModifier => new AddPartialModifierFix(), + FixKind.AddSealedModifier => new AddSealedModifierFix(), + FixKind.AddInterfaceToBaseList => new AddInterfaceToBaseListFix(), + FixKind.AddAttribute => new AddAttributeFix(), + _ => null, + }; +} diff --git a/Scribe.Ink/Shapes/ShapeInkExtensions.cs b/Scribe.Ink/Shapes/ShapeInkExtensions.cs new file mode 100644 index 0000000..17cb1aa --- /dev/null +++ b/Scribe.Ink/Shapes/ShapeInkExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CodeFixes; +using Scribe.Shapes; + +namespace Scribe.Ink.Shapes; + +/// +/// Entry points that project a into the Roslyn +/// analyzer/fixer surface. Lives in Scribe.Ink so Scribe core stays +/// free of the Microsoft.CodeAnalysis.CSharp.Workspaces dependency. +/// +public static class ShapeInkExtensions +{ + /// + /// Return a that offers code fixes for the + /// diagnostics emitted by this shape's analyzer. Package the returned + /// instance in a concrete [ExportCodeFixProvider]-attributed class + /// that delegates its members for deployment. + /// + public static CodeFixProvider ToFixProvider(this Shape shape) + where TModel : IEquatable + { + if (shape is null) + { + throw new ArgumentNullException(nameof(shape)); + } + + var builder = ImmutableArray.CreateBuilder(shape.CheckList.Length); + foreach (var check in shape.CheckList) + { + builder.Add(check.Id); + } + + return new ShapeCodeFixProvider(builder.ToImmutable()); + } +} diff --git a/Scribe.Scriptorium/Scribe.Scriptorium.csproj b/Scribe.Scriptorium/Scribe.Scriptorium.csproj new file mode 100644 index 0000000..a5a48bd --- /dev/null +++ b/Scribe.Scriptorium/Scribe.Scriptorium.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + 14 + true + false + false + false + embedded + false + + + + + + + + + + diff --git a/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs b/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs new file mode 100644 index 0000000..3562397 --- /dev/null +++ b/Scribe.Scriptorium/ShapeBuilderVariantsGenerator.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; + +namespace Scribe.Scriptorium; + +/// +/// Meta-generator. Runs at Scribe's own compile time. Scans +/// Scribe.Shapes.ShapeBuilder for every Must* primitive and +/// emits severity variants into a partial class: +/// +/// Must* (positive) → Should* (Warning) + Could* (Info) +/// MustNot*ShouldNot* (Warning) only +/// +/// Each variant delegates to the handwritten Must* method, overriding +/// only the severity when the caller has not supplied one. +/// +[Generator(LanguageNames.CSharp)] +public sealed class ShapeBuilderVariantsGenerator : IIncrementalGenerator +{ + private const string ShapeBuilderMetadataName = "Scribe.Shapes.ShapeBuilder"; + private const string DiagnosticSpecFqn = "Scribe.Shapes.DiagnosticSpec"; + + // FullyQualifiedFormat minus UseSpecialTypes, so `System.String` doesn't collapse to the + // `string` keyword — we need the real type name to be able to prepend `global::`. + private static readonly SymbolDisplayFormat FullyQualifiedNoKeywords = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var source = context.CompilationProvider + .Select((comp, ct) => BuildSource(comp, ct)); + + context.RegisterSourceOutput(source, (spc, src) => + { + if (!string.IsNullOrEmpty(src)) + { + spc.AddSource("ShapeBuilder.Variants.g.cs", src!); + } + }); + } + + private static string? BuildSource(Compilation compilation, CancellationToken ct) + { + var shapeBuilder = compilation.GetTypeByMetadataName(ShapeBuilderMetadataName); + if (shapeBuilder is null) + { + return null; + } + + var candidates = shapeBuilder.GetMembers() + .OfType() + .Where(IsVariantTarget) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToList(); + + if (candidates.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.CodeAnalysis;"); + sb.AppendLine(); + sb.AppendLine("namespace Scribe.Shapes;"); + sb.AppendLine(); + sb.AppendLine("partial class ShapeBuilder"); + sb.AppendLine("{"); + + var first = true; + foreach (var method in candidates) + { + ct.ThrowIfCancellationRequested(); + if (!first) + { + sb.AppendLine(); + } + + first = false; + EmitVariants(sb, method); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsVariantTarget(IMethodSymbol method) + { + if (method.DeclaredAccessibility != Accessibility.Public + || method.IsStatic + || method.MethodKind != MethodKind.Ordinary) + { + return false; + } + + if (!method.Name.StartsWith("Must", StringComparison.Ordinal)) + { + return false; + } + + if (method.ReturnType.ToDisplayString() != ShapeBuilderMetadataName) + { + return false; + } + + // Final parameter must be a DiagnosticSpec? — that's the override hook. + var lastParam = method.Parameters.LastOrDefault(); + if (lastParam is null) + { + return false; + } + + var type = lastParam.Type; + if (type is not INamedTypeSymbol named + || named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T + || named.TypeArguments.Length != 1 + || named.TypeArguments[0].ToDisplayString() != DiagnosticSpecFqn) + { + return false; + } + + return true; + } + + private static void EmitVariants(StringBuilder sb, IMethodSymbol method) + { + var isNegative = method.Name.StartsWith("MustNot", StringComparison.Ordinal); + var bareName = isNegative ? method.Name.Substring("MustNot".Length) : method.Name.Substring("Must".Length); + + if (isNegative) + { + EmitOne(sb, method, variantPrefix: "ShouldNot", bareName: bareName, severity: "Warning", + summary: $"Warning-level variant of ."); + } + else + { + EmitOne(sb, method, variantPrefix: "Should", bareName: bareName, severity: "Warning", + summary: $"Warning-level variant of ."); + sb.AppendLine(); + EmitOne(sb, method, variantPrefix: "Could", bareName: bareName, severity: "Info", + summary: $"Info-level variant of ."); + } + } + + private static void EmitOne( + StringBuilder sb, + IMethodSymbol method, + string variantPrefix, + string bareName, + string severity, + string summary) + { + var newName = variantPrefix + bareName; + + sb.Append(" /// ").Append(summary).AppendLine(""); + + sb.Append(" public global::Scribe.Shapes.ShapeBuilder ").Append(newName); + AppendTypeParameters(sb, method); + sb.Append('('); + AppendParameterList(sb, method); + sb.AppendLine(")"); + + AppendTypeParameterConstraints(sb, method); + + sb.Append(" => ").Append(method.Name); + AppendTypeParameters(sb, method); + sb.Append('('); + AppendForwardingArgs(sb, method, severity); + sb.AppendLine(");"); + } + + private static void AppendTypeParameters(StringBuilder sb, IMethodSymbol method) + { + if (method.TypeParameters.Length == 0) + { + return; + } + + sb.Append('<'); + for (var i = 0; i < method.TypeParameters.Length; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(method.TypeParameters[i].Name); + } + + sb.Append('>'); + } + + private static void AppendTypeParameterConstraints(StringBuilder sb, IMethodSymbol method) + { + foreach (var tp in method.TypeParameters) + { + var parts = new List(); + if (tp.HasReferenceTypeConstraint) + { + parts.Add("class"); + } + + if (tp.HasValueTypeConstraint) + { + parts.Add("struct"); + } + + if (tp.HasNotNullConstraint) + { + parts.Add("notnull"); + } + + if (tp.HasUnmanagedTypeConstraint) + { + parts.Add("unmanaged"); + } + + foreach (var ct in tp.ConstraintTypes) + { + parts.Add(ct.ToDisplayString(FullyQualifiedNoKeywords)); + } + + if (tp.HasConstructorConstraint) + { + parts.Add("new()"); + } + + if (parts.Count == 0) + { + continue; + } + + sb.Append(" where ").Append(tp.Name).Append(" : ").AppendLine(string.Join(", ", parts)); + } + } + + private static void AppendParameterList(StringBuilder sb, IMethodSymbol method) + { + for (var i = 0; i < method.Parameters.Length; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + var p = method.Parameters[i]; + sb.Append(p.Type.ToDisplayString(FullyQualifiedNoKeywords)); + sb.Append(' ').Append(p.Name); + + if (p.HasExplicitDefaultValue) + { + sb.Append(" = ").Append(FormatDefault(p.ExplicitDefaultValue, p.Type)); + } + } + } + + private static void AppendForwardingArgs(StringBuilder sb, IMethodSymbol method, string severity) + { + var last = method.Parameters.Length - 1; + for (var i = 0; i < method.Parameters.Length; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + var p = method.Parameters[i]; + if (i == last) + { + sb.Append("global::Scribe.Shapes.SeverityVariants.WithSeverity(") + .Append(p.Name) + .Append(", global::Microsoft.CodeAnalysis.DiagnosticSeverity.") + .Append(severity) + .Append(')'); + } + else + { + sb.Append(p.Name); + } + } + } + + private static string FormatDefault(object? value, ITypeSymbol type) + { + if (value is null) + { + return type.IsValueType && type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T + ? "default" + : "null"; + } + + return value switch + { + bool b => b ? "true" : "false", + string s => "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"", + _ => value.ToString() ?? "default", + }; + } + + private static string FormatXmlRef(IMethodSymbol method) + { + var builder = new StringBuilder(); + builder.Append("Scribe.Shapes.ShapeBuilder.").Append(method.Name); + if (method.TypeParameters.Length > 0) + { + builder.Append('{'); + for (var i = 0; i < method.TypeParameters.Length; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(method.TypeParameters[i].Name); + } + + builder.Append('}'); + } + + return builder.ToString(); + } +} diff --git a/Scribe.Sdk/Scribe.Sdk.csproj b/Scribe.Sdk/Scribe.Sdk.csproj index 741a423..7f26993 100644 --- a/Scribe.Sdk/Scribe.Sdk.csproj +++ b/Scribe.Sdk/Scribe.Sdk.csproj @@ -3,8 +3,8 @@ BulletsForHumanity.Scribe.Sdk — MSBuild SDK for Roslyn analyzer/generator projects. This is a pack-only project. It produces no build output — the NuGet package - contains only MSBuild props/targets, polyfill stubs, Solution-Local Analyzer - infrastructure, and LocalDev infrastructure. + contains only MSBuild props/targets (phased under Sdk/Phases/), polyfill stubs, + and LocalDev infrastructure. --> BulletsForHumanity.Scribe.Sdk @@ -19,7 +19,7 @@ $(NoWarn);NU5128;NU5129;NETSDK1212 true - MSBuild SDK for Roslyn analyzer and source generator projects. Provides default project configuration, analyzer packaging targets, netstandard2.0 polyfill stubs, solution-local analyzer auto-pack support, and LocalDev multi-repo development infrastructure. Use with: <Project Sdk="BulletsForHumanity.Scribe.Sdk"> + MSBuild SDK for Roslyn analyzer and source generator projects. Classifies projects as Library / Companion / Analyzer / Meta via <ScribeSdkProjectType>, automates Library↔Companion packaging (embedded analyzers in a single nupkg), provides Meta auto-pack for in-solution generators, ships netstandard2.0 polyfill stubs, and includes LocalDev multi-repo development infrastructure. Use with: <Project Sdk="BulletsForHumanity.Scribe.Sdk"> roslyn;analyzer;generator;source-generator;msbuild-sdk;scribe @@ -39,12 +39,6 @@ - - - - - - diff --git a/Scribe.Sdk/Sdk/Phases/Analyzer.targets b/Scribe.Sdk/Sdk/Phases/Analyzer.targets new file mode 100644 index 0000000..5608ed1 --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Analyzer.targets @@ -0,0 +1,45 @@ + + + + + + false + Analyzer + true + + + + + + + + + + + + + <_ScribeSdkDep Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' + and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True' + and '%(Extension)' == '.dll'" /> + + + + diff --git a/Scribe.Sdk/Sdk/Phases/Classification.targets b/Scribe.Sdk/Sdk/Phases/Classification.targets new file mode 100644 index 0000000..173597f --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Classification.targets @@ -0,0 +1,24 @@ + + + + + <_IsScribeLibrary Condition="'$(ScribeSdkProjectType)' == 'Library'">true + <_IsScribeCompanion Condition="'$(ScribeSdkProjectType)' == 'Companion'">true + <_IsScribeAnalyzer Condition="'$(ScribeSdkProjectType)' == 'Analyzer'">true + <_IsScribeMeta Condition="'$(ScribeSdkProjectType)' == 'Meta'">true + + diff --git a/Scribe.Sdk/Sdk/Phases/Companion.targets b/Scribe.Sdk/Sdk/Phases/Companion.targets new file mode 100644 index 0000000..99c6d27 --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Companion.targets @@ -0,0 +1,77 @@ + + + + --> + + + + + false + + false + + true + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + <_AnalyzerFile Include="$(OutputPath)$(AssemblyName).dll" /> + <_AnalyzerFile Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' + and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Common' + and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp' + and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp.Workspaces' + and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Workspaces.Common' + and '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'System.Collections.Immutable' + and '%(Extension)' == '.dll'" /> + + + + + + + + + + + diff --git a/Scribe.Sdk/Sdk/Phases/Defaults.props b/Scribe.Sdk/Sdk/Phases/Defaults.props new file mode 100644 index 0000000..260b8b9 --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Defaults.props @@ -0,0 +1,43 @@ + + + + + + netstandard2.0 + + + 14 + + + enable + + + true + + + false + embedded + + + false + + + $(NoWarn);NU5128 + + + true + + diff --git a/Scribe.Sdk/Sdk/Phases/Library.targets b/Scribe.Sdk/Sdk/Phases/Library.targets new file mode 100644 index 0000000..217f83c --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Library.targets @@ -0,0 +1,80 @@ + + + + + + + true + + $(NoWarn.Replace(';NU5128','')) + + + + + + + + + + + + + + + <_ScribeCompanionAnalyzerFile + Include="@(_ScribeCompanionAnalyzerFileRaw)" + Condition="'@(_ScribeCompanionAnalyzerName)' != '' + and $([System.String]::new('%3B@(_ScribeCompanionAnalyzerName)%3B').Contains('%3B%(_ScribeCompanionAnalyzerFileRaw.Filename)%(_ScribeCompanionAnalyzerFileRaw.Extension)%3B'))" /> + + + + + + diff --git a/Scribe.Sdk/Sdk/Phases/Meta.targets b/Scribe.Sdk/Sdk/Phases/Meta.targets new file mode 100644 index 0000000..9a6966f --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Meta.targets @@ -0,0 +1,116 @@ + + + + + + false + Analyzer + true + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + <_ScribeMetaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss')) + 0.0.0-dev.$(_ScribeMetaTimestamp) + 0.0.0-dev.$(_ScribeMetaTimestamp) + + + true + true + false + + + + + <_ScribeMetaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)')) + $(_ScribeMetaArtifactsDir)packages\ + $(RestoreAdditionalProjectSources);$(_ScribeMetaArtifactsDir)packages + + + + + + + + + + + + <_ScribeSdkMetaDep Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' + and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True' + and '%(Extension)' == '.dll'" /> + + + + + + + + + + + + + + + <_ScribeMetaOverrideVersion>$(NuGetPackageVersion) + <_ScribeMetaOverrideVersion Condition="'$(_ScribeMetaOverrideVersion)' == ''">$(PackageVersion) + <_ScribeMetaOverridePath>$(_ScribeMetaArtifactsDir)$(PackageId).Directory.Packages.targets + + + <_ScribeMetaOverrideLine Include="<Project>" /> + <_ScribeMetaOverrideLine Include=" <!-- Auto-generated by Scribe.Sdk (Meta). Rebuild to refresh. -->" /> + <_ScribeMetaOverrideLine Include=" <ItemGroup>" /> + <_ScribeMetaOverrideLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeMetaOverrideVersion)" />" /> + <_ScribeMetaOverrideLine Include=" </ItemGroup>" /> + <_ScribeMetaOverrideLine Include="</Project>" /> + + + + + + diff --git a/Scribe.Sdk/Sdk/Phases/Stubs.targets b/Scribe.Sdk/Sdk/Phases/Stubs.targets new file mode 100644 index 0000000..fbfe015 --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Stubs.targets @@ -0,0 +1,27 @@ + + + + + + + diff --git a/Scribe.Sdk/Sdk/Phases/Validation.targets b/Scribe.Sdk/Sdk/Phases/Validation.targets new file mode 100644 index 0000000..5fe5adc --- /dev/null +++ b/Scribe.Sdk/Sdk/Phases/Validation.targets @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/Scribe.Sdk/Sdk/Sdk.props b/Scribe.Sdk/Sdk/Sdk.props index 8471c46..bd3f7b6 100644 --- a/Scribe.Sdk/Sdk/Sdk.props +++ b/Scribe.Sdk/Sdk/Sdk.props @@ -1,64 +1,46 @@ - + - - - - - netstandard2.0 - - 14 - - true - - false - - Analyzer - - false - embedded - - enable - - false - - $(NoWarn);NU5128 - - true - + + - - - - true - - - - - - + diff --git a/Scribe.Sdk/Sdk/Sdk.targets b/Scribe.Sdk/Sdk/Sdk.targets index 3832a11..d1b9731 100644 --- a/Scribe.Sdk/Sdk/Sdk.targets +++ b/Scribe.Sdk/Sdk/Sdk.targets @@ -1,61 +1,24 @@ - + - - - - - - - - - - - - - - - - - - - - <_ScribeSdkDeps Include="@(ReferenceCopyLocalPaths)" - Condition="'%(ReferenceCopyLocalPaths.NuGetPackageId)' != '' - and '$([System.String]::Copy("%(ReferenceCopyLocalPaths.NuGetPackageId)").StartsWith("Microsoft.CodeAnalysis"))' != 'True' - and '%(Extension)' == '.dll'" /> - - - - - - - - + + + + + + + + + + diff --git a/Scribe.Tests/Attributes/AttributeSchemaTests.cs b/Scribe.Tests/Attributes/AttributeSchemaTests.cs new file mode 100644 index 0000000..1575c4a --- /dev/null +++ b/Scribe.Tests/Attributes/AttributeSchemaTests.cs @@ -0,0 +1,365 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Attributes; +using Scribe.Cache; + +namespace Scribe.Tests.Attributes; + +public class AttributeSchemaTests +{ + private static INamedTypeSymbol GetType(string source, string metadataName = "Target") + { + var tree = CSharpSyntaxTree.ParseText( + source, + cancellationToken: TestContext.Current.CancellationToken); + var compilation = CSharpCompilation.Create( + "TestAsm", + syntaxTrees: [tree], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location), + ]); + + var symbol = compilation.GetTypeByMetadataName(metadataName); + symbol.ShouldNotBeNull($"type '{metadataName}' not resolved; diagnostics: " + + string.Join("; ", compilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()))); + return symbol!; + } + + // ─────────────────────────────────────────────────────────────── + // Has / For basics + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Has_ReturnsTrue_WhenAttributePresent() + { + var symbol = GetType(""" + using System; + [AttributeUsage(AttributeTargets.Class)] + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """); + + AttributeSchema.Has(symbol, "FooAttribute").ShouldBeTrue(); + } + + [Fact] + public void Has_ReturnsFalse_WhenAttributeAbsent() + { + var symbol = GetType("public class Target {}"); + AttributeSchema.Has(symbol, "FooAttribute").ShouldBeFalse(); + } + + [Fact] + public void Has_MatchesWithOrWithoutAttributeSuffix() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """); + + AttributeSchema.Has(symbol, "FooAttribute").ShouldBeTrue(); + AttributeSchema.Has(symbol, "Foo").ShouldBeTrue(); + } + + [Fact] + public void For_MissingAttribute_ReaderExistsIsFalse() + { + var symbol = GetType("public class Target {}"); + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Exists.ShouldBeFalse(); + } + + [Fact] + public void For_MissingAttribute_ReadsReturnDefault_NoErrors() + { + var symbol = GetType("public class Target {}"); + var reader = AttributeSchema.For(symbol, "FooAttribute"); + + reader.Ctor(0).ShouldBeNull(); + reader.Named("X", 42).ShouldBe(42); + reader.DrainErrors().IsEmpty.ShouldBeTrue(); + } + + // ─────────────────────────────────────────────────────────────── + // Ctor / CtorOpt + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Ctor_StringArg_HappyPath() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string name) {} } + [Foo("hello")] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Exists.ShouldBeTrue(); + reader.Ctor(0).ShouldBe("hello"); + reader.DrainErrors().IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void Ctor_IntArg_HappyPath() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(int value) {} } + [Foo(42)] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Ctor(0).ShouldBe(42); + } + + [Fact] + public void Ctor_MultipleArgs_ReadByIndex() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a, int b) {} } + [Foo("x", 7)] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Ctor(0).ShouldBe("x"); + reader.Ctor(1).ShouldBe(7); + } + + [Fact] + public void Ctor_OutOfRange_ReturnsDefault_PushesError() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} } + [Foo("x")] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Ctor(99).ShouldBeNull(); + + var errors = reader.DrainErrors(); + errors.IsEmpty.ShouldBeFalse(); + errors[0].Id.ShouldBe(DiagnosticIds.AttributeCtorMissing); + } + + [Fact] + public void Ctor_TypeMismatch_ReturnsDefault_PushesError() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} } + [Foo("x")] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Ctor(0).ShouldBe(0); + + var errors = reader.DrainErrors(); + errors.IsEmpty.ShouldBeFalse(); + errors[0].Id.ShouldBe(DiagnosticIds.AttributeValueMismatch); + } + + [Fact] + public void CtorOpt_OutOfRange_SilentDefault() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} } + [Foo("x")] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.CtorOpt(99).ShouldBeNull(); + reader.DrainErrors().IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void CtorArgCount_ReportsActualCount() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(int a, int b, int c) {} } + [Foo(1, 2, 3)] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.CtorArgCount.ShouldBe(3); + } + + // ─────────────────────────────────────────────────────────────── + // Named + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Named_HappyPath() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public int Max { get; set; } } + [Foo(Max = 100)] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Named("Max", 0).ShouldBe(100); + } + + [Fact] + public void Named_Missing_ReturnsFallback() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public int Max { get; set; } } + [Foo] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Named("Max", 42).ShouldBe(42); + reader.DrainErrors().IsEmpty.ShouldBeTrue(); + } + + // ─────────────────────────────────────────────────────────────── + // TypeArg + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void TypeArg_ReadsGenericArgument() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + var typeArg = reader.TypeArg(0); + typeArg.ShouldNotBeNull(); + typeArg!.Name.ShouldBe("Int32"); + } + + [Fact] + public void TypeArg_OutOfRange_PushesError() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.TypeArg(0).ShouldBeNull(); + reader.DrainErrors()[0].Id.ShouldBe(DiagnosticIds.AttributeTypeArgMissing); + } + + // ─────────────────────────────────────────────────────────────── + // Location + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Location_CapturedForPresentAttribute() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """); + + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Location.ShouldNotBeNull(); + } + + [Fact] + public void Location_NullForMissingAttribute() + { + var symbol = GetType("public class Target {}"); + var reader = AttributeSchema.For(symbol, "FooAttribute"); + reader.Location.ShouldBeNull(); + } + + // ─────────────────────────────────────────────────────────────── + // Read projection + // ─────────────────────────────────────────────────────────────── + + private readonly record struct FooModel(string Name, int Max); + + [Fact] + public void Read_Projection_CapturesModelAndExists() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute + { + public FooAttribute(string name) {} + public int Max { get; set; } + } + [Foo("alpha", Max = 7)] public class Target {} + """); + + var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) => + new FooModel( + Name: r.Ctor(0) ?? string.Empty, + Max: r.Named("Max", 0))); + + result.Exists.ShouldBeTrue(); + result.Model.Name.ShouldBe("alpha"); + result.Model.Max.ShouldBe(7); + result.Errors.IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void Read_MissingAttribute_ProducesDefaultModel_ExistsFalse() + { + var symbol = GetType("public class Target {}"); + + var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) => + new FooModel( + Name: r.Ctor(0) ?? "fallback", + Max: r.Named("Max", 99))); + + result.Exists.ShouldBeFalse(); + result.Model.Name.ShouldBe("fallback"); + result.Model.Max.ShouldBe(99); + result.Errors.IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void Read_CollectsErrors_FromTypedReadMismatches() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} } + [Foo("x")] public class Target {} + """); + + var result = AttributeSchema.Read(symbol, "FooAttribute", (ref AttributeReader r) => + new FooModel( + Name: r.Ctor(0) ?? "", + Max: r.Ctor(5))); // wrong index — pushes error + + result.Exists.ShouldBeTrue(); + result.Errors.IsEmpty.ShouldBeFalse(); + result.Errors[0].Id.ShouldBe(DiagnosticIds.AttributeCtorMissing); + } + + [Fact] + public void Read_IsEquatable_ByValue() + { + var source = """ + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string a) {} } + [Foo("x")] public class Target {} + """; + var a = AttributeSchema.Read(GetType(source), "FooAttribute", (ref AttributeReader r) => + new FooModel(r.Ctor(0) ?? "", 0)); + var b = AttributeSchema.Read(GetType(source), "FooAttribute", (ref AttributeReader r) => + new FooModel(r.Ctor(0) ?? "", 0)); + + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } +} diff --git a/Scribe.Tests/Cache/DiagnosticInfoTests.cs b/Scribe.Tests/Cache/DiagnosticInfoTests.cs new file mode 100644 index 0000000..938e0fc --- /dev/null +++ b/Scribe.Tests/Cache/DiagnosticInfoTests.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Scribe.Cache; + +namespace Scribe.Tests.Cache; + +public class DiagnosticInfoTests +{ + private static readonly DiagnosticDescriptor TestDescriptor = new( + id: "SCRIBE001", + title: "Test rule", + messageFormat: "Type '{0}' must be partial", + category: "Scribe.Test", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + [Fact] + public void Materialize_ProducesDiagnostic_WithSameId() + { + var info = new DiagnosticInfo( + Id: "SCRIBE001", + Severity: DiagnosticSeverity.Error, + MessageArgs: EquatableArray.Create("Foo"), + Location: null); + + var diag = info.Materialize(TestDescriptor); + + diag.Id.ShouldBe("SCRIBE001"); + diag.Severity.ShouldBe(DiagnosticSeverity.Error); + } + + [Fact] + public void Materialize_FormatsMessageArgs() + { + var info = new DiagnosticInfo( + Id: "SCRIBE001", + Severity: DiagnosticSeverity.Error, + MessageArgs: EquatableArray.Create("Bar"), + Location: null); + + var diag = info.Materialize(TestDescriptor); + + diag.GetMessage(CultureInfo.InvariantCulture).ShouldBe("Type 'Bar' must be partial"); + } + + [Fact] + public void Materialize_WithNullLocation_UsesLocationNone() + { + var info = new DiagnosticInfo( + Id: "SCRIBE001", + Severity: DiagnosticSeverity.Error, + MessageArgs: EquatableArray.Empty, + Location: null); + + var diag = info.Materialize(TestDescriptor); + + diag.Location.ShouldBe(Location.None); + } + + [Fact] + public void Materialize_WithLocation_UsesProvidedLocation() + { + var location = new LocationInfo( + FilePath: "test.cs", + TextSpan: new TextSpan(0, 5), + LineSpan: new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 5))); + var info = new DiagnosticInfo( + Id: "SCRIBE001", + Severity: DiagnosticSeverity.Error, + MessageArgs: EquatableArray.Empty, + Location: location); + + var diag = info.Materialize(TestDescriptor); + + diag.Location.GetLineSpan().Path.ShouldBe("test.cs"); + diag.Location.SourceSpan.ShouldBe(new TextSpan(0, 5)); + } + + [Fact] + public void Value_Equality_Works() + { + var a = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning, + EquatableArray.Create("X"), null); + var b = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning, + EquatableArray.Create("X"), null); + + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact] + public void Different_Ids_Not_Equal() + { + var a = new DiagnosticInfo("SCRIBE001", DiagnosticSeverity.Warning, + EquatableArray.Empty, null); + var b = new DiagnosticInfo("SCRIBE002", DiagnosticSeverity.Warning, + EquatableArray.Empty, null); + + a.ShouldNotBe(b); + } +} diff --git a/Scribe.Tests/Cache/EquatableArrayTests.cs b/Scribe.Tests/Cache/EquatableArrayTests.cs new file mode 100644 index 0000000..3ebd9f8 --- /dev/null +++ b/Scribe.Tests/Cache/EquatableArrayTests.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Scribe.Cache; + +namespace Scribe.Tests.Cache; + +public class EquatableArrayTests +{ + // ─────────────────────────────────────────────────────────────── + // default / empty normalisation + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Default_IsEmpty() + { + var @default = default(EquatableArray); + @default.IsEmpty.ShouldBeTrue(); + @default.Count.ShouldBe(0); + } + + [Fact] + public void Default_Equals_Empty() + { + var @default = default(EquatableArray); + @default.ShouldBe(EquatableArray.Empty); + @default.GetHashCode().ShouldBe(EquatableArray.Empty.GetHashCode()); + } + + [Fact] + public void Empty_FromImmutableArray_Equals_Default() + { + var wrapped = new EquatableArray(ImmutableArray.Empty); + wrapped.ShouldBe(default(EquatableArray)); + } + + [Fact] + public void Default_AsSpan_DoesNotThrow() + { + var @default = default(EquatableArray); + var span = @default.AsSpan(); + span.Length.ShouldBe(0); + } + + // ─────────────────────────────────────────────────────────────── + // value equality + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Equal_Contents_Are_Equal() + { + var a = EquatableArray.Create(1, 2, 3); + var b = EquatableArray.Create(1, 2, 3); + a.Equals(b).ShouldBeTrue(); + (a == b).ShouldBeTrue(); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact] + public void Different_Contents_Are_Not_Equal() + { + var a = EquatableArray.Create(1, 2, 3); + var b = EquatableArray.Create(1, 2, 4); + a.Equals(b).ShouldBeFalse(); + (a != b).ShouldBeTrue(); + } + + [Fact] + public void Different_Length_Not_Equal() + { + var a = EquatableArray.Create(1, 2, 3); + var b = EquatableArray.Create(1, 2); + a.Equals(b).ShouldBeFalse(); + } + + [Fact] + public void String_Contents_Equality_Works() + { + var a = EquatableArray.Create("foo", "bar"); + var b = EquatableArray.Create("foo", "bar"); + a.Equals(b).ShouldBeTrue(); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact] + public void ImmutableArray_Built_Different_Ways_Are_Equal() + { + // Different underlying T[] references, same contents — must be equal. + int[] source = [10, 20, 30]; + var a = new EquatableArray(ImmutableArray.Create(source)); + var b = new EquatableArray(ImmutableArray.Create(10, 20, 30)); + a.Equals(b).ShouldBeTrue(); + } + + // ─────────────────────────────────────────────────────────────── + // indexer, span, enumeration + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Indexer_ReturnsElement() + { + var a = EquatableArray.Create(10, 20, 30); + a[0].ShouldBe(10); + a[1].ShouldBe(20); + a[2].ShouldBe(30); + } + + [Fact] + public void Count_ReturnsLength() + { + EquatableArray.Create(1, 2, 3, 4, 5).Count.ShouldBe(5); + } + + [Fact] + public void AsSpan_ReturnsElements() + { + var a = EquatableArray.Create(10, 20, 30); + var span = a.AsSpan(); + span.Length.ShouldBe(3); + span[0].ShouldBe(10); + span[2].ShouldBe(30); + } + + [Fact] + public void Enumeration_YieldsAllElements() + { + var a = EquatableArray.Create(1, 2, 3); + var collected = new List(); + foreach (var item in a) + { + collected.Add(item); + } + + collected.Count.ShouldBe(3); + collected[0].ShouldBe(1); + collected[1].ShouldBe(2); + collected[2].ShouldBe(3); + } + + [Fact] + public void Enumeration_OverDefault_YieldsNothing() + { + var a = default(EquatableArray); + var collected = new List(); + foreach (var item in a) + { + collected.Add(item); + } + + collected.Count.ShouldBe(0); + } + + // ─────────────────────────────────────────────────────────────── + // factory methods + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Create_FromParams_Works() + { + var a = EquatableArray.Create(1, 2, 3); + a.Count.ShouldBe(3); + } + + [Fact] + public void Create_FromEmptyParams_ReturnsEmpty() + { + var a = EquatableArray.Create(); + a.IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void From_Enumerable_Works() + { + var source = new[] { 1, 2, 3 }; + var a = EquatableArray.From(source); + a.Count.ShouldBe(3); + a[1].ShouldBe(2); + } + + [Fact] + public void From_NullEnumerable_ReturnsEmpty() + { + var a = EquatableArray.From(null!); + a.IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void ImplicitConversion_FromImmutableArray() + { + EquatableArray a = ImmutableArray.Create(1, 2, 3); + a.Count.ShouldBe(3); + } + + // ─────────────────────────────────────────────────────────────── + // nested equatable array (composition) + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void Nested_EquatableArray_Equals_By_Value() + { + var inner1 = EquatableArray.Create(1, 2); + var inner2 = EquatableArray.Create(3, 4); + var outerA = EquatableArray.Create(inner1, inner2); + + var inner1Copy = EquatableArray.Create(1, 2); + var inner2Copy = EquatableArray.Create(3, 4); + var outerB = EquatableArray.Create(inner1Copy, inner2Copy); + + outerA.Equals(outerB).ShouldBeTrue(); + outerA.GetHashCode().ShouldBe(outerB.GetHashCode()); + } +} diff --git a/Scribe.Tests/Cache/InternPoolTests.cs b/Scribe.Tests/Cache/InternPoolTests.cs new file mode 100644 index 0000000..7ac9abb --- /dev/null +++ b/Scribe.Tests/Cache/InternPoolTests.cs @@ -0,0 +1,42 @@ +using Scribe.Cache; + +namespace Scribe.Tests.Cache; + +public class InternPoolTests +{ + [Fact] + public void SameValue_ReturnsSameReference() + { + // Build the strings from pieces to defeat the compiler's string literal pooling, + // then confirm the InternPool gives us a single canonical reference. + var a = "scribe" + "-test-1"; + var b = "scribe" + "-test-1"; + + // Guard: without interning these should be distinct references most of the time. + // (Not strictly guaranteed by the runtime, but for concatenated strings it holds.) + var interned1 = InternPool.Intern(a); + var interned2 = InternPool.Intern(b); + + ReferenceEquals(interned1, interned2).ShouldBeTrue(); + } + + [Fact] + public void Null_ReturnsNull() + { + InternPool.Intern(null!).ShouldBeNull(); + } + + [Fact] + public void Empty_ReturnsEmpty() + { + InternPool.Intern("").ShouldBe(""); + } + + [Fact] + public void DifferentValues_StayDistinct() + { + var a = InternPool.Intern("scribe-test-distinct-a"); + var b = InternPool.Intern("scribe-test-distinct-b"); + ReferenceEquals(a, b).ShouldBeFalse(); + } +} diff --git a/Scribe.Tests/Cache/LocationInfoTests.cs b/Scribe.Tests/Cache/LocationInfoTests.cs new file mode 100644 index 0000000..cbdea66 --- /dev/null +++ b/Scribe.Tests/Cache/LocationInfoTests.cs @@ -0,0 +1,86 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Scribe.Cache; + +namespace Scribe.Tests.Cache; + +public class LocationInfoTests +{ + private static SyntaxTree Parse(string source, string path) => + CSharpSyntaxTree.ParseText( + source, + path: path, + cancellationToken: TestContext.Current.CancellationToken); + + [Fact] + public void From_Null_ReturnsNull() + { + LocationInfo.From(null).ShouldBeNull(); + } + + [Fact] + public void From_LocationNone_ReturnsNull() + { + LocationInfo.From(Location.None).ShouldBeNull(); + } + + [Fact] + public void From_SourceLocation_CapturesFilePath() + { + var tree = Parse("class Foo {}", "test.cs"); + var node = tree.GetRoot(TestContext.Current.CancellationToken) + .DescendantNodes().First(); + var loc = node.GetLocation(); + + var info = LocationInfo.From(loc); + + info.ShouldNotBeNull(); + info!.Value.FilePath.ShouldBe("test.cs"); + } + + [Fact] + public void From_SourceLocation_CapturesTextSpan() + { + var tree = Parse("class Foo {}", "test.cs"); + var node = tree.GetRoot(TestContext.Current.CancellationToken) + .DescendantNodes().First(); + var loc = node.GetLocation(); + + var info = LocationInfo.From(loc); + + info!.Value.TextSpan.ShouldBe(loc.SourceSpan); + } + + [Fact] + public void ToLocation_RoundTrips_FilePath() + { + var tree = Parse("class Foo {}", "/path/to/test.cs"); + var node = tree.GetRoot(TestContext.Current.CancellationToken) + .DescendantNodes().First(); + var original = node.GetLocation(); + + var info = LocationInfo.From(original)!.Value; + var materialised = info.ToLocation(); + + materialised.GetLineSpan().Path.ShouldBe(original.GetLineSpan().Path); + materialised.SourceSpan.ShouldBe(original.SourceSpan); + } + + [Fact] + public void Equality_By_Value() + { + var a = new LocationInfo("a.cs", new TextSpan(0, 10), new LinePositionSpan(default, default)); + var b = new LocationInfo("a.cs", new TextSpan(0, 10), new LinePositionSpan(default, default)); + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + } + + [Fact] + public void Different_Path_Not_Equal() + { + var a = new LocationInfo("a.cs", new TextSpan(0, 10), default); + var b = new LocationInfo("b.cs", new TextSpan(0, 10), default); + a.ShouldNotBe(b); + } +} diff --git a/Scribe.Tests/Cache/WellKnownTypesTests.cs b/Scribe.Tests/Cache/WellKnownTypesTests.cs new file mode 100644 index 0000000..42ad4be --- /dev/null +++ b/Scribe.Tests/Cache/WellKnownTypesTests.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Cache; + +namespace Scribe.Tests.Cache; + +public class WellKnownTypesTests +{ + private static CSharpCompilation MakeCompilation(string source = "class Foo {}") => + CSharpCompilation.Create( + "TestAssembly", + syntaxTrees: + [ + CSharpSyntaxTree.ParseText( + source, + cancellationToken: TestContext.Current.CancellationToken), + ], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + ]); + + [Fact] + public void From_ResolvesKnownBclType() + { + var compilation = MakeCompilation(); + var known = WellKnownTypes.From(compilation, "System.Object"); + + known.Get("System.Object").ShouldNotBeNull(); + } + + [Fact] + public void From_ReturnsNullForMissingType() + { + var compilation = MakeCompilation(); + var known = WellKnownTypes.From(compilation, "DoesNotExist.Whatsoever"); + + known.Get("DoesNotExist.Whatsoever").ShouldBeNull(); + } + + [Fact] + public void Get_ReturnsNullForUnregisteredName() + { + var compilation = MakeCompilation(); + var known = WellKnownTypes.From(compilation, "System.Object"); + + known.Get("System.String").ShouldBeNull(); + } + + [Fact] + public void TryGet_ReturnsTrueForKnown_FalseForUnknown() + { + var compilation = MakeCompilation(); + var known = WellKnownTypes.From(compilation, "System.Object"); + + known.TryGet("System.Object", out var obj).ShouldBeTrue(); + obj.ShouldNotBeNull(); + + known.TryGet("System.String", out _).ShouldBeFalse(); + } + + [Fact] + public void Resolve_LazilyCachesNewNames() + { + var compilation = MakeCompilation(); + var known = WellKnownTypes.From(compilation); + + known.TryGet("System.String", out _).ShouldBeFalse(); + + var first = known.Resolve(compilation, "System.String"); + first.ShouldNotBeNull(); + + known.TryGet("System.String", out var cached).ShouldBeTrue(); + cached!.ShouldBe(first!, SymbolEqualityComparer.Default); + } +} diff --git a/Scribe.Tests/Scribe.Tests.csproj b/Scribe.Tests/Scribe.Tests.csproj index d99fb4b..b138b25 100644 --- a/Scribe.Tests/Scribe.Tests.csproj +++ b/Scribe.Tests/Scribe.Tests.csproj @@ -1,9 +1,12 @@ + + + diff --git a/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs new file mode 100644 index 0000000..2426824 --- /dev/null +++ b/Scribe.Tests/Shapes/CacheCorrectnessAnalyzerTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Scribe.Ink.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Validates : every +/// ShapeBuilder.Project<TModel> invocation with a TModel +/// that stores a Roslyn reference type (ISymbol, SyntaxNode, +/// Compilation, SemanticModel, SyntaxTree, Location, +/// AttributeData) should emit SCRIBE101 pointing at the offending member. +/// +public class CacheCorrectnessAnalyzerTests +{ + [Fact] + public void Flags_ISymbol_field_on_projection_model() + { + const string source = @" +using Scribe.Shapes; +using Microsoft.CodeAnalysis; + +public record struct Bad(INamedTypeSymbol Symbol); + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Bad(null!)); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE101"); + diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .ShouldContain("Symbol"); + diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .ShouldContain("ISymbol"); + } + + [Fact] + public void Flags_SyntaxNode_property_on_projection_model() + { + const string source = @" +using Scribe.Shapes; +using Microsoft.CodeAnalysis; + +public sealed record Bad +{ + public SyntaxNode? Node { get; init; } +} + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Bad()); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE101"); + diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .ShouldContain("Node"); + diagnostics[0].GetMessage(System.Globalization.CultureInfo.InvariantCulture) + .ShouldContain("SyntaxNode"); + } + + [Fact] + public void Flags_Compilation_and_Location_together() + { + const string source = @" +using Scribe.Shapes; +using Microsoft.CodeAnalysis; + +public record struct Bad(Compilation Comp, Location Loc); + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Bad(null!, null!)); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.Length.ShouldBe(2); + diagnostics.Select(d => d.Id).ShouldAllBe(id => id == "SCRIBE101"); + var messages = string.Join("|", diagnostics.Select(d => d.GetMessage(System.Globalization.CultureInfo.InvariantCulture))); + messages.ShouldContain("Compilation"); + messages.ShouldContain("Location"); + } + + [Fact] + public void Does_not_flag_cache_safe_model() + { + const string source = @" +using Scribe.Shapes; +using Scribe.Cache; + +public record struct Good(string Fqn, string Name, LocationInfo? Where); + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Good(ctx.Fqn, ""x"", null)); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void Ignores_static_members_and_constants() + { + const string source = @" +using Scribe.Shapes; +using Microsoft.CodeAnalysis; + +public record struct Model(string Fqn) +{ + public static ISymbol? Shared = null; // static — not cached per-item + public const string Tag = ""x""; +} + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Model(ctx.Fqn)); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void Flags_ITypeSymbol_subinterface_via_ISymbol_base() + { + // ITypeSymbol : INamespaceOrTypeSymbol : ISymbol — inheritance must be walked. + const string source = @" +using Scribe.Shapes; +using Microsoft.CodeAnalysis; + +public record struct Bad(ITypeSymbol T); + +public class Use +{ + public void M() + { + var _ = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Bad(null!)); + } +} +"; + var diagnostics = RunAnalyzer(source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE101"); + } + + private static ImmutableArray RunAnalyzer(string source) + { + var parseOptions = new Microsoft.CodeAnalysis.CSharp.CSharpParseOptions( + Microsoft.CodeAnalysis.CSharp.LanguageVersion.Latest); + var tree = CSharpSyntaxTree.ParseText(source, parseOptions); + var refs = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)) + .ToList(); + // Ensure Scribe + Microsoft.CodeAnalysis are present even if their .Location + // was empty at AppDomain scan time (bundled / single-file hosting). + void AddIfMissing(System.Reflection.Assembly asm) + { + if (string.IsNullOrEmpty(asm.Location)) return; + if (refs.OfType().Any(r => string.Equals(r.FilePath, asm.Location, System.StringComparison.OrdinalIgnoreCase))) return; + refs.Add(MetadataReference.CreateFromFile(asm.Location)); + } + AddIfMissing(typeof(Scribe.Shapes.Shape).Assembly); + AddIfMissing(typeof(Scribe.Cache.LocationInfo).Assembly); + AddIfMissing(typeof(Microsoft.CodeAnalysis.ISymbol).Assembly); + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [tree], + references: refs, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var compileErrors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); + if (compileErrors.Count > 0) + { + throw new System.InvalidOperationException( + "Test compilation failed: " + + string.Join("; ", compileErrors.Select(e => e.GetMessage(System.Globalization.CultureInfo.InvariantCulture)))); + } + var analyzer = new CacheCorrectnessAnalyzer(); + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult(); + } +} diff --git a/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs b/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs new file mode 100644 index 0000000..6f6faf7 --- /dev/null +++ b/Scribe.Tests/Shapes/RelationPairIncrementalityTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Cache; +using Scribe.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Verifies cache correctness of by +/// running the generator twice with a tracked driver and inspecting +/// s. An unrelated edit must not cause +/// the matched-pair step to re-execute — otherwise the whole point of projecting +/// into cache-safe models is wasted. +/// +public class RelationPairIncrementalityTests +{ + private const string MatchedTrackingName = "scribe-test-matched"; + + private readonly record struct Command(string Fqn, string RaisesEventName); + + private readonly record struct Event(string Fqn, string Name); + + private sealed class PairGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var commandShape = Shape.Class() + .MustHaveAttribute("CommandAttribute") + .Project((in ShapeProjectionContext ctx) => + new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty)); + + var eventShape = Shape.Class() + .MustHaveAttribute("EventAttribute") + .Project((in ShapeProjectionContext ctx) => + new Event(ctx.Fqn, ctx.Fqn)); + + var pair = Relation.Pair( + commandShape.ToProvider(context), + eventShape.ToProvider(context), + c => c.RaisesEventName, + e => e.Name); + + var tracked = pair.Matched.WithTrackingName(MatchedTrackingName); + context.RegisterSourceOutput(tracked, (_, _) => { }); + } + } + + private static CSharpCompilation MakeCompilation(string source) => + CSharpCompilation.Create( + "TestAsm", + syntaxTrees: [CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken)], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location), + ], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + private static CSharpGeneratorDriver MakeDriver() => + CSharpGeneratorDriver.Create( + generators: [new PairGenerator().AsSourceGenerator()], + additionalTexts: default, + parseOptions: null, + optionsProvider: null, + driverOptions: new GeneratorDriverOptions( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true)); + + private const string Prelude = @" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} +"; + + [Fact] + public void Unrelated_edit_does_not_re_materialize_matched_pairs() + { + var before = Prelude + @" +[Command(""FooEvent"")] public class Foo {} +[Event] public class FooEvent {} +"; + + var after = before + "\npublic class Unrelated {} // add an unrelated type"; + + var driver = (CSharpGeneratorDriver)MakeDriver() + .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken); + + driver = (CSharpGeneratorDriver)driver + .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken); + + var reasons = CollectMatchedStepReasons(driver); + + // Every Matched output from the second run must be Cached or Unchanged — + // no re-projection, no re-emission. + reasons.ShouldNotBeEmpty(); + reasons.ShouldAllBe(r => + r == IncrementalStepRunReason.Cached + || r == IncrementalStepRunReason.Unchanged); + } + + [Fact] + public void Adding_a_new_matched_pair_preserves_existing_cached_outputs() + { + var before = Prelude + @" +[Command(""FooEvent"")] public class Foo {} +[Event] public class FooEvent {} +"; + + var after = Prelude + @" +[Command(""FooEvent"")] public class Foo {} +[Event] public class FooEvent {} +[Command(""BarEvent"")] public class Bar {} +[Event] public class BarEvent {} +"; + + var driver = (CSharpGeneratorDriver)MakeDriver() + .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken); + + driver = (CSharpGeneratorDriver)driver + .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken); + + var reasons = CollectMatchedStepReasons(driver); + + // After adding Bar + BarEvent we expect: + // - Foo/FooEvent output: Cached or Unchanged (preserved) + // - Bar/BarEvent output: New (freshly produced) + // No existing output should be Modified (that would imply a non-equatable model). + reasons.ShouldContain(IncrementalStepRunReason.New); + reasons.ShouldNotContain(IncrementalStepRunReason.Modified); + } + + [Fact] + public void Editing_command_attribute_value_re_materializes_only_its_pair() + { + var before = Prelude + @" +[Command(""FooEvent"")] public class Foo {} +[Event] public class FooEvent {} +[Command(""BarEvent"")] public class Bar {} +[Event] public class BarEvent {} +"; + + // Flip Bar's raised event — Foo/FooEvent pairing is unaffected. + var after = Prelude + @" +[Command(""FooEvent"")] public class Foo {} +[Event] public class FooEvent {} +[Command(""DifferentEvent"")] public class Bar {} +[Event] public class BarEvent {} +[Event] public class DifferentEvent {} +"; + + var driver = (CSharpGeneratorDriver)MakeDriver() + .RunGenerators(MakeCompilation(before), TestContext.Current.CancellationToken); + + driver = (CSharpGeneratorDriver)driver + .RunGenerators(MakeCompilation(after), TestContext.Current.CancellationToken); + + var reasons = CollectMatchedStepReasons(driver); + + // Foo's pair must remain cached; Bar's pair is a new output value. + // At least one Cached/Unchanged output proves we didn't re-run everything. + var preserved = reasons.Count(r => + r == IncrementalStepRunReason.Cached + || r == IncrementalStepRunReason.Unchanged); + preserved.ShouldBeGreaterThan(0); + } + + private static List CollectMatchedStepReasons(CSharpGeneratorDriver driver) + { + var result = driver.GetRunResult(); + var reasons = new List(); + foreach (var generatorResult in result.Results) + { + if (!generatorResult.TrackedSteps.TryGetValue(MatchedTrackingName, out var steps)) + { + continue; + } + + foreach (var step in steps) + { + foreach (var output in step.Outputs) + { + reasons.Add(output.Reason); + } + } + } + + return reasons; + } +} diff --git a/Scribe.Tests/Shapes/RelationPairTests.cs b/Scribe.Tests/Shapes/RelationPairTests.cs new file mode 100644 index 0000000..5eddc8f --- /dev/null +++ b/Scribe.Tests/Shapes/RelationPairTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Cache; +using Scribe.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Drives through +/// and validates three flows: +/// matched pairs stream, orphan-left diagnostic, orphan-right diagnostic. +/// Grounding scenario is the Hermetic-style Command ↔ Event join: each command +/// declares the event metadata name it raises, each event declares its own name; +/// the pair joins on that shared key. +/// +public class RelationPairTests +{ + private readonly record struct Command(string Fqn, string RaisesEventName); + + private readonly record struct Event(string Fqn, string Name); + + private sealed class PairGenerator : IIncrementalGenerator + { + public static readonly List> Pairs = new(); + public static readonly List Diagnostics = new(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var commandShape = Shape.Class() + .MustHaveAttribute("CommandAttribute") + .Project((in ShapeProjectionContext ctx) => + new Command( + Fqn: ctx.Fqn, + RaisesEventName: ctx.Attribute.Ctor(0) ?? string.Empty)); + + var eventShape = Shape.Class() + .MustHaveAttribute("EventAttribute") + .Project((in ShapeProjectionContext ctx) => + new Event(Fqn: ctx.Fqn, Name: ctx.Fqn)); + + var pair = Relation.Pair( + commandShape.ToProvider(context), + eventShape.ToProvider(context), + leftKey: c => c.RaisesEventName, + rightKey: e => e.Name) + .RequireLeftHasRight( + id: "SCRIBE201", + title: "Command raises undeclared event", + messageFormat: "Command '{0}' raises undeclared event '{1}'") + .WarnOnRightUnused( + id: "SCRIBE202", + title: "Event is declared but never raised", + messageFormat: "Event '{0}' is declared but never raised"); + + context.RegisterSourceOutput(pair.Matched, (spc, p) => + { + lock (Pairs) { Pairs.Add(p); } + }); + + pair.RegisterDiagnostics(context); + } + } + + private static CSharpCompilation MakeCompilation(string source) => + CSharpCompilation.Create( + "TestAsm", + syntaxTrees: [CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken)], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location), + ], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + private static (Microsoft.CodeAnalysis.GeneratorDriverRunResult Result, List> Pairs) Run(string source) + { + PairGenerator.Pairs.Clear(); + PairGenerator.Diagnostics.Clear(); + var driver = CSharpGeneratorDriver.Create(new PairGenerator()) + .RunGenerators(MakeCompilation(source), TestContext.Current.CancellationToken); + return (driver.GetRunResult(), [..PairGenerator.Pairs]); + } + + [Fact] + public void Matches_command_to_event_by_shared_name() + { + var (result, pairs) = Run(@" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} + +[Command(""DoThingCompleted"")] public class DoThing {} +[Event] public class DoThingCompleted {} +"); + result.Diagnostics.IsEmpty.ShouldBeTrue(); + pairs.Count.ShouldBe(1); + pairs[0].Left.Fqn.ShouldBe("DoThing"); + pairs[0].Right.Fqn.ShouldBe("DoThingCompleted"); + } + + [Fact] + public void Emits_SCRIBE201_when_command_raises_undeclared_event() + { + var (result, pairs) = Run(@" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} + +[Command(""MissingEvent"")] public class DoThing {} +"); + pairs.ShouldBeEmpty(); + var diag = result.Diagnostics.Single(d => d.Id == "SCRIBE201"); + var msg = diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture); + msg.ShouldContain("DoThing"); + msg.ShouldContain("MissingEvent"); + } + + [Fact] + public void Emits_SCRIBE202_when_event_has_no_raising_command() + { + var (result, _) = Run(@" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} + +[Event] public class OrphanEvent {} +"); + var diag = result.Diagnostics.Single(d => d.Id == "SCRIBE202"); + diag.Severity.ShouldBe(DiagnosticSeverity.Warning); + diag.GetMessage(System.Globalization.CultureInfo.InvariantCulture).ShouldContain("OrphanEvent"); + } + + [Fact] + public void Matched_and_orphans_coexist_in_the_same_compilation() + { + var (result, pairs) = Run(@" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} + +[Command(""Matched"")] public class DoMatched {} +[Command(""NotThere"")] public class DoOrphanCommand {} +[Event] public class Matched {} +[Event] public class OrphanEvent {} +"); + pairs.Count.ShouldBe(1); + pairs[0].Left.Fqn.ShouldBe("DoMatched"); + pairs[0].Right.Fqn.ShouldBe("Matched"); + + var ids = result.Diagnostics.Select(d => d.Id).OrderBy(x => x).ToArray(); + ids.ShouldBe(["SCRIBE201", "SCRIBE202"]); + } + + [Fact] + public void Silent_default_emits_no_diagnostics_without_policy() + { + var silentGen = new InlineGen((context) => + { + var left = Shape.Class() + .MustHaveAttribute("CommandAttribute") + .Project((in ShapeProjectionContext ctx) => + new Command(ctx.Fqn, ctx.Attribute.Ctor(0) ?? string.Empty)); + var right = Shape.Class() + .MustHaveAttribute("EventAttribute") + .Project((in ShapeProjectionContext ctx) => new Event(ctx.Fqn, ctx.Fqn)); + + var pair = Relation.Pair(left.ToProvider(context), right.ToProvider(context), + c => c.RaisesEventName, e => e.Name); + + pair.RegisterDiagnostics(context); + context.RegisterSourceOutput(pair.Matched, (spc, _) => { }); + }); + + var driver = CSharpGeneratorDriver.Create(silentGen).RunGenerators( + MakeCompilation(@" +using System; +public sealed class CommandAttribute : Attribute { public CommandAttribute(string raises) {} } +public sealed class EventAttribute : Attribute {} + +[Command(""Nope"")] public class A {} +[Event] public class B {} +"), + TestContext.Current.CancellationToken); + + driver.GetRunResult().Diagnostics.IsEmpty.ShouldBeTrue(); + } + + private sealed class InlineGen : IIncrementalGenerator + { + private readonly Action _init; + public InlineGen(Action init) => _init = init; + public void Initialize(IncrementalGeneratorInitializationContext context) => _init(context); + } +} diff --git a/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs new file mode 100644 index 0000000..97af4e1 --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeAnalyzerTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Scribe.Cache; +using Scribe.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Validates that projects a shape's +/// checks into a working that emits the +/// expected diagnostics at the configured anchors +/// with the expected fix-dispatch properties. +/// +public class ShapeAnalyzerTests +{ + private static readonly string[] _scribe001 = ["SCRIBE001"]; + + private readonly record struct Collected(string Fqn); + + [Fact] + public void MustBePartial_emits_SCRIBE001_at_type_keyword_on_non_partial_class() + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget { }"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.Length.ShouldBe(1); + var diag = diagnostics[0]; + diag.Id.ShouldBe("SCRIBE001"); + diag.Properties["fixKind"].ShouldBe("AddPartialModifier"); + diag.Properties["squiggleAt"].ShouldBe("TypeKeyword"); + var text = source.Substring(diag.Location.SourceSpan.Start, diag.Location.SourceSpan.Length); + text.ShouldBe("class"); + } + + [Fact] + public void MustBePartial_emits_nothing_when_class_is_partial() + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public partial class Widget { }"); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void MustBeSealed_emits_SCRIBE002_at_type_keyword() + { + var shape = Shape.Class() + .MustBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE002"); + diagnostics[0].Properties["fixKind"].ShouldBe("AddSealedModifier"); + } + + [Fact] + public void Secondary_MustHaveAttribute_emits_SCRIBE004_and_encodes_attribute_name_in_properties() + { + // First MustHaveAttribute is promoted to the selector — it narrows the shape's + // scope to types that carry ThingAttribute. A second MustHaveAttribute runs as + // a regular check on those narrowed types. + var shape = Shape.Class() + .MustHaveAttribute("ThingAttribute") + .MustHaveAttribute("MarkerAttribute") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public sealed class MarkerAttribute : System.Attribute { } +[Thing] public class Widget { } +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.Length.ShouldBe(1); + var diag = diagnostics[0]; + diag.Id.ShouldBe("SCRIBE004"); + diag.Properties["fixKind"].ShouldBe("AddAttribute"); + diag.Properties["attribute"].ShouldBe("MarkerAttribute"); + } + + [Fact] + public void MustImplement_squiggles_at_base_list_when_present_and_encodes_interface() + { + var shape = Shape.Class() + .MustImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; }"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.Length.ShouldBe(1); + var diag = diagnostics[0]; + diag.Id.ShouldBe("SCRIBE003"); + diag.Properties["fixKind"].ShouldBe("AddInterfaceToBaseList"); + diag.Properties["interface"].ShouldBe("System.IDisposable"); + var text = source.Substring(diag.Location.SourceSpan.Start, diag.Location.SourceSpan.Length); + text.ShouldContain("IComparable"); + } + + [Fact] + public void Analyzer_ignores_types_that_do_not_carry_the_primary_attribute() + { + var shape = Shape.Class() + .MustHaveAttribute("ThingAttribute") + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public class Alpha { } // no attribute — ignored by MustBePartial +[Thing] public class Beta { } // has attribute but not partial → SCRIBE001 +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.Select(d => d.Id).ShouldBe(_scribe001); + } + + private static ImmutableArray RunAnalyzer(Shape shape, string source) + where TModel : IEquatable + { + var compilation = Compile(source); + var analyzer = shape.ToAnalyzer(); + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + private static CSharpCompilation Compile(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var refs = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)) + .ToList(); + return CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [tree], + references: refs, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/Scribe.Tests/Shapes/ShapeBuilderTests.cs b/Scribe.Tests/Shapes/ShapeBuilderTests.cs new file mode 100644 index 0000000..566a24c --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeBuilderTests.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Cache; +using Scribe.Shapes; + +namespace Scribe.Tests.Shapes; + +/// +/// Unit-tests the per-primitive predicate behaviour of . +/// Each test parses a compilation, picks out a named type, and invokes the +/// builder's internal check pipeline via +/// round-trips would be over-weight here — we probe the predicates directly +/// through a projected Shape on a synthetic dummy model. +/// +public class ShapeBuilderTests +{ + private readonly record struct TypeNameModel(string Name); + + private static (CSharpCompilation Compilation, INamedTypeSymbol Symbol) Compile( + string source, string metadataName) + { + var tree = CSharpSyntaxTree.ParseText( + source, + cancellationToken: TestContext.Current.CancellationToken); + var compilation = CSharpCompilation.Create( + "TestAsm", + syntaxTrees: [tree], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location), + ]); + + var symbol = compilation.GetTypeByMetadataName(metadataName); + symbol.ShouldNotBeNull($"type '{metadataName}' did not resolve"); + return (compilation, symbol!); + } + + private static INamedTypeSymbol GetType(string source, string metadataName) => + Compile(source, metadataName).Symbol; + + private static EquatableArray RunChecks(ShapeBuilder builder, string source, string metadataName) + { + var (compilation, symbol) = Compile(source, metadataName); + var ct = TestContext.Current.CancellationToken; + var diags = new System.Collections.Generic.List(); + + foreach (var check in builder.Checks) + { + if (!check.Predicate(symbol, compilation, ct)) + { + diags.Add(new DiagnosticInfo( + Id: check.Id, + Severity: check.Severity, + MessageArgs: check.MessageArgs(symbol), + Location: null)); + } + } + + return EquatableArray.From(diags); + } + + // Convenience overload for tests that already hold a compiled symbol. + private static EquatableArray RunChecks(ShapeBuilder builder, INamedTypeSymbol symbol) + { + var ct = TestContext.Current.CancellationToken; + var tree = symbol.DeclaringSyntaxReferences[0].SyntaxTree; + + // Reuse the compilation the tree already belongs to by locating it via the symbol's containing assembly. + // When symbols come from our Compile(...) helper, the compilation is the one we built. + var compilation = CSharpCompilation.Create( + "Probe", + syntaxTrees: [tree], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location), + ]); + var reResolved = compilation.GetTypeByMetadataName(symbol.ToDisplayString())!; + + var diags = new System.Collections.Generic.List(); + foreach (var check in builder.Checks) + { + if (!check.Predicate(reResolved, compilation, ct)) + { + diags.Add(new DiagnosticInfo( + Id: check.Id, + Severity: check.Severity, + MessageArgs: check.MessageArgs(reResolved), + Location: null)); + } + } + + return EquatableArray.From(diags); + } + + // ─────────────────────────────────────────────────────────────── + // MustBePartial + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustBePartial_PassesForPartialClass() + { + var symbol = GetType("public partial class Foo {}", "Foo"); + var builder = Shape.Class().MustBePartial(); + RunChecks(builder, symbol).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void MustBePartial_FailsForNonPartialClass() + { + var symbol = GetType("public class Foo {}", "Foo"); + var builder = Shape.Class().MustBePartial(); + + var diags = RunChecks(builder, symbol); + diags.Count.ShouldBe(1); + diags[0].Id.ShouldBe("SCRIBE001"); + diags[0].Severity.ShouldBe(DiagnosticSeverity.Error); + } + + [Fact] + public void MustBePartial_UsesOverrideId() + { + var symbol = GetType("public class Foo {}", "Foo"); + var builder = Shape.Class().MustBePartial(new DiagnosticSpec(Id: "CUSTOM01")); + + RunChecks(builder, symbol)[0].Id.ShouldBe("CUSTOM01"); + } + + // ─────────────────────────────────────────────────────────────── + // MustBeSealed + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustBeSealed_PassesForSealedClass() + { + var symbol = GetType("public sealed class Foo {}", "Foo"); + RunChecks(Shape.Class().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void MustBeSealed_FailsForOpenClass() + { + var symbol = GetType("public class Foo {}", "Foo"); + var diags = RunChecks(Shape.Class().MustBeSealed(), symbol); + + diags.Count.ShouldBe(1); + diags[0].Id.ShouldBe("SCRIBE002"); + } + + [Fact] + public void MustBeSealed_PassesForValueType() + { + var symbol = GetType("public struct Foo {}", "Foo"); + RunChecks(Shape.Struct().MustBeSealed(), symbol).IsEmpty.ShouldBeTrue(); + } + + // ─────────────────────────────────────────────────────────────── + // MustImplement + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustImplement_PassesWhenInterfaceImplemented() + { + var symbol = GetType( + "using System; public class Foo : IDisposable { public void Dispose() {} }", + "Foo"); + RunChecks(Shape.Class().MustImplement(), symbol).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void MustImplement_FailsWhenInterfaceMissing() + { + var symbol = GetType("public class Foo {}", "Foo"); + var diags = RunChecks(Shape.Class().MustImplement(), symbol); + + diags.Count.ShouldBe(1); + diags[0].Id.ShouldBe("SCRIBE003"); + } + + [Fact] + public void MustImplement_ByMetadataName_Works() + { + var symbol = GetType( + "using System; public class Foo : IDisposable { public void Dispose() {} }", + "Foo"); + RunChecks(Shape.Class().MustImplement("System.IDisposable"), symbol).IsEmpty.ShouldBeTrue(); + } + + // ─────────────────────────────────────────────────────────────── + // MustHaveAttribute + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustHaveAttribute_PassesWhenAttributePresent() + { + var symbol = GetType(""" + using System; + public sealed class FooAttribute : Attribute {} + [Foo] public class Target {} + """, "Target"); + + RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void MustHaveAttribute_FailsWhenAttributeMissing() + { + var symbol = GetType("public class Target {}", "Target"); + var diags = RunChecks(Shape.Class().MustHaveAttribute("FooAttribute"), symbol); + + diags.Count.ShouldBe(1); + diags[0].Id.ShouldBe("SCRIBE004"); + } + + [Fact] + public void MustHaveAttribute_SetsPrimaryAttributeMetadataName() + { + var builder = Shape.Class().MustHaveAttribute("FooAttribute"); + builder.PrimaryAttributeMetadataName.ShouldBe("FooAttribute"); + } + + [Fact] + public void MustHaveAttribute_SecondCall_DoesNotOverridePrimary() + { + var builder = Shape.Class() + .MustHaveAttribute("FirstAttribute") + .MustHaveAttribute("SecondAttribute"); + builder.PrimaryAttributeMetadataName.ShouldBe("FirstAttribute"); + builder.Checks.Count.ShouldBe(2); + } + + // ─────────────────────────────────────────────────────────────── + // MustBeNamed + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustBeNamed_PassesWhenMatches() + { + var symbol = GetType("public class FooHandler {}", "FooHandler"); + RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void MustBeNamed_FailsWhenDoesNotMatch() + { + var symbol = GetType("public class FooManager {}", "FooManager"); + var diags = RunChecks(Shape.Class().MustBeNamed(".*Handler$"), symbol); + + diags.Count.ShouldBe(1); + diags[0].Id.ShouldBe("SCRIBE005"); + } + + // ─────────────────────────────────────────────────────────────── + // Fluent chaining + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MultipleChecks_AllFail_YieldsAllDiagnostics() + { + var symbol = GetType("public class Foo {}", "Foo"); + var builder = Shape.Class() + .MustBePartial() + .MustBeSealed() + .MustBeNamed(".*Handler$"); + + var diags = RunChecks(builder, symbol); + diags.Count.ShouldBe(3); + var ids = diags.AsSpan().ToArray().Select(d => d.Id).ToHashSet(); + ids.ShouldBe(new System.Collections.Generic.HashSet { "SCRIBE001", "SCRIBE002", "SCRIBE005" }); + } + + [Fact] + public void ProjectSealsBuilder_IntoTypedShape() + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new TypeNameModel(ctx.Symbol.Name)); + + shape.ShouldNotBeNull(); + } +} diff --git a/Scribe.Tests/Shapes/ShapeFixProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs new file mode 100644 index 0000000..a675ac0 --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeFixProviderTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Scribe.Ink.Shapes; +using Scribe.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Validates that dispatches +/// each to the correct code action and produces the +/// expected source transformation. +/// +public class ShapeFixProviderTests +{ + private static readonly string[] _allThreeIds = ["SCRIBE001", "SCRIBE002", "SCRIBE003"]; + + private readonly record struct Collected(string Fqn); + + [Fact] + public async Task AddPartialModifier_adds_partial_keyword() + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public class Widget { }"); + result.ShouldContain("partial class Widget"); + } + + [Fact] + public async Task AddSealedModifier_adds_sealed_keyword_before_partial() + { + var shape = Shape.Class() + .MustBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public partial class Widget { }"); + result.ShouldContain("sealed partial class Widget"); + } + + [Fact] + public async Task AddInterfaceToBaseList_appends_interface_when_base_list_absent() + { + var shape = Shape.Class() + .MustImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public sealed class Widget { public void Dispose() { } }"); + result.ShouldContain(": System.IDisposable"); + } + + [Fact] + public async Task AddInterfaceToBaseList_appends_to_existing_base_list() + { + var shape = Shape.Class() + .MustImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget : System.IComparable { public int CompareTo(object o) => 0; public void Dispose() { } }"; + var result = await ApplyFirstFix(shape, source); + result.ShouldContain("IComparable"); + result.ShouldContain("IDisposable"); + } + + [Fact] + public async Task AddAttribute_prepends_attribute_without_Attribute_suffix() + { + // Primary selector is ThingAttribute; Marker is the secondary must-have that fires. + var shape = Shape.Class() + .MustHaveAttribute("ThingAttribute") + .MustHaveAttribute("MarkerAttribute") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class ThingAttribute : System.Attribute { } +public sealed class MarkerAttribute : System.Attribute { } +[Thing] public class Widget { } +"; + var result = await ApplyFirstFix(shape, source); + result.ShouldContain("[Marker]"); + result.ShouldContain("class Widget"); + } + + [Fact] + public async Task ToFixProvider_reports_all_check_ids_as_fixable() + { + var shape = Shape.Class() + .MustBePartial() + .MustBeSealed() + .MustImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var provider = shape.ToFixProvider(); + provider.FixableDiagnosticIds.ShouldBe(_allThreeIds); + } + + private static async Task ApplyFirstFix(Shape shape, string source) + where TModel : IEquatable + { + using var workspace = new AdhocWorkspace(); + var project = workspace.AddProject("P", LanguageNames.CSharp) + .WithMetadataReferences(AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))); + var document = project.AddDocument("T.cs", SourceText.From(source)); + project = document.Project; + + var compilation = await project.GetCompilationAsync().ConfigureAwait(false) + ?? throw new InvalidOperationException("Compilation unavailable."); + var analyzer = shape.ToAnalyzer(); + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None) + .ConfigureAwait(false); + + var first = diagnostics.OrderBy(d => d.Location.SourceSpan.Start).First(); + + var fixProvider = shape.ToFixProvider(); + var actions = new List(); + var context = new CodeFixContext( + document, + first, + (action, _) => actions.Add(action), + CancellationToken.None); + await fixProvider.RegisterCodeFixesAsync(context).ConfigureAwait(false); + + actions.Count.ShouldBeGreaterThan(0); + + var operations = await actions[0].GetOperationsAsync(CancellationToken.None).ConfigureAwait(false); + var applied = operations.OfType().First(); + var newDoc = applied.ChangedSolution.GetDocument(document.Id)!; + var text = await newDoc.GetTextAsync().ConfigureAwait(false); + return text.ToString(); + } +} diff --git a/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs new file mode 100644 index 0000000..18abae6 --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeSeverityVariantsTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Scribe.Shapes; +using Shouldly; +using Xunit; + +namespace Scribe.Tests.Shapes; + +/// +/// Validates the Scriptorium-generated severity variants: +/// Should* demotes the default to +/// , Could* demotes to +/// . All three variants share the same +/// diagnostic id as the Must* primitive, so #pragma warning disable +/// silences them uniformly. A caller-supplied severity in the spec wins. +/// +public class ShapeSeverityVariantsTests +{ + private readonly record struct Collected(string Fqn); + + [Fact] + public void ShouldBePartial_reports_SCRIBE001_at_Warning_severity() + { + var shape = Shape.Class() + .ShouldBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE001"); + diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning); + } + + [Fact] + public void CouldBePartial_reports_SCRIBE001_at_Info_severity() + { + var shape = Shape.Class() + .CouldBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE001"); + diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Info); + } + + [Fact] + public void MustBePartial_ShouldBePartial_and_CouldBePartial_all_share_SCRIBE001() + { + var must = Shape.Class().MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + var should = Shape.Class().ShouldBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + var could = Shape.Class().CouldBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + const string source = "public class Widget { }"; + + RunAnalyzer(must, source).Single().Id.ShouldBe("SCRIBE001"); + RunAnalyzer(should, source).Single().Id.ShouldBe("SCRIBE001"); + RunAnalyzer(could, source).Single().Id.ShouldBe("SCRIBE001"); + } + + [Fact] + public void Pragma_warning_disable_SCRIBE001_silences_the_ShouldBe_variant() + { + var shape = Shape.Class() + .ShouldBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + const string source = @" +#pragma warning disable SCRIBE001 +public class Widget { } +#pragma warning restore SCRIBE001 +"; + var diagnostics = RunAnalyzer(shape, source); + + diagnostics.ShouldBeEmpty(); + } + + [Fact] + public void Caller_supplied_severity_wins_over_variant_default() + { + // ShouldBePartial defaults to Warning, but the caller pins Hidden. + var shape = Shape.Class() + .ShouldBePartial(new DiagnosticSpec(Severity: DiagnosticSeverity.Hidden)) + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Hidden); + } + + [Fact] + public void ShouldImplement_generic_variant_flows_through_type_parameter_constraint() + { + // Exercises the Scriptorium-generated generic overload with its + // `where T : class` constraint, plus severity demotion. + var shape = Shape.Class() + .ShouldImplement() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE003"); + diagnostics[0].Severity.ShouldBe(DiagnosticSeverity.Warning); + diagnostics[0].Properties["interface"].ShouldBe("System.IDisposable"); + } + + private static ImmutableArray RunAnalyzer(Shape shape, string source) + where TModel : IEquatable + { + var compilation = Compile(source); + var analyzer = shape.ToAnalyzer(); + var withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + private static CSharpCompilation Compile(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var refs = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)) + .ToList(); + return CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [tree], + references: refs, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/Scribe.Tests/Shapes/ShapeToProviderTests.cs b/Scribe.Tests/Shapes/ShapeToProviderTests.cs new file mode 100644 index 0000000..6c66992 --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeToProviderTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Scribe.Cache; +using Scribe.Shapes; + +namespace Scribe.Tests.Shapes; + +/// +/// Drives an incremental generator built on +/// through and asserts the projected +/// s surface via a captured output. +/// +public class ShapeToProviderTests +{ + private readonly record struct CollectedModel(string Fqn, int ViolationCount); + + private sealed class CapturingGenerator : IIncrementalGenerator + { + public static readonly List> Captured = new(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var shape = Shape.Class() + .MustHaveAttribute("ThingAttribute") + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new CollectedModel( + Fqn: ctx.Fqn, + ViolationCount: 0)); + + var provider = shape.ToProvider(context); + + context.RegisterSourceOutput(provider, (spc, value) => + { + lock (Captured) + { + Captured.Add(value); + } + + spc.AddSource( + $"{value.Model.Fqn.Replace('.', '_').Replace('<', '_').Replace('>', '_')}.g.cs", + $"// collected: {value.Fqn}, violations: {value.Violations.Count}"); + }); + } + } + + private sealed class NoAttributeGenerator : IIncrementalGenerator + { + public static readonly List> Captured = new(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new CollectedModel( + Fqn: ctx.Fqn, + ViolationCount: 0)); + + context.RegisterSourceOutput(shape.ToProvider(context), (spc, value) => + { + lock (Captured) + { + Captured.Add(value); + } + }); + } + } + + private static CSharpCompilation MakeCompilation(string source) + { + return CSharpCompilation.Create( + "TestAsm", + syntaxTrees: + [ + CSharpSyntaxTree.ParseText( + source, + cancellationToken: TestContext.Current.CancellationToken), + ], + references: + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Attribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.IDisposable).Assembly.Location), + ], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + [Fact] + public void ToProvider_WithAttribute_CollectsMatchingTypes_AndFlagsViolations() + { + CapturingGenerator.Captured.Clear(); + + var compilation = MakeCompilation(""" + using System; + public sealed class ThingAttribute : Attribute {} + + [Thing] public partial class Alpha {} + [Thing] public class Beta {} // not partial -> violation + public partial class Gamma {} // not annotated -> ignored + """); + + var driver = CSharpGeneratorDriver.Create(new CapturingGenerator()); + driver = (CSharpGeneratorDriver)driver.RunGenerators( + compilation, TestContext.Current.CancellationToken); + + var result = driver.GetRunResult(); + result.Diagnostics.IsEmpty.ShouldBeTrue(); + + CapturingGenerator.Captured.Count.ShouldBe(2); + var alpha = CapturingGenerator.Captured.Single(m => m.Fqn == "Alpha"); + alpha.Violations.IsEmpty.ShouldBeTrue(); + + var beta = CapturingGenerator.Captured.Single(m => m.Fqn == "Beta"); + beta.Violations.Count.ShouldBe(1); + beta.Violations[0].Id.ShouldBe("SCRIBE001"); + } + + [Fact] + public void ToProvider_WithoutAttribute_CollectsAllClassKinds() + { + NoAttributeGenerator.Captured.Clear(); + + var compilation = MakeCompilation(""" + public partial class Alpha {} + public class Beta {} // flagged + public struct Gamma {} // wrong kind, filtered out + public interface IDelta {} // wrong kind, filtered out + """); + + var driver = CSharpGeneratorDriver.Create(new NoAttributeGenerator()); + driver = (CSharpGeneratorDriver)driver.RunGenerators( + compilation, TestContext.Current.CancellationToken); + + NoAttributeGenerator.Captured.Count.ShouldBe(2); + NoAttributeGenerator.Captured.Select(c => c.Fqn).OrderBy(x => x) + .ShouldBe(["Alpha", "Beta"]); + } + + [Fact] + public void ToProvider_ProjectionReceivesAttributeReader() + { + var captured = new List(); + + var generator = new InlineGenerator(ctx => + { + var shape = Shape.Class() + .MustHaveAttribute("FooAttribute") + .Project((in ShapeProjectionContext pc) => new ValueModel( + pc.Fqn, + pc.Attribute.Ctor(0) ?? "")); + + ctx.RegisterSourceOutput(shape.ToProvider(ctx), (spc, v) => + { + captured.Add($"{v.Model.Fqn}:{v.Model.Value}"); + }); + }); + + var compilation = MakeCompilation(""" + using System; + public sealed class FooAttribute : Attribute { public FooAttribute(string v) {} } + [Foo("hello")] public class Target {} + """); + + CSharpGeneratorDriver.Create(generator).RunGenerators( + compilation, TestContext.Current.CancellationToken); + + captured.ShouldContain("Target:hello"); + } + + private readonly record struct ValueModel(string Fqn, string Value); + + private sealed class InlineGenerator : IIncrementalGenerator + { + private readonly Action _init; + public InlineGenerator(Action init) => _init = init; + public void Initialize(IncrementalGeneratorInitializationContext context) => _init(context); + } +} diff --git a/Scribe.slnx b/Scribe.slnx index ce85d1b..3ba87c9 100644 --- a/Scribe.slnx +++ b/Scribe.slnx @@ -1,5 +1,7 @@ + + diff --git a/Scribe/Attributes/AttributeReader.cs b/Scribe/Attributes/AttributeReader.cs new file mode 100644 index 0000000..05e5fa0 --- /dev/null +++ b/Scribe/Attributes/AttributeReader.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Attributes; + +/// +/// Stack-only, typed reader over a single . Obtain via +/// or the +/// callback supplied to . +/// +/// +/// +/// This is a ref struct: it pins the underlying +/// (which holds symbol references) and must not escape the current stack frame. +/// Read what you need, project into a cache-safe model, let the reader go out of scope. +/// +/// +/// All typed-read methods silently return default when the requested slot is +/// missing, out of range, or the value is not convertible to the requested type — +/// and push a into . +/// +/// +public ref struct AttributeReader +{ + private readonly AttributeData? _attribute; + private List? _errors; + + internal AttributeReader(AttributeData? attribute) + { + _attribute = attribute; + _errors = null; + } + + /// Whether a matching attribute was found on the inspected symbol. + public readonly bool Exists => _attribute is not null; + + /// Location of the attribute application syntax, if available. + public readonly LocationInfo? Location => + _attribute is null ? null : LocationInfo.From(GetAttributeLocation(_attribute)); + + /// + /// Number of constructor arguments supplied at the call site. Zero if the attribute + /// is missing. + /// + public readonly int CtorArgCount => + _attribute?.ConstructorArguments.Length ?? 0; + + /// + /// Read constructor argument as . + /// Emits a into and returns + /// default if the index is out of range or the value is not a + /// . + /// + public T? Ctor(int index) + { + if (_attribute is null) + { + return default; + } + + var args = _attribute.ConstructorArguments; + if ((uint)index >= (uint)args.Length) + { + PushError( + DiagnosticIds.AttributeCtorMissing, + DiagnosticSeverity.Warning, + EquatableArray.Create(index.ToString(), _attribute.AttributeClass?.Name ?? "?")); + return default; + } + + return Coerce(args[index], $"ctor[{index}]"); + } + + /// + /// Read constructor argument as ; + /// returns default silently (no diagnostic) if the index is out of range. + /// A type mismatch still emits a diagnostic. + /// + public T? CtorOpt(int index) + { + if (_attribute is null) + { + return default; + } + + var args = _attribute.ConstructorArguments; + if ((uint)index >= (uint)args.Length) + { + return default; + } + + return Coerce(args[index], $"ctor[{index}]"); + } + + /// + /// Read the named argument (property or field) as + /// , returning if the argument + /// is absent. A type mismatch emits a diagnostic and returns . + /// + public T Named(string name, T fallback) + { + if (_attribute is null || string.IsNullOrEmpty(name)) + { + return fallback; + } + + foreach (var pair in _attribute.NamedArguments) + { + if (!string.Equals(pair.Key, name, StringComparison.Ordinal)) + { + continue; + } + + var coerced = Coerce(pair.Value, name); + return coerced is null ? fallback : coerced; + } + + return fallback; + } + + /// + /// Read the -th generic type argument of the attribute + /// (e.g., [Foo<Bar>]TypeArg(0) == Bar). Returns + /// and emits a diagnostic if the index is out of range. + /// + public ITypeSymbol? TypeArg(int index) + { + if (_attribute is null) + { + return null; + } + + var typeArgs = _attribute.AttributeClass?.TypeArguments ?? ImmutableArray.Empty; + if ((uint)index >= (uint)typeArgs.Length) + { + PushError( + DiagnosticIds.AttributeTypeArgMissing, + DiagnosticSeverity.Warning, + EquatableArray.Create(index.ToString(), _attribute.AttributeClass?.Name ?? "?")); + return null; + } + + return typeArgs[index]; + } + + /// + /// Return the accumulated diagnostics and reset the internal buffer. Intended to be + /// called once at the end of a projection. + /// + public EquatableArray DrainErrors() + { + if (_errors is null || _errors.Count == 0) + { + return EquatableArray.Empty; + } + + var array = EquatableArray.From(_errors); + _errors = null; + return array; + } + + private T? Coerce(TypedConstant value, string slotDescription) + { + if (value.IsNull) + { + return default; + } + + // Array values: project each element. + if (value.Kind == TypedConstantKind.Array) + { + if (typeof(T).IsArray) + { + var elementType = typeof(T).GetElementType()!; + var source = value.Values; + var array = Array.CreateInstance(elementType, source.Length); + for (var i = 0; i < source.Length; i++) + { + var element = source[i].Value; + if (element is not null && elementType.IsAssignableFrom(element.GetType())) + { + array.SetValue(element, i); + } + } + + return (T)(object)array; + } + } + + // Type arguments: TypedConstant of kind Type carries an ITypeSymbol in .Value. + if (value.Kind == TypedConstantKind.Type) + { + if (value.Value is T typed) + { + return typed; + } + } + + if (value.Value is T direct) + { + return direct; + } + + // Enums are boxed as their underlying integral value. + if (value.Kind == TypedConstantKind.Enum && value.Value is not null) + { + try + { + return (T)value.Value; + } + catch (InvalidCastException) + { + // fall through to mismatch path + } + } + + PushError( + DiagnosticIds.AttributeValueMismatch, + DiagnosticSeverity.Warning, + EquatableArray.Create(slotDescription, typeof(T).Name)); + return default; + } + + private void PushError(string id, DiagnosticSeverity severity, EquatableArray args) + { + _errors ??= new List(); + _errors.Add(new DiagnosticInfo( + Id: id, + Severity: severity, + MessageArgs: args, + Location: Location)); + } + + private static Location? GetAttributeLocation(AttributeData attribute) + { + var reference = attribute.ApplicationSyntaxReference; + if (reference is null) + { + return null; + } + + return Microsoft.CodeAnalysis.Location.Create(reference.SyntaxTree, reference.Span); + } +} + +/// +/// Well-known diagnostic IDs emitted by when typed reads +/// fail. Consumers can register matching s if they +/// wish to surface these to the user; otherwise the diagnostics are informational only. +/// +public static class DiagnosticIds +{ + /// Constructor argument index out of range. + public const string AttributeCtorMissing = "SCRIBE100"; + + /// Constructor / named argument value did not match the requested type. + public const string AttributeValueMismatch = "SCRIBE101"; + + /// Generic type-argument index out of range on the attribute class. + public const string AttributeTypeArgMissing = "SCRIBE102"; +} diff --git a/Scribe/Attributes/AttributeSchema.cs b/Scribe/Attributes/AttributeSchema.cs new file mode 100644 index 0000000..92bd814 --- /dev/null +++ b/Scribe/Attributes/AttributeSchema.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Attributes; + +/// +/// Typed, cache-safe single-attribute reader. Replaces the manual +/// ConstructorArguments[i].Value is string pattern that every Roslyn +/// source generator reinvents, with a small declarative projection surface. +/// +/// +/// +/// Usage — ad-hoc read for a transient stage: +/// +/// +/// var reader = AttributeSchema.For(symbol, "My.Ns.FooAttribute"); +/// if (reader.Exists) +/// { +/// var name = reader.Ctor<string>(0); +/// var max = reader.Named<int>("Max", 100); +/// } +/// +/// +/// Usage — cache-safe projection into a user model, with collected diagnostics: +/// +/// +/// var result = AttributeSchema.Read(symbol, "My.Ns.FooAttribute", (ref AttributeReader r) => +/// new MyModel( +/// Name: r.Ctor<string>(0), +/// Format: r.Named<string>("Format", "default"))); +/// // result.Exists, result.Model, result.Errors, result.Location +/// +/// +public static class AttributeSchema +{ + /// + /// Return a scoped to the first attribute on + /// whose class FQN equals . + /// If no match is found, the returned reader's + /// is and all typed reads return default. + /// + /// The symbol to inspect. + /// + /// Fully-qualified type name of the attribute, with or without the Attribute + /// suffix — both forms are accepted. Matched via the attribute class's display string. + /// + public static AttributeReader For(ISymbol symbol, string attributeFqn) + { + var attribute = FindAttribute(symbol, attributeFqn); + return new AttributeReader(attribute); + } + + /// + /// Project the first matching attribute into a cache-safe model. Collects any typed-read + /// mismatches into . + /// + /// The projection target. Constrain to so the result can flow through the incremental cache. + public static AttributeReadResult Read( + ISymbol symbol, + string attributeFqn, + AttributeProjection project) + where TModel : IEquatable + { + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + var attribute = FindAttribute(symbol, attributeFqn); + var reader = new AttributeReader(attribute); + var model = project(ref reader); + + return new AttributeReadResult( + Model: model, + Exists: reader.Exists, + Location: reader.Location, + Errors: reader.DrainErrors()); + } + + /// Convenience — if carries the named attribute. + public static bool Has(ISymbol symbol, string attributeFqn) => + FindAttribute(symbol, attributeFqn) is not null; + + private static AttributeData? FindAttribute(ISymbol symbol, string attributeFqn) + { + if (symbol is null || string.IsNullOrEmpty(attributeFqn)) + { + return null; + } + + var normalized = Normalize(attributeFqn); + + foreach (var attribute in symbol.GetAttributes()) + { + var fqn = attribute.AttributeClass?.ToDisplayString(); + if (fqn is null) + { + continue; + } + + // Generic attributes like [Foo] produce a display string with type args; + // strip them for the match so callers can pass the bare name. + var fqnBase = StripTypeArgs(fqn); + + if (string.Equals(fqn, attributeFqn, StringComparison.Ordinal) + || string.Equals(fqnBase, attributeFqn, StringComparison.Ordinal) + || string.Equals(Normalize(fqn), normalized, StringComparison.Ordinal) + || string.Equals(Normalize(fqnBase), normalized, StringComparison.Ordinal)) + { + return attribute; + } + } + + return null; + } + + // Strip a trailing "Attribute" suffix so both "Foo" and "FooAttribute" match the class "FooAttribute". + private static string Normalize(string fqn) + { + const string Suffix = "Attribute"; + if (fqn.Length > Suffix.Length && fqn.EndsWith(Suffix, StringComparison.Ordinal)) + { + return fqn.Substring(0, fqn.Length - Suffix.Length); + } + + return fqn; + } + + private static string StripTypeArgs(string fqn) + { + var bracket = fqn.IndexOf('<'); + return bracket > 0 ? fqn.Substring(0, bracket) : fqn; + } +} + +/// Projection delegate for . +public delegate TModel AttributeProjection(ref AttributeReader reader); + +/// +/// Cache-safe result of . +/// All fields are pipeline-safe. +/// +public readonly record struct AttributeReadResult( + TModel Model, + bool Exists, + LocationInfo? Location, + EquatableArray Errors) + where TModel : IEquatable; diff --git a/Scribe/Cache/DiagnosticInfo.cs b/Scribe/Cache/DiagnosticInfo.cs new file mode 100644 index 0000000..e5050dd --- /dev/null +++ b/Scribe/Cache/DiagnosticInfo.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis; + +namespace Scribe.Cache; + +/// +/// A cache-safe record of a diagnostic — the ID, severity, message arguments, and +/// location — without holding references to a or +/// . +/// +/// +/// +/// Flows through the incremental pipeline alongside the symbol data it accompanies. +/// At the emission stage, callers combine it with a user-provided +/// (which contains non-cacheable state) and +/// materialise a real via . +/// +/// +public readonly record struct DiagnosticInfo( + string Id, + DiagnosticSeverity Severity, + EquatableArray MessageArgs, + LocationInfo? Location) +{ + /// + /// Combine this cache-safe record with the user-supplied descriptor and produce a + /// real for reporting. + /// + /// + /// The descriptor to use. Its should match + /// — this is not enforced here, callers are responsible. + /// + public Diagnostic Materialize(DiagnosticDescriptor descriptor) + { + var location = Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None; + return MessageArgs.IsEmpty + ? Diagnostic.Create(descriptor, location) + : Diagnostic.Create(descriptor, location, ToObjectArray(MessageArgs)); + } + + private static object?[] ToObjectArray(EquatableArray args) + { + var result = new object?[args.Count]; + for (var i = 0; i < args.Count; i++) + { + result[i] = args[i]; + } + + return result; + } +} diff --git a/Scribe/Cache/EquatableArray.cs b/Scribe/Cache/EquatableArray.cs new file mode 100644 index 0000000..d1c976e --- /dev/null +++ b/Scribe/Cache/EquatableArray.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Scribe.Cache; + +/// +/// An that participates in structural, element-wise +/// value equality — the form required by the Roslyn incremental-generator cache. +/// +/// +/// +/// is a struct wrapping a T[]; its built-in +/// compares the underlying array reference, +/// not its contents. Any value that flows through an +/// IncrementalValuesProvider<T> must compare by value, otherwise the +/// cache is invalidated on every compilation and the generator runs on every keystroke. +/// +/// +/// wraps , computes a +/// deterministic element-wise hash once at construction time (cached in a field so +/// is a constant-time field read), and overrides +/// to compare element-by-element via +/// . +/// +/// +/// default(EquatableArray<T>) is normalised to an empty array (same hash, +/// equal to ) — callers never need to null-check before use. +/// +/// +/// Element type. Must implement . +public readonly struct EquatableArray : IEquatable>, IReadOnlyList + where T : IEquatable +{ + /// Canonical empty instance. Equal to default(EquatableArray<T>). + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + private readonly ImmutableArray _array; + private readonly int _hash; + + /// Wrap an existing . + /// Source array. default and empty are treated identically. + public EquatableArray(ImmutableArray array) + { + if (array.IsDefaultOrEmpty) + { + _array = ImmutableArray.Empty; + _hash = EmptyHash; + } + else + { + _array = array; + _hash = ComputeHash(array); + } + } + + /// Number of elements. + public int Count => _array.IsDefault ? 0 : _array.Length; + + /// if there are zero elements. + public bool IsEmpty => _array.IsDefaultOrEmpty; + + /// Element at the given zero-based index. + public T this[int index] => _array[index]; + + /// Allocation-free read-only span over the elements. + public ReadOnlySpan AsSpan() => _array.IsDefault ? [] : _array.AsSpan(); + + /// Structural, element-wise equality. + public bool Equals(EquatableArray other) + { + if (_hash != other._hash) + { + return false; + } + + var left = AsSpan(); + var right = other.AsSpan(); + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (!EqualityComparer.Default.Equals(left[i], right[i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + /// + public override int GetHashCode() => _hash; + + /// Enumerate elements. + public IEnumerator GetEnumerator() => + _array.IsDefault + ? Enumerable.Empty().GetEnumerator() + : ((IEnumerable)_array).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// Structural equality operator. + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + + /// Structural inequality operator. + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); + + /// Implicit conversion from . + public static implicit operator EquatableArray(ImmutableArray array) => new(array); + + // ─── internal ───────────────────────────────────────────────────────────── + + private const int EmptyHash = 0; + + private static int ComputeHash(ImmutableArray array) + { + // FNV-1a over element hashes. Deterministic, no System.HashCode dependency + // (not guaranteed on netstandard2.0). + const uint seed = 2166136261u; + const uint step = 16777619u; + var hash = seed; + foreach (var item in array) + { + var itemHash = (uint)(item?.GetHashCode() ?? 0); + hash = unchecked((hash ^ itemHash) * step); + } + + return unchecked((int)hash); + } +} + +/// Factory helpers for . +public static class EquatableArray +{ + /// Create from an existing . + public static EquatableArray Create(ImmutableArray source) + where T : IEquatable => new(source); + + /// Create from a params array. + public static EquatableArray Create(params T[] items) + where T : IEquatable => + items is null || items.Length == 0 + ? EquatableArray.Empty + : new EquatableArray(ImmutableArray.Create(items)); + + /// Create from an arbitrary enumerable. + public static EquatableArray From(IEnumerable source) + where T : IEquatable + { + if (source is null) + { + return EquatableArray.Empty; + } + + if (source is ImmutableArray immutable) + { + return new EquatableArray(immutable); + } + + return new EquatableArray(source.ToImmutableArray()); + } +} diff --git a/Scribe/Cache/InternPool.cs b/Scribe/Cache/InternPool.cs new file mode 100644 index 0000000..d55a351 --- /dev/null +++ b/Scribe/Cache/InternPool.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; + +namespace Scribe.Cache; + +/// +/// Process-wide string intern pool for strings that repeat across an incremental +/// compilation — diagnostic IDs, fully-qualified type names, file paths, attribute +/// metadata names. +/// +/// +/// +/// The primary benefit is reducing on +/// cache-key comparison paths. An interned string compares reference-first, so the +/// hot path collapses from O(length) to O(1) in the common case. +/// +/// +/// This pool is separate from the BCL to avoid +/// polluting the CLR's global intern table with per-analyzer strings. +/// +/// +/// Scope: pool strings that are (a) small, (b) high-repetition, +/// (c) drawn from a bounded vocabulary. Do not intern user message text, +/// user-authored member names, or anything unbounded. +/// +/// +public static class InternPool +{ + private static readonly ConcurrentDictionary Pool = new(); + + /// + /// Return the canonical interned instance for . Subsequent + /// calls with an equal string return the same reference. + /// + public static string Intern(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return Pool.GetOrAdd(value, value); + } + + /// Current number of distinct strings in the pool. Primarily for diagnostics/tests. + public static int Count => Pool.Count; +} diff --git a/Scribe/Cache/LocationInfo.cs b/Scribe/Cache/LocationInfo.cs new file mode 100644 index 0000000..c9b2ff4 --- /dev/null +++ b/Scribe/Cache/LocationInfo.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Scribe.Cache; + +/// +/// A cache-safe stand-in for . Captures file path and spans +/// as primitive values so the enclosing value can flow through the Roslyn incremental +/// cache without pinning a / . +/// +/// +/// +/// Roslyn's holds a reference to its , +/// which in turn is tied to a . Storing a +/// in any cached pipeline value pins the entire compilation in memory and invalidates +/// the cache on every compilation change. +/// +/// +/// Convert at the boundary: use when entering the pipeline +/// and when materialising a diagnostic for reporting. +/// +/// +public readonly record struct LocationInfo( + string FilePath, + TextSpan TextSpan, + LinePositionSpan LineSpan) +{ + /// + /// Convert a Roslyn into a cache-safe . + /// Returns for null, non-source, or tree-less locations. + /// + public static LocationInfo? From(Location? location) + { + if (location is null || !location.IsInSource) + { + return null; + } + + var tree = location.SourceTree; + if (tree is null) + { + return null; + } + + return new LocationInfo( + InternPool.Intern(tree.FilePath), + location.SourceSpan, + location.GetLineSpan().Span); + } + + /// Materialise back into a Roslyn for reporting. + public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan); +} diff --git a/Scribe/Cache/WellKnownTypes.cs b/Scribe/Cache/WellKnownTypes.cs new file mode 100644 index 0000000..e2911c5 --- /dev/null +++ b/Scribe/Cache/WellKnownTypes.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Scribe.Cache; + +/// +/// A small per- cache of s +/// resolved by metadata name, so each symbol is looked up at most once per compilation. +/// +/// +/// +/// Not cache-safe. This type holds +/// references, which are tied to the specific they were +/// resolved from. Construct it fresh at the edge of an incremental pipeline +/// stage (typically inside a CompilationProvider.Select callback) and discard +/// it before any cached value crosses the stage boundary. +/// +/// +/// The purpose is purely to avoid repeating +/// — an O(n) scan over +/// referenced assemblies — for the same name within a single pipeline stage. +/// +/// +public sealed class WellKnownTypes +{ + private readonly Dictionary _cache; + + private WellKnownTypes(Dictionary cache) => _cache = cache; + + /// + /// Resolve all listed metadata names against and build + /// a fresh cache. + /// + public static WellKnownTypes From(Compilation compilation, params string[] metadataNames) + { + if (compilation is null) + { + throw new System.ArgumentNullException(nameof(compilation)); + } + + var cache = new Dictionary( + metadataNames?.Length ?? 0, + System.StringComparer.Ordinal); + + if (metadataNames is not null) + { + foreach (var name in metadataNames) + { + if (!string.IsNullOrEmpty(name) && !cache.ContainsKey(name)) + { + cache[name] = compilation.GetTypeByMetadataName(name); + } + } + } + + return new WellKnownTypes(cache); + } + + /// + /// Resolve a metadata name, caching the result. Unlike , + /// this is used when the set of required types is not known up front. Requires the caller + /// to hold a live . + /// + public INamedTypeSymbol? Resolve(Compilation compilation, string metadataName) + { + if (_cache.TryGetValue(metadataName, out var cached)) + { + return cached; + } + + var resolved = compilation.GetTypeByMetadataName(metadataName); + _cache[metadataName] = resolved; + return resolved; + } + + /// Look up a pre-resolved metadata name. Returns if not present or not found. + public INamedTypeSymbol? Get(string metadataName) => + _cache.TryGetValue(metadataName, out var symbol) ? symbol : null; + + /// Look up a pre-resolved metadata name, returning if not present. + public bool TryGet(string metadataName, out INamedTypeSymbol? symbol) => + _cache.TryGetValue(metadataName, out symbol); +} diff --git a/Scribe/Scribe.csproj b/Scribe/Scribe.csproj index affb488..517f1f2 100644 --- a/Scribe/Scribe.csproj +++ b/Scribe/Scribe.csproj @@ -23,6 +23,7 @@ + @@ -42,4 +43,12 @@ + + + + + diff --git a/Scribe/Shapes/DiagnosticSpec.cs b/Scribe/Shapes/DiagnosticSpec.cs new file mode 100644 index 0000000..5b8a363 --- /dev/null +++ b/Scribe/Shapes/DiagnosticSpec.cs @@ -0,0 +1,21 @@ +using Microsoft.CodeAnalysis; + +namespace Scribe.Shapes; + +/// +/// Selective override for a check's default diagnostic +/// descriptor. Any field left keeps the primitive's default. +/// +/// Diagnostic ID (defaults to SCRIBE-prefixed per primitive). +/// One-line diagnostic title. +/// Message format — {0} etc. will be substituted with the primitive's arguments. +/// Severity — keeps the primitive default. +/// Squiggle anchor. +/// Override for the auto-fix hint. +public readonly record struct DiagnosticSpec( + string? Id = null, + string? Title = null, + string? Message = null, + DiagnosticSeverity? Severity = null, + SquiggleAt? Target = null, + FixSpec? Fix = null); diff --git a/Scribe/Shapes/FixSpec.cs b/Scribe/Shapes/FixSpec.cs new file mode 100644 index 0000000..2c8e20e --- /dev/null +++ b/Scribe/Shapes/FixSpec.cs @@ -0,0 +1,49 @@ +namespace Scribe.Shapes; + +/// +/// A declarative hint — picked up by Phase-4 code-fix generation — describing +/// how a violation of a check should be auto-repaired. +/// +public readonly record struct FixSpec(FixKind Kind, string? EquivalenceKey = null); + +/// +/// Catalog of built-in fix shapes recognised by Scribe.Fixes. +/// +public enum FixKind +{ + /// No automatic fix — the diagnostic is informational or requires user judgement. + None, + + /// Add the partial modifier to the type declaration. + AddPartialModifier, + + /// Add the sealed modifier to the type declaration. + AddSealedModifier, + + /// Add an interface to the base list. + AddInterfaceToBaseList, + + /// Remove a specific entry from the base list. + RemoveFromBaseList, + + /// Add an attribute to the declaration. + AddAttribute, + + /// Rename the type to match a specified pattern. + RenameTo, + + /// Generate a stub method matching a specified signature. + AddStubMethod, + + /// Add the abstract modifier to the type declaration. + AddAbstractModifier, + + /// Remove the abstract modifier from the type declaration. + RemoveAbstractModifier, + + /// Add the static modifier to the type declaration. + AddStaticModifier, + + /// Add a base class to the type declaration (fix property baseClass). + AddBaseClass, +} diff --git a/Scribe/Shapes/Relation.cs b/Scribe/Shapes/Relation.cs new file mode 100644 index 0000000..d8775f8 --- /dev/null +++ b/Scribe/Shapes/Relation.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Join two shape streams on a key. Produces a matched-pair stream plus an +/// opt-in diagnostic stream for orphaned sides. Orphans are silent by default — +/// the pair is a query, not a requirement — so the author must declare +/// what missing means by calling +/// or . +/// +public static class Relation +{ + /// + /// Join and by equal keys. + /// The returned builder lazily wires matched-pair and diagnostic providers + /// when their properties are first accessed. + /// + public static PairBuilder Pair( + IncrementalValuesProvider> left, + IncrementalValuesProvider> right, + Func leftKey, + Func rightKey) + where TLeft : IEquatable + where TRight : IEquatable + => new(left, right, leftKey, rightKey); +} + +/// +/// Configuration surface for a join. +/// Each orphan policy returns this so the declaration reads left-to-right. +/// +public sealed class PairBuilder + where TLeft : IEquatable + where TRight : IEquatable +{ + private readonly IncrementalValuesProvider> _left; + private readonly IncrementalValuesProvider> _right; + private readonly Func _leftKey; + private readonly Func _rightKey; + + private OrphanPolicy? _leftHasRight; + private OrphanPolicy? _rightUnused; + + internal PairBuilder( + IncrementalValuesProvider> left, + IncrementalValuesProvider> right, + Func leftKey, + Func rightKey) + { + _left = left; + _right = right; + _leftKey = leftKey; + _rightKey = rightKey; + } + + /// + /// Emit a diagnostic for every left-side item whose key matches no right-side + /// item. The message receives {0} = left.Fqn and {1} = missing key. + /// + public PairBuilder RequireLeftHasRight( + string id, + string title, + string messageFormat, + DiagnosticSeverity severity = DiagnosticSeverity.Error) + { + _leftHasRight = new OrphanPolicy(id, title, messageFormat, severity); + return this; + } + + /// + /// Emit a diagnostic for every right-side item whose key is not referenced by + /// any left-side item. The message receives {0} = right.Fqn. + /// + public PairBuilder WarnOnRightUnused( + string id, + string title, + string messageFormat, + DiagnosticSeverity severity = DiagnosticSeverity.Warning) + { + _rightUnused = new OrphanPolicy(id, title, messageFormat, severity); + return this; + } + + /// + /// Stream of successful joins. Each left item is paired with the first + /// right-side item sharing its key; duplicate right-side keys drop to first- + /// wins in v1 (a RequireUniqueRightKey overload may be added later). + /// + public IncrementalValuesProvider> Matched + { + get + { + var leftKey = _leftKey; + var rightKey = _rightKey; + + var rightByKey = _right + .Collect() + .Select((arr, _) => BuildRightIndex(arr, rightKey)); + + return _left + .Combine(rightByKey) + .Select((tuple, _) => TryPair(tuple.Left, tuple.Right, leftKey)) + .Where(p => p.HasValue) + .Select((p, _) => p!.Value); + } + } + + /// + /// Stream of orphan diagnostics. Empty unless + /// or was + /// configured. Pair with when reporting, or call + /// to wire everything up in one line. + /// + public IncrementalValuesProvider Diagnostics + { + get + { + var leftKey = _leftKey; + var rightKey = _rightKey; + var leftPolicy = _leftHasRight; + var rightPolicy = _rightUnused; + + var rightByKey = _right + .Collect() + .Select((arr, _) => BuildRightIndex(arr, rightKey)); + + var leftOrphans = _left + .Combine(rightByKey) + .Select((tuple, _) => FindLeftOrphan(tuple.Left, tuple.Right, leftKey, leftPolicy)) + .Where(d => d.HasValue) + .Select((d, _) => d!.Value); + + var leftKeys = _left + .Collect() + .Select((arr, _) => BuildLeftKeySet(arr, leftKey)); + + var rightOrphans = _right + .Combine(leftKeys) + .Select((tuple, _) => FindRightOrphan(tuple.Left, tuple.Right, rightKey, rightPolicy)) + .Where(d => d.HasValue) + .Select((d, _) => d!.Value); + + return leftOrphans.Collect() + .Combine(rightOrphans.Collect()) + .SelectMany((pair, _) => Concat(pair.Left, pair.Right)); + } + } + + /// + /// One descriptor per configured orphan policy, keyed by diagnostic id. + /// Consumer-side analyzers/generators must include these in their + /// SupportedDiagnostics when reporting. + /// + public ImmutableArray Descriptors + { + get + { + var builder = ImmutableArray.CreateBuilder(2); + if (_leftHasRight is { } l) + { + builder.Add(BuildDescriptor(l)); + } + + if (_rightUnused is { } r) + { + builder.Add(BuildDescriptor(r)); + } + + return builder.ToImmutable(); + } + } + + /// + /// One-liner: materialize every orphan with the + /// matching descriptor and report it. Call from + /// IncrementalGenerator.Initialize. + /// + public void RegisterDiagnostics(IncrementalGeneratorInitializationContext context) + { + var descriptors = Descriptors; + if (descriptors.IsEmpty) + { + return; + } + + context.RegisterSourceOutput(Diagnostics, (spc, info) => + { + foreach (var descriptor in descriptors) + { + if (string.Equals(descriptor.Id, info.Id, StringComparison.Ordinal)) + { + spc.ReportDiagnostic(info.Materialize(descriptor)); + return; + } + } + }); + } + + private static DiagnosticDescriptor BuildDescriptor(OrphanPolicy policy) => + new( + id: policy.Id, + title: policy.Title, + messageFormat: policy.MessageFormat, + category: "Scribe.Relation", + defaultSeverity: policy.Severity, + isEnabledByDefault: true); + + private static IEnumerable Concat( + ImmutableArray a, + ImmutableArray b) + { + foreach (var d in a) + { + yield return d; + } + + foreach (var d in b) + { + yield return d; + } + } + + private static ImmutableDictionary> BuildRightIndex( + ImmutableArray> rights, + Func keyOf) + { + var builder = ImmutableDictionary.CreateBuilder>(StringComparer.Ordinal); + foreach (var right in rights) + { + var key = keyOf(right.Model); + if (key is null) + { + continue; + } + + if (!builder.ContainsKey(key)) + { + builder.Add(key, right); + } + } + + return builder.ToImmutable(); + } + + private static ImmutableHashSet BuildLeftKeySet( + ImmutableArray> lefts, + Func keyOf) + { + var builder = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + foreach (var left in lefts) + { + var key = keyOf(left.Model); + if (key is null) + { + continue; + } + + builder.Add(key); + } + + return builder.ToImmutable(); + } + + private static ShapedPair? TryPair( + ShapedSymbol left, + ImmutableDictionary> rightByKey, + Func leftKey) + { + var key = leftKey(left.Model); + if (key is null) + { + return null; + } + + return rightByKey.TryGetValue(key, out var right) + ? new ShapedPair(left, right) + : null; + } + + private static DiagnosticInfo? FindLeftOrphan( + ShapedSymbol left, + ImmutableDictionary> rightByKey, + Func leftKey, + OrphanPolicy? policy) + { + if (policy is null) + { + return null; + } + + var key = leftKey(left.Model); + if (key is null || rightByKey.ContainsKey(key)) + { + return null; + } + + return new DiagnosticInfo( + Id: policy.Value.Id, + Severity: policy.Value.Severity, + MessageArgs: EquatableArray.Create(left.Fqn, key), + Location: left.Location); + } + + private static DiagnosticInfo? FindRightOrphan( + ShapedSymbol right, + ImmutableHashSet leftKeys, + Func rightKey, + OrphanPolicy? policy) + { + if (policy is null) + { + return null; + } + + var key = rightKey(right.Model); + if (key is null || leftKeys.Contains(key)) + { + return null; + } + + return new DiagnosticInfo( + Id: policy.Value.Id, + Severity: policy.Value.Severity, + MessageArgs: EquatableArray.Create(right.Fqn), + Location: right.Location); + } + + private readonly record struct OrphanPolicy( + string Id, + string Title, + string MessageFormat, + DiagnosticSeverity Severity); +} diff --git a/Scribe/Shapes/SeverityVariants.cs b/Scribe/Shapes/SeverityVariants.cs new file mode 100644 index 0000000..1c39aa6 --- /dev/null +++ b/Scribe/Shapes/SeverityVariants.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis; + +namespace Scribe.Shapes; + +/// +/// Helpers used by the generated Should* / Could* / +/// ShouldNot* variants of each Must* primitive. Applies a +/// default severity only when the caller has not explicitly chosen one. +/// +internal static class SeverityVariants +{ + /// + /// Return a whose + /// is the one the caller provided, or — if the caller left it unset — + /// . + /// + public static DiagnosticSpec WithSeverity(DiagnosticSpec? spec, DiagnosticSeverity defaultSeverity) + { + if (spec is null) + { + return new DiagnosticSpec(Severity: defaultSeverity); + } + + return spec.Value.Severity is null + ? spec.Value with { Severity = defaultSeverity } + : spec.Value; + } +} diff --git a/Scribe/Shapes/Shape.cs b/Scribe/Shapes/Shape.cs new file mode 100644 index 0000000..bce4d23 --- /dev/null +++ b/Scribe/Shapes/Shape.cs @@ -0,0 +1,38 @@ +namespace Scribe.Shapes; + +/// +/// Entry point for the Shape DSL. Pick the type-kind you want to match, then fluently +/// layer MustBeX constraints, then +/// into a cache-safe model. +/// +/// +/// +/// var shape = Shape.Class() +/// .MustHaveAttribute<ThingAttribute>() +/// .MustBePartial() +/// .MustImplement<IThing>() +/// .Project(in ctx => new ThingModel( +/// Fqn: ctx.Fqn, +/// Flavor: ctx.Attribute.Ctor<string>(0) ?? "default")); +/// +/// +public static class Shape +{ + /// Match class declarations. + public static ShapeBuilder Class() => new(TypeKindFilter.Class); + + /// Match record declarations (class form). + public static ShapeBuilder Record() => new(TypeKindFilter.Record); + + /// Match record struct declarations. + public static ShapeBuilder RecordStruct() => new(TypeKindFilter.RecordStruct); + + /// Match struct declarations. + public static ShapeBuilder Struct() => new(TypeKindFilter.Struct); + + /// Match interface declarations. + public static ShapeBuilder Interface() => new(TypeKindFilter.Interface); + + /// Match any type declaration regardless of kind. + public static ShapeBuilder AnyType() => new(TypeKindFilter.Any); +} diff --git a/Scribe/Shapes/ShapeBuilder.cs b/Scribe/Shapes/ShapeBuilder.cs new file mode 100644 index 0000000..f388bc4 --- /dev/null +++ b/Scribe/Shapes/ShapeBuilder.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.RegularExpressions; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Fluent builder that accumulates a conjunction of MustBeX checks on a single +/// type-shape subject and projects the match into a cache-safe TModel. +/// +/// +/// Obtain via the factories on . +/// Each primitive is a declarative constraint: the check carries a default +/// diagnostic ID, title, message, severity, squiggle anchor, and fix hint; callers +/// can selectively override any of these via a . +/// +public sealed partial class ShapeBuilder +{ + private readonly TypeKindFilter _kind; + private readonly List _checks = new(); + private string? _primaryAttributeMetadataName; + + internal ShapeBuilder(TypeKindFilter kind) => _kind = kind; + + internal TypeKindFilter Kind => _kind; + internal IReadOnlyList Checks => _checks; + internal string? PrimaryAttributeMetadataName => _primaryAttributeMetadataName; + + /// + /// Seal the builder and project matches into a cache-safe model. + /// The projection runs inside the incremental pipeline's transform stage, with + /// access to the matched symbol, semantic model, and — if declared — the + /// driving attribute reader. + /// + /// Must be for cache correctness. + public Shape Project(ProjectionDelegate project) + where TModel : IEquatable + { + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + return new Shape( + kind: _kind, + checks: _checks.ToArray(), + primaryAttributeMetadataName: _primaryAttributeMetadataName, + project: project); + } + + internal bool MatchesSyntaxNode(SyntaxNode node) => + _kind switch + { + TypeKindFilter.Class => + node is ClassDeclarationSyntax c && !c.Modifiers.Any(SyntaxKind.RecordKeyword), + TypeKindFilter.Record => + node is RecordDeclarationSyntax r && r.IsKind(SyntaxKind.RecordDeclaration), + TypeKindFilter.RecordStruct => + node is RecordDeclarationSyntax rs && rs.IsKind(SyntaxKind.RecordStructDeclaration), + TypeKindFilter.Struct => + node is StructDeclarationSyntax s && !s.Modifiers.Any(SyntaxKind.RecordKeyword), + TypeKindFilter.Interface => + node is InterfaceDeclarationSyntax, + TypeKindFilter.Any => + node is TypeDeclarationSyntax, + _ => false, + }; + + internal bool MatchesSymbolKind(INamedTypeSymbol symbol) => + _kind switch + { + TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord, + TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord, + TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord, + TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord, + TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface, + TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface, + _ => false, + }; + + private void AddCheck( + string defaultId, + string defaultTitle, + string defaultMessage, + DiagnosticSeverity defaultSeverity, + SquiggleAt defaultSquiggle, + FixKind defaultFix, + Func predicate, + Func> messageArgs, + DiagnosticSpec? spec, + Func>? fixProperties = null) + { + _checks.Add(new ShapeCheck( + Id: InternPool.Intern(spec?.Id ?? defaultId), + Title: spec?.Title ?? defaultTitle, + MessageFormat: spec?.Message ?? defaultMessage, + Severity: spec?.Severity ?? defaultSeverity, + SquiggleAt: spec?.Target ?? defaultSquiggle, + FixKind: spec?.Fix?.Kind ?? defaultFix, + Predicate: predicate, + MessageArgs: messageArgs, + FixProperties: fixProperties)); + } + + // ─────────────────────────────────────────────────────────────── + // Primitives (Phase 3) + // ─────────────────────────────────────────────────────────────── + + /// Require the type to be declared partial. + public ShapeBuilder MustBePartial(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE001", + defaultTitle: "Type must be partial", + defaultMessage: "Type '{0}' must be declared 'partial'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.AddPartialModifier, + predicate: static (sym, _, ct) => IsPartial(sym, ct), + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Require the type to be declared sealed. + public ShapeBuilder MustBeSealed(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE002", + defaultTitle: "Type must be sealed", + defaultMessage: "Type '{0}' must be declared 'sealed'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.AddSealedModifier, + predicate: static (sym, _, _) => sym.IsSealed || sym.IsValueType || sym.IsStatic, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// + /// Require the type to implement . For non-generic interfaces + /// only in v1 — use the overload + /// with a metadata name for generic interfaces. + /// + public ShapeBuilder MustImplement(DiagnosticSpec? spec = null) + where T : class + { + var fqn = typeof(T).FullName!; + return MustImplement(fqn, spec); + } + + /// Require the type to implement the interface named by . + public ShapeBuilder MustImplement(string metadataName, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + AddCheck( + defaultId: "SCRIBE003", + defaultTitle: "Type must implement required interface", + defaultMessage: "Type '{0}' must implement '{1}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.BaseList, + defaultFix: FixKind.AddInterfaceToBaseList, + predicate: (sym, comp, _) => ImplementsByMetadataName(sym, comp, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("interface", interned)); + return this; + } + + /// + /// Require the type to carry attribute . The first + /// MustHaveAttribute declared on the shape is promoted to the driving + /// attribute and routes collection through + /// . + /// + public ShapeBuilder MustHaveAttribute(DiagnosticSpec? spec = null) + where T : System.Attribute + => MustHaveAttribute(typeof(T).FullName!, spec); + + /// + /// Require the type to carry the attribute named by . + /// + public ShapeBuilder MustHaveAttribute(string metadataName, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + _primaryAttributeMetadataName ??= interned; + + AddCheck( + defaultId: "SCRIBE004", + defaultTitle: "Type must have required attribute", + defaultMessage: "Type '{0}' must be annotated with '[{1}]'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.Identifier, + defaultFix: FixKind.AddAttribute, + predicate: (sym, _, _) => HasAttribute(sym, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("attribute", interned)); + return this; + } + + /// + /// Require the type's to match + /// as a regular expression. No auto-fix is offered by default — a regex pattern + /// does not uniquely identify a target name; override via + /// with a concrete to supply one. + /// + public ShapeBuilder MustBeNamed(string pattern, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(pattern)) + { + throw new ArgumentException("Pattern must not be empty.", nameof(pattern)); + } + + var regex = new Regex(pattern, RegexOptions.CultureInvariant); + AddCheck( + defaultId: "SCRIBE005", + defaultTitle: "Type name must match required pattern", + defaultMessage: "Type '{0}' name does not match pattern '{1}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.Identifier, + defaultFix: FixKind.None, + predicate: (sym, _, _) => regex.IsMatch(sym.Name), + messageArgs: sym => EquatableArray.Create(sym.Name, pattern), + spec: spec); + return this; + } + + /// Require the type to be declared abstract. + public ShapeBuilder MustBeAbstract(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE006", + defaultTitle: "Type must be abstract", + defaultMessage: "Type '{0}' must be declared 'abstract'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.AddAbstractModifier, + predicate: static (sym, _, _) => sym.IsAbstract, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Require the type to be declared static. + public ShapeBuilder MustBeStatic(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE007", + defaultTitle: "Type must be static", + defaultMessage: "Type '{0}' must be declared 'static'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.AddStaticModifier, + predicate: static (sym, _, _) => sym.IsStatic, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// + /// Require the type to extend the base class . + /// Interfaces use instead. + /// + public ShapeBuilder MustExtend(DiagnosticSpec? spec = null) + where T : class + => MustExtend(typeof(T).FullName!, spec); + + /// + /// Require the type to extend the base class named by . + /// + public ShapeBuilder MustExtend(string metadataName, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + AddCheck( + defaultId: "SCRIBE008", + defaultTitle: "Type must extend required base class", + defaultMessage: "Type '{0}' must extend base class '{1}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.BaseList, + defaultFix: FixKind.AddBaseClass, + predicate: (sym, compilation, _) => ExtendsByMetadataName(sym, compilation, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("baseClass", interned)); + return this; + } + + /// + /// Require the type's containing namespace to match + /// as a regular expression. No auto-fix is offered — moving a file is outside + /// Scribe's automation surface. + /// + public ShapeBuilder MustBeInNamespace(string pattern, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(pattern)) + { + throw new ArgumentException("Pattern must not be empty.", nameof(pattern)); + } + + var regex = new Regex(pattern, RegexOptions.CultureInvariant); + AddCheck( + defaultId: "SCRIBE009", + defaultTitle: "Type's namespace must match required pattern", + defaultMessage: "Type '{0}' is in namespace '{1}' which does not match pattern '{2}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ContainingNamespace, + defaultFix: FixKind.None, + predicate: (sym, _, _) => regex.IsMatch(sym.ContainingNamespace?.ToDisplayString() ?? string.Empty), + messageArgs: sym => EquatableArray.Create( + sym.Name, + sym.ContainingNamespace?.ToDisplayString() ?? string.Empty, + pattern), + spec: spec); + return this; + } + + /// Forbid the abstract modifier on the type. + public ShapeBuilder MustNotBeAbstract(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE010", + defaultTitle: "Type must not be abstract", + defaultMessage: "Type '{0}' must not be declared 'abstract'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.RemoveAbstractModifier, + predicate: static (sym, _, _) => !sym.IsAbstract, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid generic type parameters on the type. + public ShapeBuilder MustNotBeGeneric(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE011", + defaultTitle: "Type must not be generic", + defaultMessage: "Type '{0}' must not declare generic type parameters", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.Identifier, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => !sym.IsGenericType, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid implementation of interface . + public ShapeBuilder MustNotImplement(DiagnosticSpec? spec = null) + where T : class + => MustNotImplement(typeof(T).FullName!, spec); + + /// + /// Forbid implementation of the interface named by . + /// + public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec = null) + { + if (string.IsNullOrEmpty(metadataName)) + { + throw new ArgumentException("Metadata name must not be empty.", nameof(metadataName)); + } + + var interned = InternPool.Intern(metadataName); + AddCheck( + defaultId: "SCRIBE012", + defaultTitle: "Type must not implement forbidden interface", + defaultMessage: "Type '{0}' must not implement '{1}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.BaseList, + defaultFix: FixKind.RemoveFromBaseList, + predicate: (sym, compilation, _) => !ImplementsByMetadataName(sym, compilation, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("interface", interned)); + return this; + } + + // ─────────────────────────────────────────────────────────────── + // Predicate helpers (static, closure-free) + // ─────────────────────────────────────────────────────────────── + + private static bool IsPartial(INamedTypeSymbol symbol, CancellationToken ct) + { + var refs = symbol.DeclaringSyntaxReferences; + foreach (var reference in refs) + { + ct.ThrowIfCancellationRequested(); + if (reference.GetSyntax(ct) is TypeDeclarationSyntax tds + && tds.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return true; + } + } + + return false; + } + + private static bool ImplementsByMetadataName( + INamedTypeSymbol symbol, Compilation compilation, string metadataName) + { + var target = compilation.GetTypeByMetadataName(metadataName); + if (target is null) + { + // If the interface is not visible in this compilation, cannot assert — treat as satisfied. + return true; + } + + foreach (var iface in symbol.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, target)) + { + return true; + } + } + + return false; + } + + private static bool ExtendsByMetadataName( + INamedTypeSymbol symbol, Compilation compilation, string metadataName) + { + var target = compilation.GetTypeByMetadataName(metadataName); + if (target is null) + { + // Base class not visible in this compilation — cannot assert, treat as satisfied. + return true; + } + + var current = symbol.BaseType; + while (current is not null) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, target)) + { + return true; + } + + current = current.BaseType; + } + + return false; + } + + private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) + { + foreach (var attribute in symbol.GetAttributes()) + { + var fqn = attribute.AttributeClass?.ToDisplayString(); + if (fqn is null) + { + continue; + } + + if (string.Equals(fqn, metadataName, StringComparison.Ordinal)) + { + return true; + } + + // Strip generic args and/or trailing "Attribute" for a forgiving match. + var bracket = fqn.IndexOf('<'); + var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn; + if (string.Equals(bare, metadataName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/Scribe/Shapes/ShapeCheck.cs b/Scribe/Shapes/ShapeCheck.cs new file mode 100644 index 0000000..5959baa --- /dev/null +++ b/Scribe/Shapes/ShapeCheck.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Immutable; +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// Internal, opaque representation of a single declarative check registered on a +/// . A check pairs a positive +/// ( = shape satisfied) with the metadata needed to emit a +/// diagnostic when violated and the data needed to apply an automatic code fix. +/// +internal sealed record ShapeCheck( + string Id, + string Title, + string MessageFormat, + DiagnosticSeverity Severity, + SquiggleAt SquiggleAt, + FixKind FixKind, + Func Predicate, + Func> MessageArgs, + Func>? FixProperties = null); diff --git a/Scribe/Shapes/ShapeProjectionContext.cs b/Scribe/Shapes/ShapeProjectionContext.cs new file mode 100644 index 0000000..e5e6d97 --- /dev/null +++ b/Scribe/Shapes/ShapeProjectionContext.cs @@ -0,0 +1,60 @@ +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Attributes; + +namespace Scribe.Shapes; + +/// +/// Stack-only context passed to the user projection callback of +/// . Exposes the matched symbol and the +/// attribute reader (if declared +/// the driving attribute). +/// +/// +/// This is a ref struct: it pins and must not +/// escape the projection. Use it only to extract the cache-safe values that will +/// live in the resulting TModel. +/// +public readonly ref struct ShapeProjectionContext +{ + private readonly INamedTypeSymbol _symbol; + private readonly AttributeReader _attribute; + private readonly SemanticModel _semanticModel; + private readonly Compilation _compilation; + private readonly CancellationToken _cancellationToken; + + internal ShapeProjectionContext( + INamedTypeSymbol symbol, + AttributeReader attribute, + SemanticModel semanticModel, + Compilation compilation, + CancellationToken cancellationToken) + { + _symbol = symbol; + _attribute = attribute; + _semanticModel = semanticModel; + _compilation = compilation; + _cancellationToken = cancellationToken; + } + + /// The matched type symbol. + public INamedTypeSymbol Symbol => _symbol; + + /// Reader over the driving attribute, if was declared. + public AttributeReader Attribute => _attribute; + + /// Semantic model of the declaration's syntax tree. + public SemanticModel SemanticModel => _semanticModel; + + /// Current compilation. + public Compilation Compilation => _compilation; + + /// Cancellation token flowed from the incremental pipeline. + public CancellationToken CancellationToken => _cancellationToken; + + /// Fully-qualified display name of the matched type. + public string Fqn => _symbol.ToDisplayString(); +} + +/// Projection delegate for . +public delegate TModel ProjectionDelegate(in ShapeProjectionContext ctx); diff --git a/Scribe/Shapes/Shape_T.ToAnalyzer.cs b/Scribe/Shapes/Shape_T.ToAnalyzer.cs new file mode 100644 index 0000000..496bfa9 --- /dev/null +++ b/Scribe/Shapes/Shape_T.ToAnalyzer.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Scribe.Shapes; + +/// +/// Triple-lowering: project the same declarative shape into a +/// . Each registered check becomes a +/// ; on each +/// the analyser runs the same predicates the generator pipeline runs and emits +/// s at each check's . +/// +public sealed partial class Shape +{ + private DiagnosticAnalyzer? _cachedAnalyzer; + private ImmutableArray _cachedDescriptors; + private readonly ConcurrentDictionary _descriptorsById = new(); + + /// + /// Return a that reports this shape's checks + /// against every matching type in a compilation. Package the returned instance + /// inside a concrete [DiagnosticAnalyzer]-attributed wrapper class for + /// deployment as an analyzer. + /// + public DiagnosticAnalyzer ToAnalyzer() => + _cachedAnalyzer ??= new ShapeDiagnosticAnalyzer(this); + + internal ImmutableArray SupportedDescriptors + { + get + { + if (_cachedDescriptors.IsDefault) + { + var builder = ImmutableArray.CreateBuilder(_checks.Length); + foreach (var check in _checks) + { + builder.Add(DescriptorFor(check)); + } + + _cachedDescriptors = builder.ToImmutable(); + } + + return _cachedDescriptors; + } + } + + internal DiagnosticDescriptor DescriptorFor(ShapeCheck check) + { + return _descriptorsById.GetOrAdd(check.Id, _ => new DiagnosticDescriptor( + id: check.Id, + title: check.Title, + messageFormat: check.MessageFormat, + category: "Scribe.Shape", + defaultSeverity: check.Severity, + isEnabledByDefault: true)); + } + + internal TypeKindFilter KindFilter => _kind; + internal string? PrimaryAttributeName => _primaryAttributeMetadataName; + internal ShapeCheck[] CheckList => _checks; + + [DiagnosticAnalyzer(Microsoft.CodeAnalysis.LanguageNames.CSharp)] + private sealed class ShapeDiagnosticAnalyzer : DiagnosticAnalyzer + { + private readonly Shape _shape; + + public ShapeDiagnosticAnalyzer(Shape shape) => _shape = shape; + + public override ImmutableArray SupportedDiagnostics + => _shape.SupportedDescriptors; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private void AnalyzeNamedType(SymbolAnalysisContext ctx) + { + if (ctx.Symbol is not INamedTypeSymbol type) + { + return; + } + + if (!MatchesKind(_shape.KindFilter, type)) + { + return; + } + + if (_shape.PrimaryAttributeName is { } attrName + && !HasAttribute(type, attrName)) + { + // Shape drives off an attribute the type doesn't carry — silently skip. + return; + } + + foreach (var check in _shape.CheckList) + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + if (check.Predicate(type, ctx.Compilation, ctx.CancellationToken)) + { + continue; + } + + var descriptor = _shape.DescriptorFor(check); + var location = SquiggleLocator.Resolve(type, check.SquiggleAt, ctx.CancellationToken); + var properties = BuildProperties(check, type); + var args = check.MessageArgs(type); + var objArgs = new object?[args.Count]; + for (var i = 0; i < args.Count; i++) + { + objArgs[i] = args[i]; + } + + ctx.ReportDiagnostic(Diagnostic.Create( + descriptor, + location, + properties, + objArgs)); + } + } + + private static ImmutableDictionary BuildProperties( + ShapeCheck check, INamedTypeSymbol symbol) + { + var props = check.FixProperties?.Invoke(symbol) + ?? ImmutableDictionary.Empty; + + return props + .SetItem("fixKind", check.FixKind.ToString()) + .SetItem("squiggleAt", check.SquiggleAt.ToString()); + } + + private static bool MatchesKind(TypeKindFilter filter, INamedTypeSymbol symbol) => + filter switch + { + TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord, + TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord, + TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord, + TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord, + TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface, + TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface, + _ => false, + }; + + private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) + { + foreach (var attribute in symbol.GetAttributes()) + { + var fqn = attribute.AttributeClass?.ToDisplayString(); + if (fqn is null) + { + continue; + } + + if (string.Equals(fqn, metadataName, StringComparison.Ordinal)) + { + return true; + } + + var bracket = fqn.IndexOf('<'); + var bare = bracket > 0 ? fqn.Substring(0, bracket) : fqn; + if (string.Equals(bare, metadataName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Scribe/Shapes/Shape_T.cs b/Scribe/Shapes/Shape_T.cs new file mode 100644 index 0000000..f32cfe6 --- /dev/null +++ b/Scribe/Shapes/Shape_T.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; +using Scribe.Attributes; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// A sealed shape typed with its projection . Produced +/// by . In Phase 3 only +/// is exposed; ToAnalyzer and ToFixProvider +/// ship in Phase 4. +/// +/// User projection type. Must be for cache correctness. +public sealed partial class Shape where TModel : IEquatable +{ + private readonly TypeKindFilter _kind; + private readonly ShapeCheck[] _checks; + private readonly string? _primaryAttributeMetadataName; + private readonly ProjectionDelegate _project; + + internal Shape( + TypeKindFilter kind, + ShapeCheck[] checks, + string? primaryAttributeMetadataName, + ProjectionDelegate project) + { + _kind = kind; + _checks = checks; + _primaryAttributeMetadataName = primaryAttributeMetadataName; + _project = project; + } + + /// + /// Produce an of + /// — one per type in the compilation that + /// matches the shape's kind filter and (if declared) carries the driving + /// attribute. Violations of declared checks are carried on each result as + /// . + /// + public IncrementalValuesProvider> ToProvider( + IncrementalGeneratorInitializationContext context) + { + if (_primaryAttributeMetadataName is not null) + { + return context.SyntaxProvider + .ForAttributeWithMetadataName( + _primaryAttributeMetadataName, + predicate: (node, _) => MatchesNodeKind(node), + transform: TransformWithAttribute) + .Where(static s => s.HasValue) + .Select(static (s, _) => s!.Value); + } + + return context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (node, _) => MatchesNodeKind(node), + transform: TransformFromSyntax) + .Where(static s => s.HasValue) + .Select(static (s, _) => s!.Value); + } + + private ShapedSymbol? TransformWithAttribute( + GeneratorAttributeSyntaxContext ctx, CancellationToken ct) + { + if (ctx.TargetSymbol is not INamedTypeSymbol type) + { + return null; + } + + return BuildResult(type, ctx.SemanticModel, ct); + } + + private ShapedSymbol? TransformFromSyntax( + GeneratorSyntaxContext ctx, CancellationToken ct) + { + var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct); + if (symbol is not INamedTypeSymbol type) + { + return null; + } + + return BuildResult(type, ctx.SemanticModel, ct); + } + + private bool MatchesNodeKind(SyntaxNode node) + { + // Delegate to the builder's kind matcher by reconstructing a lightweight check. + return _kind switch + { + TypeKindFilter.Any => node is Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax, + _ => KindMatcher.Matches(_kind, node), + }; + } + + private bool MatchesSymbolKind(INamedTypeSymbol symbol) => + _kind switch + { + TypeKindFilter.Class => symbol.TypeKind == TypeKind.Class && !symbol.IsRecord, + TypeKindFilter.Record => symbol.TypeKind == TypeKind.Class && symbol.IsRecord, + TypeKindFilter.RecordStruct => symbol.TypeKind == TypeKind.Struct && symbol.IsRecord, + TypeKindFilter.Struct => symbol.TypeKind == TypeKind.Struct && !symbol.IsRecord, + TypeKindFilter.Interface => symbol.TypeKind == TypeKind.Interface, + TypeKindFilter.Any => symbol.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Interface, + _ => false, + }; + + private ShapedSymbol? BuildResult( + INamedTypeSymbol symbol, SemanticModel semanticModel, CancellationToken ct) + { + if (!MatchesSymbolKind(symbol)) + { + return null; + } + + var compilation = semanticModel.Compilation; + var violations = RunChecks(symbol, compilation, ct); + + var attributeReader = _primaryAttributeMetadataName is not null + ? AttributeSchema.For(symbol, _primaryAttributeMetadataName) + : default; + + var projectionContext = new ShapeProjectionContext( + symbol, attributeReader, semanticModel, compilation, ct); + var model = _project(in projectionContext); + + var location = LocationInfo.From(FirstLocation(symbol)); + + return new ShapedSymbol( + Fqn: InternPool.Intern(symbol.ToDisplayString()), + Model: model, + Location: location, + Violations: violations); + } + + private EquatableArray RunChecks( + INamedTypeSymbol symbol, Compilation compilation, CancellationToken ct) + { + if (_checks.Length == 0) + { + return EquatableArray.Empty; + } + + List? violations = null; + foreach (var check in _checks) + { + ct.ThrowIfCancellationRequested(); + if (check.Predicate(symbol, compilation, ct)) + { + continue; + } + + violations ??= new List(); + violations.Add(new DiagnosticInfo( + Id: check.Id, + Severity: check.Severity, + MessageArgs: check.MessageArgs(symbol), + Location: LocationInfo.From(FirstLocation(symbol)))); + } + + return violations is null + ? EquatableArray.Empty + : EquatableArray.From(violations); + } + + private static Location? FirstLocation(INamedTypeSymbol symbol) + { + var locations = symbol.Locations; + return locations.Length == 0 ? null : locations[0]; + } + + private static class KindMatcher + { + public static bool Matches(TypeKindFilter kind, SyntaxNode node) + { + if (node is not Microsoft.CodeAnalysis.CSharp.Syntax.TypeDeclarationSyntax) + { + return false; + } + + return kind switch + { + TypeKindFilter.Class => + node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax, + TypeKindFilter.Record => + node is Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax r + && r.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordDeclaration), + TypeKindFilter.RecordStruct => + node is Microsoft.CodeAnalysis.CSharp.Syntax.RecordDeclarationSyntax rs + && rs.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.RecordStructDeclaration), + TypeKindFilter.Struct => + node is Microsoft.CodeAnalysis.CSharp.Syntax.StructDeclarationSyntax, + TypeKindFilter.Interface => + node is Microsoft.CodeAnalysis.CSharp.Syntax.InterfaceDeclarationSyntax, + _ => false, + }; + } + } +} diff --git a/Scribe/Shapes/ShapedPair.cs b/Scribe/Shapes/ShapedPair.cs new file mode 100644 index 0000000..c8df8a4 --- /dev/null +++ b/Scribe/Shapes/ShapedPair.cs @@ -0,0 +1,16 @@ +using System; + +namespace Scribe.Shapes; + +/// +/// A value-equatable pair of s produced by +/// . Flows through +/// +/// so that joined-pair consumers react only to actual pairing changes, not +/// unrelated edits on either side. +/// +public readonly record struct ShapedPair( + ShapedSymbol Left, + ShapedSymbol Right) + where TLeft : IEquatable + where TRight : IEquatable; diff --git a/Scribe/Shapes/ShapedSymbol.cs b/Scribe/Shapes/ShapedSymbol.cs new file mode 100644 index 0000000..a3a07d2 --- /dev/null +++ b/Scribe/Shapes/ShapedSymbol.cs @@ -0,0 +1,21 @@ +using System; +using Scribe.Cache; + +namespace Scribe.Shapes; + +/// +/// A cache-safe projection of a single type matched by a . +/// Flows through +/// as the unit of change the generator downstream reacts to. +/// +/// Consumer-supplied projection; must be value-equatable for cache correctness. +/// Fully-qualified display name of the matched type (interned). +/// User projection of the symbol — cache-safe by constraint. +/// Location of the primary declaration, for downstream reporting. +/// Collected s emitted by declared shape checks. +public readonly record struct ShapedSymbol( + string Fqn, + TModel Model, + LocationInfo? Location, + EquatableArray Violations) + where TModel : IEquatable; diff --git a/Scribe/Shapes/SquiggleAt.cs b/Scribe/Shapes/SquiggleAt.cs new file mode 100644 index 0000000..390ff5a --- /dev/null +++ b/Scribe/Shapes/SquiggleAt.cs @@ -0,0 +1,33 @@ +namespace Scribe.Shapes; + +/// +/// Closed enumeration of squiggle anchor points on a type declaration. +/// Each primitive picks an opinionated default +/// so the diagnostic lands in the most editorially-useful place. +/// +public enum SquiggleAt +{ + /// The class / struct / record keyword token. + TypeKeyword, + + /// The type's identifier (name) token. + Identifier, + + /// The modifier list (e.g., public sealed). + ModifierList, + + /// The base list (e.g., : IFoo, IDisposable). + BaseList, + + /// The attribute list preceding the declaration. + AttributeList, + + /// The containing namespace declaration. + ContainingNamespace, + + /// The entire type declaration span. + EntireDeclaration, + + /// The first member of the offending kind. + FirstMemberOfKind, +} diff --git a/Scribe/Shapes/SquiggleLocator.cs b/Scribe/Shapes/SquiggleLocator.cs new file mode 100644 index 0000000..61f0af6 --- /dev/null +++ b/Scribe/Shapes/SquiggleLocator.cs @@ -0,0 +1,76 @@ +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Shapes; + +/// +/// Resolves a anchor to a concrete on +/// a type's primary declaration. Used by to +/// land diagnostics at the most editorially-useful place. +/// +internal static class SquiggleLocator +{ + public static Location Resolve(INamedTypeSymbol symbol, SquiggleAt anchor, CancellationToken ct) + { + var fallback = FirstLocationOrNone(symbol); + var refs = symbol.DeclaringSyntaxReferences; + if (refs.Length == 0) + { + return fallback; + } + + var syntax = refs[0].GetSyntax(ct); + if (syntax is not TypeDeclarationSyntax tds) + { + return fallback; + } + + return anchor switch + { + SquiggleAt.TypeKeyword => tds.Keyword.GetLocation(), + SquiggleAt.Identifier => tds.Identifier.GetLocation(), + SquiggleAt.ModifierList => ModifierListLocation(tds), + SquiggleAt.BaseList => tds.BaseList?.GetLocation() ?? tds.Identifier.GetLocation(), + SquiggleAt.AttributeList => tds.AttributeLists.FirstOrDefault()?.GetLocation() ?? tds.Identifier.GetLocation(), + SquiggleAt.ContainingNamespace => ContainingNamespaceLocation(tds) ?? tds.Identifier.GetLocation(), + SquiggleAt.EntireDeclaration => tds.GetLocation(), + SquiggleAt.FirstMemberOfKind => tds.Members.FirstOrDefault()?.GetLocation() ?? tds.Identifier.GetLocation(), + _ => tds.Identifier.GetLocation(), + }; + } + + private static Location ModifierListLocation(TypeDeclarationSyntax tds) + { + if (tds.Modifiers.Count == 0) + { + return tds.Keyword.GetLocation(); + } + + var first = tds.Modifiers.First(); + var last = tds.Modifiers.Last(); + var span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(first.Span.Start, last.Span.End); + return Location.Create(tds.SyntaxTree, span); + } + + private static Location? ContainingNamespaceLocation(SyntaxNode node) + { + for (var current = node.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case FileScopedNamespaceDeclarationSyntax fsn: + return fsn.Name.GetLocation(); + case NamespaceDeclarationSyntax nsd: + return nsd.Name.GetLocation(); + } + } + + return null; + } + + private static Location FirstLocationOrNone(INamedTypeSymbol symbol) => + symbol.Locations.Length == 0 ? Location.None : symbol.Locations[0]; +} diff --git a/Scribe/Shapes/TypeKindFilter.cs b/Scribe/Shapes/TypeKindFilter.cs new file mode 100644 index 0000000..da106fe --- /dev/null +++ b/Scribe/Shapes/TypeKindFilter.cs @@ -0,0 +1,15 @@ +namespace Scribe.Shapes; + +/// +/// Coarse type-kind filter applied at the syntax stage before semantic analysis. +/// Picked by , , etc. +/// +internal enum TypeKindFilter +{ + Class, + Record, + RecordStruct, + Struct, + Interface, + Any, +} diff --git a/Scribe/build/Scribe.LocalDev.props b/Scribe/build/Scribe.LocalDev.props index fb98cec..307634b 100644 --- a/Scribe/build/Scribe.LocalDev.props +++ b/Scribe/build/Scribe.LocalDev.props @@ -2,40 +2,67 @@ @@ -49,16 +76,49 @@ <_ScribeLocalDevImported>true - - + + + + $(ScribeName) + + + + + + <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.user" /> + <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.local" /> + + + <_ScribeLocalSentinelList>@(_ScribeLocalSentinel) + + and '$(_ScribeLocalSentinelList)' != ''"> true + + + + <_ScribeProducerBase>$(ScribeRoot)\.$(ScribesName.ToLowerInvariant()) + + + true + + $(ScribeRoot)\.artifacts\ @@ -69,21 +129,27 @@ - - + + true $(ScribePackagesDir) - + diff --git a/Scribe/build/Scribe.LocalDev.targets b/Scribe/build/Scribe.LocalDev.targets index b1c5382..1c233ab 100644 --- a/Scribe/build/Scribe.LocalDev.targets +++ b/Scribe/build/Scribe.LocalDev.targets @@ -2,25 +2,36 @@ @@ -33,16 +44,18 @@ <_ScribeLocalDevTargetsImported>true - + - - $(ScribeLocalDevTriggerProject) - - - - + - - - <_ScribeDevVersion>$(NuGetPackageVersion) <_ScribeDevVersion Condition="'$(_ScribeDevVersion)' == ''">$(PackageVersion) - <_ScribeDevOverridePath>$(ScribeArtifactsDir)$(ScribeLocalDevOverrideName).Directory.Packages.targets + <_ScribeDevOverridePath>$(ScribeArtifactsDir)$(PackageId).Directory.Packages.targets - - <_ScribeDevPackage Include="$(ScribeLocalDevPackageNames)" /> - - <_ScribeDevLine Include="<Project>" /> <_ScribeDevLine Include=" <!-- Auto-generated by Scribe LocalDev — do not edit. Rebuild the producer to refresh. -->" /> <_ScribeDevLine Include=" <ItemGroup>" /> - <_ScribeDevLine Include=" <PackageVersion Update="%(_ScribeDevPackage.Identity)" Version="$(_ScribeDevVersion)" />" /> + <_ScribeDevLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeDevVersion)" />" /> <_ScribeDevLine Include=" </ItemGroup>" /> <_ScribeDevLine Include="</Project>" /> @@ -117,4 +107,55 @@ Importance="high" /> + + + + + + <_ScribeCleanupOverride>$(ScribeArtifactsDir)$(PackageId).Directory.Packages.targets + + + + + + + + + <_ScribeStalePackage Include="$(ScribePackagesDir)$(PackageId).*-dev.*.nupkg" /> + <_ScribeStalePackage Include="$(ScribePackagesDir)$(PackageId).*-dev.*.snupkg" /> + + + + + diff --git a/Scribe/build/Scribe.SolutionAnalyzer.props b/Scribe/build/Scribe.SolutionAnalyzer.props deleted file mode 100644 index 5f7b01e..0000000 --- a/Scribe/build/Scribe.SolutionAnalyzer.props +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - <_ScribeSaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss')) - 0.0.0-dev.$(_ScribeSaTimestamp) - 0.0.0-dev.$(_ScribeSaTimestamp) - - - true - true - - - false - - - - - - <_ScribeSaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)')) - - - - - - $(_ScribeSaArtifactsDir)packages\ - - - - - - - $(RestoreAdditionalProjectSources);$(_ScribeSaArtifactsDir)packages - - - diff --git a/Scribe/build/Scribe.SolutionAnalyzer.targets b/Scribe/build/Scribe.SolutionAnalyzer.targets deleted file mode 100644 index 8e83133..0000000 --- a/Scribe/build/Scribe.SolutionAnalyzer.targets +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - <_ScribeSaVersion>$(NuGetPackageVersion) - <_ScribeSaVersion Condition="'$(_ScribeSaVersion)' == ''">$(PackageVersion) - <_ScribeSaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)')) - <_ScribeSaOverridePath>$(_ScribeSaArtifactsDir)$(PackageId).Directory.Packages.targets - - - - <_ScribeSaLine Include="<Project>" /> - <_ScribeSaLine Include=" <!-- Auto-generated by Scribe SolutionAnalyzer — do not edit. Rebuild the analyzer to refresh. -->" /> - <_ScribeSaLine Include=" <ItemGroup>" /> - <_ScribeSaLine Include=" <PackageVersion Update="$(PackageId)" Version="$(_ScribeSaVersion)" />" /> - <_ScribeSaLine Include=" </ItemGroup>" /> - <_ScribeSaLine Include="</Project>" /> - - - - - - - diff --git a/docs/architecture-infrastructure.md b/docs/architecture-infrastructure.md index b973bc1..18189b1 100644 --- a/docs/architecture-infrastructure.md +++ b/docs/architecture-infrastructure.md @@ -113,7 +113,7 @@ Solution-Local Analyzer and LocalDev use the same underlying mechanism but at di | Concern | LocalDev | Solution-Local Analyzer | | --------- | ---------- | ------------------------ | | Scope | Cross-repo | Intra-solution | -| Trigger | `.localscribe` sentinel | `ScribeSolutionAnalyzer=true` | +| Trigger | `..user` or `..local` sentinel (e.g. `.scribe.user`, `.hermetic.local`) | `ScribeSolutionAnalyzer=true` | | Version | NBGV + timestamp suffix | Timestamp-only (`0.0.0-dev.yyyyMMddHHmmss`) | | Override files | Generated `.Directory.Packages.targets` | Same — generated `.Directory.Packages.targets` | | Package directory | Shared `$(ScribeRoot)/.artifacts/packages/` | Solution `$(ArtifactsPath)packages/` | @@ -169,23 +169,49 @@ All subsequent property groups and imports are conditioned on `'$(_ScribeLocalDe ### Sentinel File Detection -Activates Local Scribe mode when a `.localscribe` file exists in `$(ScribeRoot)` (the shared workspace root). This works in both Visual Studio and CLI builds without build configuration hacks. +Two tiers of activation — one per-producer, one umbrella: + +**Per-producer (`$(IsLocalProducer)`):** each producer declares `$(ScribesName)` in its Directory.Build.props (e.g. `Scribe`, `Hermetic`). EITHER `.$(ScribesName).user` OR `.$(ScribesName).local` (lowercased, e.g. `.hermetic.user` / `.hermetic.local`) at `$(ScribeRoot)` flips `$(IsLocalProducer)` on for that project. Producer mode gates auto-pack, timestamped versions, and override-file emission. + +```xml + + <_ScribeProducerBase>$(ScribeRoot)\.$(ScribesName.ToLowerInvariant()) + + + true + +``` + +**Umbrella (`$(IsLocalScribe)`):** ANY `.*.user` or `.*.local` sentinel at `$(ScribeRoot)` flips `$(IsLocalScribe)` on. The umbrella flag governs consumer-side infrastructure: NuGet source registration and override-file imports. ```xml - + + <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.user" /> + <_ScribeLocalSentinel Include="$(ScribeRoot)\.*.local" /> + + + + <_ScribeLocalSentinelList>@(_ScribeLocalSentinel) + + true ``` -Three activation methods: +Three activation methods (umbrella): + +1. **Sentinel file (recommended):** Create `..user` OR `..local` in `$(ScribeRoot)` — e.g. `.scribe.user` to build Scribe locally, `.hermetic.local` to build Hermetic locally. Either form works. Delete to deactivate. Add to `.gitignore`. + + **Why two forms?** `.user` is the .NET convention — `*.user` is in the standard [VisualStudio.gitignore](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore), so it won't be accidentally committed. `.local` reads more naturally and matches the JS ecosystem (Vite, Next.js). Both are accepted rather than forcing a choice between idioms. -1. **Sentinel file (recommended):** Create `.localscribe` in `$(ScribeRoot)`. Delete it to deactivate. Add `.localscribe` to `.gitignore`. -2. **Explicit property:** `-p:IsLocalScribe=true` on the command line. +2. **Explicit property:** `-p:IsLocalScribe=true` on the command line — consumer infra only. Does not activate any specific producer; pair with a `-p:IsLocalProducer=true` or a sentinel. 3. **Props file:** `true` in Directory.Build.props or Directory.Solution.props. +**Typo tolerance:** `$(ScribeName)` (non-possessive) forwards to `$(ScribesName)` if only the former is set. + ### Path Setup When active, derives the shared artifacts and packages directories from `$(ScribeRoot)`: @@ -200,7 +226,7 @@ When active, derives the shared artifacts and packages directories from `$(Scrib ### Auto-Pack -For the trigger project only, enables `GeneratePackageOnBuild` and redirects output to the shared packages directory: +For the trigger project only — identified by `$(MSBuildProjectName) == $(ScribesName)` — and only when `$(IsLocalProducer) == true`, enables `GeneratePackageOnBuild` and redirects output to the shared packages directory: ```xml true @@ -229,7 +255,14 @@ Uses a wildcard import to pick up all override files from the shared artifacts d ### Override File Generation -The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project only. It generates a `.Directory.Packages.targets` file containing `PackageVersion Update` entries: +The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project only, gated on `$(IsLocalProducer) == true`. The file is named after `$(ScribesName)` so a consumer can load it by string-concat: + +```csharp +$(ScribeArtifactsDir)$(ScribesName).Directory.Packages.targets +// e.g. .artifacts/Hermetic.Directory.Packages.targets +``` + +Contents: ```xml @@ -242,6 +275,15 @@ The `_ScribeLocalDevOverride` target runs after `Pack` on the trigger project on The version is read from `$(NuGetPackageVersion)` (set by Nerdbank.GitVersioning or the SDK). +### Cleanup on Sentinel Removal + +The `_ScribeLocalDevCleanupStale` target runs `BeforeTargets="Build"` on the trigger project when `$(IsLocalProducer) != true` and `$(ScribesName) != ''`. It deletes: + +- The stale `$(ScribesName).Directory.Packages.targets` override file (if present). +- Stale `-dev.*` packages matching each ID in `$(ScribeLocalDevPackageNames)` (`.nupkg` and `.snupkg`). + +Cleanup is scoped to this producer's own artifacts only — other producers' files are untouched. Removing a producer's `..user` / `..local` sentinel and rebuilding the producer normally is enough to clean its dev packages out of the shared artifacts folder. + --- ## Multi-Repo Chain diff --git a/docs/project-setup.md b/docs/project-setup.md index c6d2e29..e7fe5c1 100644 --- a/docs/project-setup.md +++ b/docs/project-setup.md @@ -262,16 +262,24 @@ In the consumer's `Directory.Build.props`, point `ScribeRoot` to the shared work #### 2. Configure the producer -In the producer's `Directory.Build.props`, set the trigger project and package names *before* importing Scribe's LocalDev files: +In the producer's `Directory.Build.props`, set the producer name and package IDs *before* importing Scribe's LocalDev files: ```xml $(MSBuildThisFileDirectory).. - MyFramework + MyFramework MyFramework ``` +`$(ScribesName)` drives three things: + +- **Sentinel detection:** matches `.$(ScribesName).user` OR `.$(ScribesName).local` (lowercased, e.g. `.myframework.user` or `.myframework.local`) at `$(ScribeRoot)`. Either form activates — pick whichever you prefer. +- **Auto-pack trigger:** the MSBuild project whose name equals `$(ScribesName)` is the one that packs. +- **Override file name:** generated as `$(ScribesName).Directory.Packages.targets`. + +`$(ScribeName)` (non-possessive) is accepted as a typo-tolerant alias. + #### 3. Import the LocalDev files If you consume Scribe via NuGet, the import happens automatically (NuGet auto-imports `build/BulletsForHumanity.Scribe.props` and `.targets`). @@ -290,17 +298,23 @@ For direct import from a sibling checkout: #### 4. Activate -Three ways to activate LocalDev: +Activation is **per-producer**: -1. **Sentinel file (recommended):** Create a `.localscribe` file in `$(ScribeRoot)` (the shared workspace root). Delete it to deactivate. Add `.localscribe` to `.gitignore`. -2. **MSBuild property:** `dotnet build -p:IsLocalScribe=true` +1. **Sentinel file (recommended):** Create EITHER `.$(ScribesName).user` OR `.$(ScribesName).local` at `$(ScribeRoot)` — for example `.myframework.user` or `.myframework.local`. Either form works; pick whichever you prefer. Delete to deactivate. Multiple producers can be activated independently (e.g. `.scribe.user` + `.hermetic.local`). Already `.gitignore`d via the `*.user` and `*.local` patterns. + + **Why two forms?** `.user` is the long-standing .NET convention — `*.user` is already in the standard [VisualStudio.gitignore](https://github.com/github/gitignore/blob/main/VisualStudio.gitignore) so it won't be accidentally committed in any .NET repo. `.local` reads more naturally ("myframework, local variant") and matches the convention used by the JS ecosystem (Vite, Next.js, etc.). Rather than force a choice between "idiomatic .NET" and "idiomatic elsewhere," both are accepted. + +2. **MSBuild property:** `dotnet build -p:IsLocalScribe=true` — activates consumer infra only; pair with a sentinel or `-p:IsLocalProducer=true` to enable a producer. 3. **Props file:** Set `true` in `Directory.Build.props` or `Directory.Solution.props`. +Building a producer **without** its sentinel triggers automatic cleanup: the producer's stale `-dev.*` packages and its override file are deleted from `.artifacts/`. + ### Provided Properties | Property | Value when active | | ---------- | ------------------- | -| `$(IsLocalScribe)` | `true` | +| `$(IsLocalScribe)` | `true` when *any* `.*.user` or `.*.local` sentinel exists (umbrella — consumer infra) | +| `$(IsLocalProducer)` | `true` when `.$(ScribesName).user` or `.$(ScribesName).local` exists (per-producer — pack + overrides) | | `$(ScribeArtifactsDir)` | `$(ScribeRoot)\.artifacts\` | | `$(ScribePackagesDir)` | `$(ScribeArtifactsDir)packages\` | @@ -309,9 +323,8 @@ Three ways to activate LocalDev: | Property | Set by | Purpose | | ---------- | -------- | --------- | | `$(ScribeRoot)` | Consumer | Shared workspace root | -| `$(ScribeLocalDevTriggerProject)` | Producer | MSBuild project name that triggers auto-pack and override generation | +| `$(ScribesName)` | Producer | Producer name — drives sentinel detection, auto-pack trigger, and override file name. `$(ScribeName)` is accepted as a typo alias. | | `$(ScribeLocalDevPackageNames)` | Producer | Semicolon-separated NuGet package IDs to include in the override file | -| `$(ScribeLocalDevOverrideName)` | Producer (optional) | Override file name prefix — defaults to `$(ScribeLocalDevTriggerProject)` | ### Chaining diff --git a/docs/solution-local-analyzers.md b/docs/solution-local-analyzers.md deleted file mode 100644 index 9f8f1f2..0000000 --- a/docs/solution-local-analyzers.md +++ /dev/null @@ -1,260 +0,0 @@ -# Solution-Local Analyzers - -How to set up Roslyn analyzers and source generators that live inside the same solution they serve — no NuGet publishing, no cross-repo setup. - ---- - -## Overview - -A **solution-local analyzer** is a Roslyn analyzer or source generator project that exists inside the same solution as the projects it serves. It is not published to NuGet. It is not consumed cross-repo. It automates *this* codebase and nothing else. - -Examples: - -- A generator that reads domain model types and emits mapping code, DTOs, or validation rules -- An analyzer that enforces solution-specific coding conventions -- A generator that emits strongly-typed configuration accessors from appsettings schema - -The Scribe SDK provides first-class support for this workflow. When `ScribeSolutionAnalyzer=true` is set on a Scribe SDK project, the SDK automatically: - -1. **Packs the analyzer on every build** — produces a `.nupkg` in the solution's `artifacts/packages/` directory -2. **Generates a unique timestamp version** (`0.0.0-dev.yyyyMMddHHmmss`) — forces Visual Studio to reload the analyzer on every build -3. **Generates a version override file** — consuming projects automatically resolve the locally-built package via the same mechanism as [LocalDev](project-setup.md#local-development-localdev) -4. **Bundles private dependencies** — the same dependency bundling as any Scribe SDK project - -Consuming projects reference the analyzer via standard ``, getting all the same analyzer wiring that any published package gets. - ---- - -## Quick Start - -### 1. Create the analyzer project - -```xml - - - true - - - - - - -``` - -### 2. Configure the solution for local packages - -The solution must use artifact output. In `Directory.Build.props`: - -```xml - - true - $(MSBuildThisFileDirectory).artifacts/ - - - - - - $(RestoreAdditionalProjectSources);$(ArtifactsPath)packages - - -``` - -In `Directory.Build.targets`, import override files generated by solution-local analyzers: - -```xml - -``` - -### 3. Reference from consuming projects - -Add the analyzer to your `Directory.Packages.props` (any version — the override file pins the actual version): - -```xml - - -``` - -Then reference it from consuming projects: - -```xml - - - -``` - -### 4. Build - -```shell -dotnet build MySolution.sln -``` - -The analyzer auto-packs on build. The version override file is generated automatically. Consuming projects resolve the package from the artifacts directory. Diagnostics and generated code appear automatically. - ---- - -## How It Works - -### Auto-Pack on Build - -When `ScribeSolutionAnalyzer=true`, the SDK sets `GeneratePackageOnBuild=true` and redirects `PackageOutputPath` to `$(ArtifactsPath)packages/`. Every build produces a fresh `.nupkg`. - -### Timestamp Version - -Each build produces a unique version: `0.0.0-dev.yyyyMMddHHmmss` (e.g. `0.0.0-dev.20260406054112`). This serves two purposes: - -1. **Forces Visual Studio to reload** — VS detects a new analyzer version and reloads the analyzer assembly, picking up changes immediately -2. **Eliminates NuGet cache issues** — each version is unique, so NuGet never serves stale cached content - -### Version Override File - -After each pack, the SDK generates a `.Directory.Packages.targets` file in the artifacts directory: - -```xml - - - - - - -``` - -This is the same mechanism as [LocalDev](project-setup.md#local-development-localdev). Consuming projects import the file via wildcard from `$(ArtifactsPath)`, which updates the `PackageVersion` to match the freshly-built package. - -### Package Directory - -Packages are placed in `$(ArtifactsPath)packages/` — the standard artifacts packages directory. This requires the solution to use artifact output (`UseArtifactsOutput=true`), which is the recommended setup for modern .NET projects. - ---- - -## Solution Layout - -A typical solution with a solution-local analyzer: - -```text -MySolution/ - MySolution.sln - Directory.Build.props ← UseArtifactsOutput, RestoreAdditionalProjectSources - Directory.Build.targets ← Import override files from .artifacts/ - Directory.Packages.props ← PackageVersion Include for MyAnalyzer - .gitignore ← includes .artifacts/ - .artifacts/ ← auto-generated, git-ignored - packages/ - MyAnalyzer.0.0.0-dev.20260406054112.nupkg - MyAnalyzer.Directory.Packages.targets ← version override file - MyAnalyzer/ - MyAnalyzer.csproj ← ScribeSolutionAnalyzer=true - MyGenerator.cs - MyApp/ - MyApp.csproj ← - Program.cs -``` - ---- - -## Multiple Analyzers - -A solution can have multiple solution-local analyzers. Each generates its own override file in the artifacts directory: - -```xml - - - - true - - ... - -``` - -```xml - - - - true - - ... - -``` - -The wildcard import in `Directory.Build.targets` picks up all override files: - -```csharp -.artifacts/ - FirstAnalyzer.Directory.Packages.targets - SecondAnalyzer.Directory.Packages.targets - packages/ - FirstAnalyzer.0.0.0-dev.20260406054112.nupkg - SecondAnalyzer.0.0.0-dev.20260406054112.nupkg -``` - ---- - -## Relationship to LocalDev - -Solution-local analyzers use the **same mechanism** as LocalDev (version override files + NuGet source registration) but scoped to a single solution: - -| Concern | LocalDev | Solution-Local Analyzer | -| --------- | ---------- | ------------------------ | -| Scope | Cross-repo (e.g. Scribe → Hermetic → MyApp) | Intra-solution | -| Trigger | `.localscribe` sentinel file | `ScribeSolutionAnalyzer=true` property | -| Published? | Yes (eventually to NuGet) | Never | -| Version management | NBGV + timestamp + override files | Timestamp-only + override files | -| Package directory | Shared `$(ScribeRoot)/.artifacts/packages/` | Solution `$(ArtifactsPath)packages/` | -| Override file location | `$(ScribeRoot)/.artifacts/` | `$(ArtifactsPath)` | -| Setup complexity | `$(ScribeRoot)`, trigger project, package names | One property + consumer-side imports | - -Both features are independent and can coexist. A solution can have solution-local analyzers and also participate in a LocalDev chain. - ---- - -## Provided Properties - -| Property | Value | Set by | -| ---------- | ------- | -------- | -| `$(ScribeSolutionAnalyzer)` | `true` | Developer (in `.csproj`) | -| `$(Version)` | `0.0.0-dev.yyyyMMddHHmmss` | SDK (timestamp-based) | -| `$(PackageVersion)` | `0.0.0-dev.yyyyMMddHHmmss` | SDK (same as Version) | -| `$(GeneratePackageOnBuild)` | `true` | SDK | -| `$(PackageOutputPath)` | `$(ArtifactsPath)packages\` | SDK | - ---- - -## Design Decisions - -| Decision | Choice | Rationale | -| ---------- | -------- | ----------- | -| Detection mechanism | Explicit `ScribeSolutionAnalyzer=true` property | Simple, discoverable, no magic. Convention-based detection would be ambiguous. | -| Version strategy | Timestamp-based `0.0.0-dev.yyyyMMddHHmmss` | Unique version per build forces Visual Studio to reload analyzers. Eliminates NuGet cache issues. | -| Package directory | `$(ArtifactsPath)packages/` | Uses the solution's existing artifact output directory. No separate `.packages/` directory needed. | -| Override files | Same mechanism as LocalDev | Proven pattern — `PackageVersion Update` entries in auto-generated `.Directory.Packages.targets` files, imported via wildcard. | -| Consumer-side setup | Manual one-time setup (`Directory.Build.props` + `.targets`) | Same pattern as LocalDev. All projects in the solution share the same `Directory.Build.*` files, so one-time setup covers everything. | - ---- - -## Troubleshooting - -### Consuming project doesn't see the analyzer - -1. Verify `$(ArtifactsPath)packages/` contains a `.nupkg` for the analyzer -2. Verify `Directory.Build.props` registers `$(ArtifactsPath)packages` in `RestoreAdditionalProjectSources` -3. Verify `Directory.Build.targets` imports `$(ArtifactsPath)*.Directory.Packages.targets` -4. Run `dotnet restore` on the consuming project -5. In Visual Studio, try **Build → Rebuild Solution** - -### Stale generated code after analyzer changes - -Each build produces a unique timestamp version, which forces Visual Studio to reload the analyzer. If you still see stale output: - -1. Close and reopen the solution in Visual Studio -2. Run `dotnet restore && dotnet build` - -### ArtifactsPath is not set - -The solution must use artifact output. Add to `Directory.Build.props`: - -```xml - - true - $(MSBuildThisFileDirectory).artifacts/ - -```