From b4134b31e6da1a422f4ed57b60610cd768d43a78 Mon Sep 17 00:00:00 2001 From: Max Obrist Date: Mon, 13 Apr 2026 02:38:47 +0700 Subject: [PATCH] feat(hermtic): fix some edge cases and cleanup bootstrapping after new sdk release --- Directory.Build.props | 12 +- Scribe.Ink/Scribe.Ink.csproj | 20 +- Scribe.Ink/Shapes/FixResolver.cs | 32 ++ Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs | 61 +++ .../Fixes/AddParameterlessConstructorFix.cs | 48 ++ .../Shapes/Fixes/AddStaticModifierFix.cs | 47 ++ .../Shapes/Fixes/RemoveAbstractModifierFix.cs | 35 ++ Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs | 94 ++++ .../Shapes/Fixes/RemoveFromBaseListFix.cs | 81 +++ .../Shapes/Fixes/RemovePartialModifierFix.cs | 35 ++ .../Shapes/Fixes/RemoveSealedModifierFix.cs | 35 ++ .../Shapes/Fixes/RemoveStaticModifierFix.cs | 35 ++ Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs | 127 +++++ Scribe.Ink/Shapes/ShapeCodeFixProvider.cs | 12 +- Scribe.Ink/Shapes/ShapeFixAllProvider.cs | 207 ++++++++ Scribe.Scriptorium/Scribe.Scriptorium.csproj | 39 +- Scribe.Sdk/Scribe.Sdk.csproj | 2 + Scribe.Sdk/Sdk/Phases/Analyzer.targets | 7 + Scribe.Sdk/Sdk/Phases/Companion.targets | 2 +- Scribe.Sdk/Sdk/Phases/Meta.targets | 31 +- .../Shapes/ShapeFixAllProviderTests.cs | 137 +++++ .../Shapes/ShapePhase8PrimitivesTests.cs | 315 +++++++++++ Scribe.Tests/Shapes/ShapePhase8_5Tests.cs | 491 ++++++++++++++++++ Scribe/Scribe.csproj | 28 +- Scribe/Shapes/FixSpec.cs | 18 + Scribe/Shapes/ShapeBuilder.cs | 411 +++++++++++++++ Scribe/Stubs.cs | 266 ---------- Scribe/build/BulletsForHumanity.Scribe.props | 4 - .../build/BulletsForHumanity.Scribe.targets | 4 - docs/project-setup.md | 2 + global.json | 3 + 31 files changed, 2291 insertions(+), 350 deletions(-) create mode 100644 Scribe.Ink/Shapes/FixResolver.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs create mode 100644 Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs create mode 100644 Scribe.Ink/Shapes/ShapeFixAllProvider.cs create mode 100644 Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs create mode 100644 Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs create mode 100644 Scribe.Tests/Shapes/ShapePhase8_5Tests.cs delete mode 100644 Scribe/Stubs.cs delete mode 100644 Scribe/build/BulletsForHumanity.Scribe.props delete mode 100644 Scribe/build/BulletsForHumanity.Scribe.targets diff --git a/Directory.Build.props b/Directory.Build.props index d6c6975..dd819b1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,6 +12,10 @@ true false + net10.0 + preview + enable + true @@ -35,16 +39,14 @@ $(MSBuildThisFileDirectory).artifacts/ - + - net10.0 - preview enable - enable nullable Recommended true - true true $(NoWarn);NU1507;CS1591 diff --git a/Scribe.Ink/Scribe.Ink.csproj b/Scribe.Ink/Scribe.Ink.csproj index 986d483..843fd13 100644 --- a/Scribe.Ink/Scribe.Ink.csproj +++ b/Scribe.Ink/Scribe.Ink.csproj @@ -1,26 +1,14 @@ - - + - netstandard2.0 - 14 - true - false - Analyzer - false - embedded - false - true + Analyzer + enable - $(NoWarn);NU5128;RS1038;RS2008 + $(NoWarn);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/FixResolver.cs b/Scribe.Ink/Shapes/FixResolver.cs new file mode 100644 index 0000000..f60a1a1 --- /dev/null +++ b/Scribe.Ink/Shapes/FixResolver.cs @@ -0,0 +1,32 @@ +using Scribe.Ink.Shapes.Fixes; +using Scribe.Shapes; + +namespace Scribe.Ink.Shapes; + +/// +/// Maps a to its concrete +/// implementation. Shared between and +/// . +/// +internal static class FixResolver +{ + public static IShapeFix? Resolve(FixKind kind) => kind switch + { + FixKind.AddPartialModifier => new AddPartialModifierFix(), + FixKind.AddSealedModifier => new AddSealedModifierFix(), + FixKind.AddInterfaceToBaseList => new AddInterfaceToBaseListFix(), + FixKind.AddAttribute => new AddAttributeFix(), + FixKind.AddAbstractModifier => new AddAbstractModifierFix(), + FixKind.RemoveAbstractModifier => new RemoveAbstractModifierFix(), + FixKind.AddStaticModifier => new AddStaticModifierFix(), + FixKind.AddBaseClass => new AddBaseClassFix(), + FixKind.RemoveFromBaseList => new RemoveFromBaseListFix(), + FixKind.RemovePartialModifier => new RemovePartialModifierFix(), + FixKind.RemoveSealedModifier => new RemoveSealedModifierFix(), + FixKind.RemoveStaticModifier => new RemoveStaticModifierFix(), + FixKind.RemoveAttribute => new RemoveAttributeFix(), + FixKind.SetVisibility => new SetVisibilityFix(), + FixKind.AddParameterlessConstructor => new AddParameterlessConstructorFix(), + _ => null, + }; +} diff --git a/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs b/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs new file mode 100644 index 0000000..4155393 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs @@ -0,0 +1,61 @@ +using System.Linq; +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 AddBaseClassFix : IShapeFix +{ + public string Title(Diagnostic diagnostic) + { + var name = BaseClassName(diagnostic); + return name is null ? "Add required base class" : $"Extend '{name}'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + var baseClassName = BaseClassName(diagnostic); + if (baseClassName is null) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var baseType = SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(baseClassName)); + + // Base class must come first in C#'s base list. + TypeDeclarationSyntax newTypeDecl; + if (typeDecl.BaseList is null) + { + newTypeDecl = typeDecl.WithBaseList(SyntaxFactory.BaseList( + SyntaxFactory.SingletonSeparatedList(baseType))); + } + else + { + var existing = typeDecl.BaseList.Types; + var withBase = existing.Insert(0, baseType); + newTypeDecl = typeDecl.WithBaseList(typeDecl.BaseList.WithTypes(withBase)); + } + + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + private static string? BaseClassName(Diagnostic diagnostic) + { + return diagnostic.Properties.TryGetValue("baseClass", out var name) && !string.IsNullOrEmpty(name) + ? name + : null; + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs b/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs new file mode 100644 index 0000000..47ebc63 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs @@ -0,0 +1,48 @@ +using System.Linq; +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 AddParameterlessConstructorFix : IShapeFix +{ + public string Title(Diagnostic _) => "Add public parameterless constructor"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var name = typeDecl.Identifier.ValueText; + + var ctor = SyntaxFactory.ConstructorDeclaration(name) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithParameterList(SyntaxFactory.ParameterList()) + .WithBody(SyntaxFactory.Block()); + + var insertAt = 0; + for (var i = 0; i < typeDecl.Members.Count; i++) + { + if (typeDecl.Members[i] is FieldDeclarationSyntax) + { + insertAt = i + 1; + continue; + } + break; + } + + var newTypeDecl = typeDecl.WithMembers(typeDecl.Members.Insert(insertAt, ctor)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs b/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs new file mode 100644 index 0000000..e47e7e4 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.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 AddStaticModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Add 'static' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + if (typeDecl.Modifiers.Any(SyntaxKind.StaticKeyword)) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var staticToken = SyntaxFactory.Token(SyntaxKind.StaticKeyword) + .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, staticToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs new file mode 100644 index 0000000..e844dc7 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs @@ -0,0 +1,35 @@ +using System.Linq; +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 RemoveAbstractModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Remove 'abstract' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + var abstractToken = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.AbstractKeyword)); + if (abstractToken == default) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(abstractToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs new file mode 100644 index 0000000..ffc30d5 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +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 RemoveAttributeFix : IShapeFix +{ + public string Title(Diagnostic diagnostic) + { + var name = AttributeName(diagnostic); + return name is null ? "Remove forbidden attribute" : $"Remove '[{Shorten(name)}]'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + var target = AttributeName(diagnostic); + if (target is null) + { + return document; + } + + var shortName = Shorten(target); + var fullName = target; + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var newTypeDecl = typeDecl; + + foreach (var list in typeDecl.AttributeLists) + { + var keepAttrs = list.Attributes + .Where(a => !Matches(a, fullName, shortName)) + .ToArray(); + + if (keepAttrs.Length == list.Attributes.Count) + { + continue; + } + + if (keepAttrs.Length == 0) + { + newTypeDecl = newTypeDecl.WithAttributeLists( + newTypeDecl.AttributeLists.Remove(list)); + } + else + { + var newList = list.WithAttributes(SyntaxFactory.SeparatedList(keepAttrs)); + newTypeDecl = newTypeDecl.WithAttributeLists( + newTypeDecl.AttributeLists.Replace(list, newList)); + } + } + + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + private static bool Matches(AttributeSyntax attribute, string full, string shortName) + { + var text = attribute.Name.ToString(); + var bareShort = Shorten(shortName); + return string.Equals(text, full, StringComparison.Ordinal) + || string.Equals(text, shortName, StringComparison.Ordinal) + || string.Equals(text, bareShort, StringComparison.Ordinal) + || text.EndsWith("." + shortName, StringComparison.Ordinal) + || text.EndsWith("." + bareShort, StringComparison.Ordinal); + } + + private static string? AttributeName(Diagnostic diagnostic) + { + return diagnostic.Properties.TryGetValue("attribute", out var name) && !string.IsNullOrEmpty(name) + ? name + : null; + } + + private static string Shorten(string name) + { + const string Suffix = "Attribute"; + return name.EndsWith(Suffix, StringComparison.Ordinal) + ? name.Substring(0, name.Length - Suffix.Length) + : name; + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs new file mode 100644 index 0000000..23a1135 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemoveFromBaseListFix.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scribe.Ink.Shapes.Fixes; + +internal sealed class RemoveFromBaseListFix : IShapeFix +{ + public string Title(Diagnostic diagnostic) + { + var name = TargetName(diagnostic); + return name is null ? "Remove forbidden base type" : $"Remove '{name}'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + if (typeDecl.BaseList is null) + { + return document; + } + + var target = TargetName(diagnostic); + if (target is null) + { + return document; + } + + var simpleName = ShortName(target); + var match = typeDecl.BaseList.Types.FirstOrDefault(t => MatchesTypeName(t, target, simpleName)); + if (match is null) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var remaining = typeDecl.BaseList.Types.Remove(match); + TypeDeclarationSyntax newTypeDecl = remaining.Count == 0 + ? typeDecl.WithBaseList(null) + : typeDecl.WithBaseList(typeDecl.BaseList.WithTypes(remaining)); + + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + private static string? TargetName(Diagnostic diagnostic) + { + if (diagnostic.Properties.TryGetValue("interface", out var i) && !string.IsNullOrEmpty(i)) + { + return i; + } + + return diagnostic.Properties.TryGetValue("baseClass", out var b) && !string.IsNullOrEmpty(b) + ? b + : null; + } + + private static string ShortName(string metadataName) + { + var lastDot = metadataName.LastIndexOf('.'); + return lastDot < 0 ? metadataName : metadataName.Substring(lastDot + 1); + } + + private static bool MatchesTypeName(BaseTypeSyntax baseType, string full, string simple) + { + var text = baseType.Type.ToString(); + return string.Equals(text, full, StringComparison.Ordinal) + || string.Equals(text, simple, StringComparison.Ordinal) + || text.EndsWith("." + simple, StringComparison.Ordinal); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs new file mode 100644 index 0000000..b3ab4f3 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemovePartialModifierFix.cs @@ -0,0 +1,35 @@ +using System.Linq; +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 RemovePartialModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Remove 'partial' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.PartialKeyword)); + if (token == default) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs new file mode 100644 index 0000000..9ae1c65 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemoveSealedModifierFix.cs @@ -0,0 +1,35 @@ +using System.Linq; +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 RemoveSealedModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Remove 'sealed' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.SealedKeyword)); + if (token == default) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs b/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs new file mode 100644 index 0000000..5674f0d --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/RemoveStaticModifierFix.cs @@ -0,0 +1,35 @@ +using System.Linq; +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 RemoveStaticModifierFix : IShapeFix +{ + public string Title(Diagnostic _) => "Remove 'static' modifier"; + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic _, + CancellationToken ct) + { + var token = typeDecl.Modifiers.FirstOrDefault(m => m.IsKind(SyntaxKind.StaticKeyword)); + if (token == default) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var newTypeDecl = typeDecl.WithModifiers(typeDecl.Modifiers.Remove(token)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } +} diff --git a/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs b/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs new file mode 100644 index 0000000..dd25c32 --- /dev/null +++ b/Scribe.Ink/Shapes/Fixes/SetVisibilityFix.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 SetVisibilityFix : IShapeFix +{ + private static readonly SyntaxKind[] VisibilityKinds = + { + SyntaxKind.PublicKeyword, + SyntaxKind.InternalKeyword, + SyntaxKind.PrivateKeyword, + SyntaxKind.ProtectedKeyword, + }; + + public string Title(Diagnostic diagnostic) + { + var target = Target(diagnostic); + return target is null ? "Change visibility" : $"Make '{target}'"; + } + + public async Task FixAsync( + Document document, + TypeDeclarationSyntax typeDecl, + Diagnostic diagnostic, + CancellationToken ct) + { + var target = Target(diagnostic); + if (target is null) + { + return document; + } + + if (!TryGetKeywordKind(target, out var targetKind)) + { + return document; + } + + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var modifiers = typeDecl.Modifiers; + + var kept = new List(modifiers.Count); + var firstVisibilityTrivia = default(SyntaxTriviaList); + var replaced = false; + foreach (var token in modifiers) + { + if (IsVisibilityKind(token.Kind())) + { + if (!replaced) + { + firstVisibilityTrivia = token.LeadingTrivia; + replaced = true; + } + continue; + } + kept.Add(token); + } + + var newToken = SyntaxFactory.Token(targetKind) + .WithTrailingTrivia(SyntaxFactory.Space); + + if (replaced) + { + newToken = newToken.WithLeadingTrivia(firstVisibilityTrivia); + kept.Insert(0, newToken); + } + else + { + var existingLeading = typeDecl.GetLeadingTrivia(); + newToken = newToken.WithLeadingTrivia(existingLeading); + var newTypeDecl = typeDecl + .WithoutLeadingTrivia() + .WithModifiers(modifiers.Insert(0, newToken)); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, newTypeDecl)); + } + + var newModifiers = SyntaxFactory.TokenList(kept); + var updated = typeDecl.WithModifiers(newModifiers); + return document.WithSyntaxRoot(root.ReplaceNode(typeDecl, updated)); + } + + private static bool IsVisibilityKind(SyntaxKind kind) + { + foreach (var k in VisibilityKinds) + { + if (k == kind) return true; + } + return false; + } + + private static bool TryGetKeywordKind(string visibility, out SyntaxKind kind) + { + switch (visibility) + { + case "public": + kind = SyntaxKind.PublicKeyword; + return true; + case "internal": + kind = SyntaxKind.InternalKeyword; + return true; + case "private": + kind = SyntaxKind.PrivateKeyword; + return true; + default: + kind = default; + return false; + } + } + + private static string? Target(Diagnostic diagnostic) + { + return diagnostic.Properties.TryGetValue("visibility", out var v) && !string.IsNullOrEmpty(v) + ? v + : null; + } +} diff --git a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs index ca00f35..eb11c76 100644 --- a/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs +++ b/Scribe.Ink/Shapes/ShapeCodeFixProvider.cs @@ -24,7 +24,7 @@ internal sealed class ShapeCodeFixProvider : CodeFixProvider public override ImmutableArray FixableDiagnosticIds => _ids; - public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + public override FixAllProvider? GetFixAllProvider() => ShapeFixAllProvider.Instance; public override async Task RegisterCodeFixesAsync(CodeFixContext context) { @@ -45,7 +45,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) continue; } - var fix = ResolveFix(fixKind); + var fix = FixResolver.Resolve(fixKind); if (fix is null) { continue; @@ -67,12 +67,4 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) } } - 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/ShapeFixAllProvider.cs b/Scribe.Ink/Shapes/ShapeFixAllProvider.cs new file mode 100644 index 0000000..04f5b13 --- /dev/null +++ b/Scribe.Ink/Shapes/ShapeFixAllProvider.cs @@ -0,0 +1,207 @@ +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.Syntax; +using Scribe.Ink.Shapes.Fixes; +using Scribe.Shapes; + +namespace Scribe.Ink.Shapes; + +/// +/// Custom that chains Shape fixes targeting the +/// same type declaration. The built-in +/// applies each fix to the original document and merges the results, which +/// fails when multiple diagnostics touch the same node. This provider applies +/// fixes sequentially per-type using a to +/// re-locate the type declaration across edits. +/// +internal sealed class ShapeFixAllProvider : FixAllProvider +{ + public static readonly ShapeFixAllProvider Instance = new(); + + private ShapeFixAllProvider() { } + + public override IEnumerable GetSupportedFixAllScopes() + => new[] { FixAllScope.Document, FixAllScope.Project, FixAllScope.Solution }; + + public override Task GetFixAsync(FixAllContext context) + { + var title = context.Scope switch + { + FixAllScope.Document => "Apply Shape fixes (document)", + FixAllScope.Project => "Apply Shape fixes (project)", + FixAllScope.Solution => "Apply Shape fixes (solution)", + _ => "Apply Shape fixes", + }; + + return Task.FromResult(CodeAction.Create( + title: title, + createChangedSolution: ct => FixAllAsync(context, ct), + equivalenceKey: "Shape.FixAll." + context.Scope)); + } + + private static async Task FixAllAsync(FixAllContext context, CancellationToken ct) + { + var solution = context.Solution; + + var documents = CollectDocuments(context, ct); + + foreach (var document in documents) + { + ct.ThrowIfCancellationRequested(); + var diagnostics = await context.GetDocumentDiagnosticsAsync(document).ConfigureAwait(false); + if (diagnostics.IsDefaultOrEmpty) + { + continue; + } + + var updated = await FixDocumentAsync(document, diagnostics, ct).ConfigureAwait(false); + if (updated is null) + { + continue; + } + + solution = updated.Project.Solution; + } + + return solution; + } + + private static IReadOnlyList CollectDocuments( + FixAllContext context, CancellationToken ct) + { + switch (context.Scope) + { + case FixAllScope.Document when context.Document is not null: + return new[] { context.Document }; + + case FixAllScope.Project when context.Project is not null: + return context.Project.Documents.ToArray(); + + case FixAllScope.Solution: + var all = new List(); + foreach (var project in context.Solution.Projects) + { + ct.ThrowIfCancellationRequested(); + all.AddRange(project.Documents); + } + return all; + + default: + return Array.Empty(); + } + } + + private static async Task FixDocumentAsync( + Document document, + ImmutableArray diagnostics, + CancellationToken ct) + { + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (root is null) + { + return null; + } + + // Group diagnostics by the type declaration they target, then annotate + // each type so we can re-locate it after every edit. + var perType = new Dictionary>(); + foreach (var diagnostic in diagnostics) + { + ct.ThrowIfCancellationRequested(); + var node = root.FindNode(diagnostic.Location.SourceSpan); + var typeDecl = node.AncestorsAndSelf().OfType().FirstOrDefault(); + if (typeDecl is null) + { + continue; + } + + if (!perType.TryGetValue(typeDecl, out var list)) + { + list = new List(); + perType[typeDecl] = list; + } + list.Add(diagnostic); + } + + if (perType.Count == 0) + { + return null; + } + + var annotations = new Dictionary>(); + var perTypeAnnotations = new Dictionary(); + foreach (var entry in perType) + { + var annotation = new SyntaxAnnotation("Scribe.Shape.FixAll", Guid.NewGuid().ToString("N")); + annotations[annotation] = entry.Value; + perTypeAnnotations[entry.Key] = annotation; + } + + var annotated = root.ReplaceNodes( + perType.Keys, + (original, _) => original.WithAdditionalAnnotations(perTypeAnnotations[original])); + + var workingDoc = document.WithSyntaxRoot(annotated); + + foreach (var pair in annotations) + { + var annotation = pair.Key; + foreach (var diagnostic in pair.Value) + { + ct.ThrowIfCancellationRequested(); + + if (!TryResolveFix(diagnostic, out var fix)) + { + continue; + } + + var currentRoot = await workingDoc.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (currentRoot is null) + { + continue; + } + + var typeDecl = currentRoot + .GetAnnotatedNodes(annotation) + .OfType() + .FirstOrDefault(); + if (typeDecl is null) + { + continue; + } + + workingDoc = await fix.FixAsync(workingDoc, typeDecl, diagnostic, ct).ConfigureAwait(false); + } + } + + return workingDoc; + } + + private static bool TryResolveFix(Diagnostic diagnostic, out IShapeFix fix) + { + fix = null!; + if (!diagnostic.Properties.TryGetValue("fixKind", out var fixKindStr) + || string.IsNullOrEmpty(fixKindStr) + || !Enum.TryParse(fixKindStr, out var fixKind) + || fixKind == FixKind.None) + { + return false; + } + + var resolved = FixResolver.Resolve(fixKind); + if (resolved is null) + { + return false; + } + + fix = resolved; + return true; + } +} diff --git a/Scribe.Scriptorium/Scribe.Scriptorium.csproj b/Scribe.Scriptorium/Scribe.Scriptorium.csproj index a5a48bd..957f33a 100644 --- a/Scribe.Scriptorium/Scribe.Scriptorium.csproj +++ b/Scribe.Scriptorium/Scribe.Scriptorium.csproj @@ -1,22 +1,13 @@ - + + + - netstandard2.0 - 14 - true - false - false - false - embedded - false + Meta @@ -26,4 +17,20 @@ + + + + + + false + false + + diff --git a/Scribe.Sdk/Scribe.Sdk.csproj b/Scribe.Sdk/Scribe.Sdk.csproj index 7f26993..0566cde 100644 --- a/Scribe.Sdk/Scribe.Sdk.csproj +++ b/Scribe.Sdk/Scribe.Sdk.csproj @@ -9,6 +9,8 @@ BulletsForHumanity.Scribe.Sdk netstandard2.0 + + latest false true diff --git a/Scribe.Sdk/Sdk/Phases/Analyzer.targets b/Scribe.Sdk/Sdk/Phases/Analyzer.targets index cc92b1c..4d98d32 100644 --- a/Scribe.Sdk/Sdk/Phases/Analyzer.targets +++ b/Scribe.Sdk/Sdk/Phases/Analyzer.targets @@ -19,6 +19,13 @@ false Analyzer true + + $(NoWarn);RS1038 diff --git a/Scribe.Sdk/Sdk/Phases/Companion.targets b/Scribe.Sdk/Sdk/Phases/Companion.targets index cce0024..bd863b1 100644 --- a/Scribe.Sdk/Sdk/Phases/Companion.targets +++ b/Scribe.Sdk/Sdk/Phases/Companion.targets @@ -18,7 +18,7 @@ Usage in a Companion .csproj: Companion - MyOrg.MyLib + MyOrg.MyLib (metadata only) --> diff --git a/Scribe.Sdk/Sdk/Phases/Meta.targets b/Scribe.Sdk/Sdk/Phases/Meta.targets index 20f2ad1..84f4aef 100644 --- a/Scribe.Sdk/Sdk/Phases/Meta.targets +++ b/Scribe.Sdk/Sdk/Phases/Meta.targets @@ -12,6 +12,11 @@ that consuming projects resolve via a PackageVersion override emitted to $(ArtifactsPath).Directory.Packages.targets. + Opt out of auto-pack with false — useful for + solution-internal Meta projects consumed purely via ProjectReference + that should never be packaged at all. The override-file emission is + also skipped. + The consumer's Directory.Build.targets must import override files via wildcard: in Sdk.targets). No per-node conditions needed inside. --> - + false Analyzer true $(GetTargetPathDependsOn);GetDependencyTargetPaths + + false + + false + + + + + <_ScribeMetaTimestamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmss')) 0.0.0-dev.$(_ScribeMetaTimestamp) 0.0.0-dev.$(_ScribeMetaTimestamp) - - - true - true false @@ -54,6 +65,16 @@ <_ScribeMetaArtifactsDir>$([MSBuild]::EnsureTrailingSlash('$(ArtifactsPath)')) $(_ScribeMetaArtifactsDir)packages\ + + + + $(RestoreAdditionalProjectSources);$(_ScribeMetaArtifactsDir)packages diff --git a/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs new file mode 100644 index 0000000..2c04202 --- /dev/null +++ b/Scribe.Tests/Shapes/ShapeFixAllProviderTests.cs @@ -0,0 +1,137 @@ +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 the custom ShapeFixAllProvider chains multiple +/// Shape fixes that target the same type declaration — the scenario the +/// built-in BatchFixer cannot handle. +/// +public class ShapeFixAllProviderTests +{ + private readonly record struct Collected(string Fqn); + + [Fact] + public async Task FixAll_chains_partial_and_sealed_fixes_on_same_type() + { + var shape = Shape.Class() + .MustBePartial() + .MustBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await RunFixAll(shape, "public class Widget { }", FixAllScope.Document); + + result.ShouldContain("partial"); + result.ShouldContain("sealed"); + result.ShouldContain("class Widget"); + } + + [Fact] + public async Task FixAll_chains_visibility_plus_remove_modifier_fixes() + { + var shape = Shape.Class() + .MustBePublic() + .MustNotBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await RunFixAll(shape, "internal sealed class Widget { }", FixAllScope.Document); + + result.ShouldContain("public"); + result.ShouldNotContain("internal"); + result.ShouldNotContain("sealed"); + result.ShouldContain("class Widget"); + } + + [Fact] + public async Task FixAll_applies_fixes_per_type_independently() + { + var shape = Shape.Class() + .MustBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public class Widget { } +public class Gadget { } +"; + var result = await RunFixAll(shape, source, FixAllScope.Document); + + result.ShouldContain("partial class Widget"); + result.ShouldContain("partial class Gadget"); + } + + private static async Task RunFixAll( + Shape shape, string source, FixAllScope scope) + 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); + + diagnostics.Length.ShouldBeGreaterThan(0); + + var fixProvider = shape.ToFixProvider(); + var fixAll = fixProvider.GetFixAllProvider(); + fixAll.ShouldNotBeNull(); + + var context = new FixAllContext( + document: document, + codeFixProvider: fixProvider, + scope: scope, + codeActionEquivalenceKey: "Shape.FixAll." + scope, + diagnosticIds: fixProvider.FixableDiagnosticIds, + fixAllDiagnosticProvider: new TestDiagnosticProvider(diagnostics), + cancellationToken: CancellationToken.None); + + var action = await fixAll.GetFixAsync(context).ConfigureAwait(false); + action.ShouldNotBeNull(); + + var operations = await action.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(); + } + + private sealed class TestDiagnosticProvider : FixAllContext.DiagnosticProvider + { + private readonly ImmutableArray _diagnostics; + + public TestDiagnosticProvider(ImmutableArray diagnostics) + => _diagnostics = diagnostics; + + public override Task> GetAllDiagnosticsAsync(Project project, CancellationToken ct) + => Task.FromResult>(_diagnostics); + + public override Task> GetDocumentDiagnosticsAsync(Document document, CancellationToken ct) + => Task.FromResult>(_diagnostics); + + public override Task> GetProjectDiagnosticsAsync(Project project, CancellationToken ct) + => Task.FromResult>(Enumerable.Empty()); + } +} diff --git a/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs new file mode 100644 index 0000000..57db68a --- /dev/null +++ b/Scribe.Tests/Shapes/ShapePhase8PrimitivesTests.cs @@ -0,0 +1,315 @@ +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; + +/// +/// Exercises the Phase 8 extensions to the MustBe*/MustNot* +/// catalog: analyzer emissions for SCRIBE006–012 and the matching code +/// fix application. Each test pairs an analyzer assertion with a fix +/// assertion where a fix exists. +/// +public class ShapePhase8PrimitivesTests +{ + private readonly record struct Collected(string Fqn); + + [Fact] + public void MustBeAbstract_emits_SCRIBE006_with_AddAbstractModifier_fix_kind() + { + var shape = Shape.Class() + .MustBeAbstract() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE006"); + diagnostics[0].Properties["fixKind"].ShouldBe("AddAbstractModifier"); + } + + [Fact] + public void MustBeAbstract_emits_nothing_when_already_abstract() + { + var shape = Shape.Class() + .MustBeAbstract() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public abstract class Widget { }").ShouldBeEmpty(); + } + + [Fact] + public async Task MustBeAbstract_fix_adds_abstract_keyword() + { + var shape = Shape.Class() + .MustBeAbstract() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public class Widget { }"); + result.ShouldContain("abstract class Widget"); + } + + [Fact] + public void MustBeStatic_emits_SCRIBE007_with_AddStaticModifier_fix_kind() + { + var shape = Shape.Class() + .MustBeStatic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE007"); + diagnostics[0].Properties["fixKind"].ShouldBe("AddStaticModifier"); + } + + [Fact] + public async Task MustBeStatic_fix_adds_static_keyword() + { + var shape = Shape.Class() + .MustBeStatic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public class Widget { }"); + result.ShouldContain("static class Widget"); + } + + [Fact] + public void MustExtend_emits_SCRIBE008_and_encodes_base_class_in_fix_properties() + { + var shape = Shape.Class() + .MustHaveAttribute("MarkerAttribute") + .MustExtend("MyBase") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +public class MyBase { } +[Marker] public class Widget { } +"; + var diagnostics = RunAnalyzer(shape, source) + .Where(d => d.Id == "SCRIBE008") + .ToArray(); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Properties["fixKind"].ShouldBe("AddBaseClass"); + diagnostics[0].Properties["baseClass"].ShouldBe("MyBase"); + } + + [Fact] + public void MustExtend_emits_nothing_when_already_extends() + { + var shape = Shape.Class() + .MustHaveAttribute("MarkerAttribute") + .MustExtend("MyBase") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +public class MyBase { } +[Marker] public class Widget : MyBase { } +"; + RunAnalyzer(shape, source).Where(d => d.Id == "SCRIBE008").ShouldBeEmpty(); + } + + [Fact] + public async Task MustExtend_fix_inserts_base_class_at_head_of_base_list() + { + var shape = Shape.Class() + .MustHaveAttribute("MarkerAttribute") + .MustExtend("MyBase") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +public class MyBase { } +[Marker] public class Widget : System.IComparable { public int CompareTo(object o) => 0; } +"; + var result = await ApplyFirstFix(shape, source); + result.ShouldContain("Widget : MyBase"); + result.ShouldContain("IComparable"); + } + + [Fact] + public void MustBeInNamespace_emits_SCRIBE009_with_no_fix() + { + var shape = Shape.Class() + .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +namespace MyApp.Other { public class Widget { } } +"; + var diagnostics = RunAnalyzer(shape, source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE009"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustBeInNamespace_allows_matching_namespace() + { + var shape = Shape.Class() + .MustBeInNamespace(@"^MyApp\.Domain(\..*)?$") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +namespace MyApp.Domain.Things { public class Widget { } } +"; + RunAnalyzer(shape, source).ShouldBeEmpty(); + } + + [Fact] + public void MustNotBeAbstract_emits_SCRIBE010_with_RemoveAbstractModifier_fix() + { + var shape = Shape.Class() + .MustNotBeAbstract() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public abstract class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE010"); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveAbstractModifier"); + } + + [Fact] + public async Task MustNotBeAbstract_fix_removes_abstract_keyword() + { + var shape = Shape.Class() + .MustNotBeAbstract() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public abstract class Widget { }"); + result.ShouldNotContain("abstract"); + result.ShouldContain("class Widget"); + } + + [Fact] + public void MustNotBeGeneric_emits_SCRIBE011_with_no_fix_on_generic_class() + { + var shape = Shape.Class() + .MustNotBeGeneric() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE011"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustNotBeGeneric_allows_non_generic_class() + { + var shape = Shape.Class() + .MustNotBeGeneric() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); + } + + [Fact] + public void MustNotImplement_emits_SCRIBE012_and_encodes_interface_for_fix() + { + var shape = Shape.Class() + .MustNotImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget : System.IDisposable { public void Dispose() { } }"; + var diagnostics = RunAnalyzer(shape, source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE012"); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveFromBaseList"); + diagnostics[0].Properties["interface"].ShouldBe("System.IDisposable"); + } + + [Fact] + public async Task MustNotImplement_fix_removes_forbidden_interface_from_base_list() + { + var shape = Shape.Class() + .MustNotImplement("System.IDisposable") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget : System.IComparable, System.IDisposable { public int CompareTo(object o) => 0; public void Dispose() { } }"; + var result = await ApplyFirstFix(shape, source); + result.ShouldContain("IComparable"); + result.ShouldNotContain("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)); + } + + 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(d => d.Properties.TryGetValue("fixKind", out var k) + && !string.IsNullOrEmpty(k) + && k != "None"); + + 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/ShapePhase8_5Tests.cs b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs new file mode 100644 index 0000000..f0fe52b --- /dev/null +++ b/Scribe.Tests/Shapes/ShapePhase8_5Tests.cs @@ -0,0 +1,491 @@ +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; + +/// +/// Exercises the Phase 8.5 additions to the MustBe*/MustNot* +/// catalog: direct negations (SCRIBE013–020), visibility (SCRIBE021–026), +/// declaration kind (SCRIBE027–030), and parameterless constructor +/// (SCRIBE031), along with the matching fix implementations. +/// +public class ShapePhase8_5Tests +{ + private readonly record struct Collected(string Fqn); + + // ─────────────────────────────────────────────────────────────── + // Direct negations + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustNotBePartial_emits_SCRIBE013_with_RemovePartialModifier_fix() + { + var shape = Shape.Class() + .MustNotBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public partial class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE013"); + diagnostics[0].Properties["fixKind"].ShouldBe("RemovePartialModifier"); + } + + [Fact] + public async Task MustNotBePartial_fix_removes_partial_keyword() + { + var shape = Shape.Class() + .MustNotBePartial() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public partial class Widget { }"); + result.ShouldNotContain("partial"); + result.ShouldContain("class Widget"); + } + + [Fact] + public void MustNotBeSealed_emits_SCRIBE014_with_RemoveSealedModifier_fix() + { + var shape = Shape.Class() + .MustNotBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public sealed class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE014"); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveSealedModifier"); + } + + [Fact] + public async Task MustNotBeSealed_fix_removes_sealed_keyword() + { + var shape = Shape.Class() + .MustNotBeSealed() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public sealed class Widget { }"); + result.ShouldNotContain("sealed"); + result.ShouldContain("class Widget"); + } + + [Fact] + public void MustNotHaveAttribute_emits_SCRIBE015_with_RemoveAttribute_fix() + { + var shape = Shape.Class() + .MustNotHaveAttribute("MarkerAttribute") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +[Marker] public class Widget { } +"; + var diagnostics = RunAnalyzer(shape, source) + .Where(d => d.Id == "SCRIBE015") + .ToArray(); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveAttribute"); + diagnostics[0].Properties["attribute"].ShouldBe("MarkerAttribute"); + } + + [Fact] + public async Task MustNotHaveAttribute_fix_removes_attribute_from_type() + { + var shape = Shape.Class() + .MustNotHaveAttribute("MarkerAttribute") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +[Marker] public class Widget { } +"; + var result = await ApplyFirstFix(shape, source); + result.ShouldNotContain("[Marker]"); + result.ShouldContain("class Widget"); + } + + [Fact] + public void MustNotBeNamed_emits_SCRIBE016_with_no_fix() + { + var shape = Shape.Class() + .MustNotBeNamed(@"^.*Impl$") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class WidgetImpl { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE016"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustNotBeStatic_emits_SCRIBE017_with_RemoveStaticModifier_fix() + { + var shape = Shape.Class() + .MustNotBeStatic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public static class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE017"); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveStaticModifier"); + } + + [Fact] + public async Task MustNotBeStatic_fix_removes_static_keyword() + { + var shape = Shape.Class() + .MustNotBeStatic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public static class Widget { }"); + result.ShouldNotContain("static"); + result.ShouldContain("class Widget"); + } + + [Fact] + public void MustNotExtend_emits_SCRIBE018_and_encodes_baseClass_for_fix() + { + var shape = Shape.Class() + .MustHaveAttribute("MarkerAttribute") + .MustNotExtend("MyBase") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +public class MyBase { } +[Marker] public class Widget : MyBase { } +"; + var diagnostics = RunAnalyzer(shape, source) + .Where(d => d.Id == "SCRIBE018") + .ToArray(); + + diagnostics.Length.ShouldBe(1); + diagnostics[0].Properties["fixKind"].ShouldBe("RemoveFromBaseList"); + diagnostics[0].Properties["baseClass"].ShouldBe("MyBase"); + } + + [Fact] + public async Task MustNotExtend_fix_removes_base_class_from_base_list() + { + var shape = Shape.Class() + .MustHaveAttribute("MarkerAttribute") + .MustNotExtend("MyBase") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = @" +public sealed class MarkerAttribute : System.Attribute { } +public class MyBase { } +[Marker] public class Widget : MyBase, System.IComparable { public int CompareTo(object o) => 0; } +"; + var result = await ApplyFirstFix(shape, source); + result.ShouldNotContain("MyBase,"); + result.ShouldNotContain(": MyBase"); + result.ShouldContain("IComparable"); + } + + [Fact] + public void MustNotBeInNamespace_emits_SCRIBE019_with_no_fix() + { + var shape = Shape.Class() + .MustNotBeInNamespace(@"^MyApp\.Legacy(\..*)?$") + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "namespace MyApp.Legacy { public class Widget { } }"; + var diagnostics = RunAnalyzer(shape, source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE019"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustBeGeneric_emits_SCRIBE020_on_non_generic_class_with_no_fix() + { + var shape = Shape.Class() + .MustBeGeneric() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE020"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustBeGeneric_allows_generic_class() + { + var shape = Shape.Class() + .MustBeGeneric() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); + } + + // ─────────────────────────────────────────────────────────────── + // Visibility + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustBePublic_emits_SCRIBE021_with_SetVisibility_fix_and_visibility_property() + { + var shape = Shape.Class() + .MustBePublic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "internal class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE021"); + diagnostics[0].Properties["fixKind"].ShouldBe("SetVisibility"); + diagnostics[0].Properties["visibility"].ShouldBe("public"); + } + + [Fact] + public async Task MustBePublic_fix_rewrites_visibility_to_public() + { + var shape = Shape.Class() + .MustBePublic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "internal class Widget { }"); + result.ShouldContain("public class Widget"); + result.ShouldNotContain("internal class"); + } + + [Fact] + public void MustBeInternal_emits_SCRIBE022_with_SetVisibility_fix() + { + var shape = Shape.Class() + .MustBeInternal() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE022"); + diagnostics[0].Properties["visibility"].ShouldBe("internal"); + } + + [Fact] + public async Task MustBeInternal_fix_rewrites_visibility_to_internal() + { + var shape = Shape.Class() + .MustBeInternal() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var result = await ApplyFirstFix(shape, "public class Widget { }"); + result.ShouldContain("internal class Widget"); + result.ShouldNotContain("public class"); + } + + [Fact] + public void MustNotBePublic_emits_SCRIBE024_with_no_fix() + { + var shape = Shape.Class() + .MustNotBePublic() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE024"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustNotBeInternal_allows_public_class() + { + var shape = Shape.Class() + .MustNotBeInternal() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); + } + + // ─────────────────────────────────────────────────────────────── + // Declaration kind + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustBeRecord_emits_SCRIBE027_on_plain_class_with_no_fix() + { + var shape = Shape.Class() + .MustBeRecord() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE027"); + diagnostics[0].Properties["fixKind"].ShouldBe("None"); + } + + [Fact] + public void MustBeRecord_allows_record_class() + { + var shape = Shape.Class() + .MustBeRecord() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public record class Widget(int X);").ShouldBeEmpty(); + } + + [Fact] + public void MustNotBeRecord_emits_SCRIBE028_on_record() + { + var shape = Shape.Record() + .MustNotBeRecord() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public record class Widget(int X);"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE028"); + } + + [Fact] + public void MustBeValueType_emits_SCRIBE029_on_class_and_skips_struct() + { + var shape = Shape.Struct() + .MustBeValueType() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public struct Widget { }").ShouldBeEmpty(); + + var shapeClass = Shape.Class() + .MustBeValueType() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shapeClass, "public class Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE029"); + } + + [Fact] + public void MustNotBeValueType_emits_SCRIBE030_on_struct() + { + var shape = Shape.Struct() + .MustNotBeValueType() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var diagnostics = RunAnalyzer(shape, "public struct Widget { }"); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE030"); + } + + // ─────────────────────────────────────────────────────────────── + // Parameterless constructor + // ─────────────────────────────────────────────────────────────── + + [Fact] + public void MustHaveParameterlessConstructor_allows_class_with_no_explicit_ctors() + { + var shape = Shape.Class() + .MustHaveParameterlessConstructor() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + RunAnalyzer(shape, "public class Widget { }").ShouldBeEmpty(); + } + + [Fact] + public void MustHaveParameterlessConstructor_emits_SCRIBE031_when_only_parameterised_ctor_exists() + { + var shape = Shape.Class() + .MustHaveParameterlessConstructor() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget { public Widget(int x) { } }"; + var diagnostics = RunAnalyzer(shape, source); + diagnostics.Length.ShouldBe(1); + diagnostics[0].Id.ShouldBe("SCRIBE031"); + diagnostics[0].Properties["fixKind"].ShouldBe("AddParameterlessConstructor"); + } + + [Fact] + public async Task MustHaveParameterlessConstructor_fix_adds_public_parameterless_ctor() + { + var shape = Shape.Class() + .MustHaveParameterlessConstructor() + .Project((in ShapeProjectionContext ctx) => new Collected(ctx.Fqn)); + + var source = "public class Widget { public Widget(int x) { } }"; + var result = await ApplyFirstFix(shape, source); + result.ShouldContain("public Widget()"); + } + + // ─────────────────────────────────────────────────────────────── + // Harness + // ─────────────────────────────────────────────────────────────── + + 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)); + } + + 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(d => d.Properties.TryGetValue("fixKind", out var k) + && !string.IsNullOrEmpty(k) + && k != "None"); + + 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/Scribe.csproj b/Scribe/Scribe.csproj index 517f1f2..66bf236 100644 --- a/Scribe/Scribe.csproj +++ b/Scribe/Scribe.csproj @@ -1,23 +1,13 @@ - + - netstandard2.0 - 14 - true - false + Library - - false - embedded - - $(NoWarn);NU5128 - - false + false + enable BulletsForHumanity.Scribe Fluent source-generation utilities for Roslyn incremental generators. Provides Quill (fluent source builder), naming helpers, XML doc extraction, and well-known BCL type constants. @@ -30,14 +20,6 @@ - - - - - diff --git a/Scribe/Shapes/FixSpec.cs b/Scribe/Shapes/FixSpec.cs index 2c8e20e..fe29622 100644 --- a/Scribe/Shapes/FixSpec.cs +++ b/Scribe/Shapes/FixSpec.cs @@ -46,4 +46,22 @@ public enum FixKind /// Add a base class to the type declaration (fix property baseClass). AddBaseClass, + + /// Remove the partial modifier from the type declaration. + RemovePartialModifier, + + /// Remove the sealed modifier from the type declaration. + RemoveSealedModifier, + + /// Remove the static modifier from the type declaration. + RemoveStaticModifier, + + /// Remove an attribute from the declaration (fix property attribute). + RemoveAttribute, + + /// Change the type's visibility modifier (fix property visibilitypublic/internal/private). + SetVisibility, + + /// Add an explicit public parameterless constructor to the type. + AddParameterlessConstructor, } diff --git a/Scribe/Shapes/ShapeBuilder.cs b/Scribe/Shapes/ShapeBuilder.cs index f388bc4..5a0ae2d 100644 --- a/Scribe/Shapes/ShapeBuilder.cs +++ b/Scribe/Shapes/ShapeBuilder.cs @@ -403,6 +403,389 @@ public ShapeBuilder MustNotImplement(string metadataName, DiagnosticSpec? spec = return this; } + // ─────────────────────────────────────────────────────────────── + // Phase 8.5 — negations of positive primitives + // ─────────────────────────────────────────────────────────────── + + /// Forbid the partial modifier on the type. + public ShapeBuilder MustNotBePartial(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE013", + defaultTitle: "Type must not be partial", + defaultMessage: "Type '{0}' must not be declared 'partial'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.RemovePartialModifier, + predicate: static (sym, _, ct) => !IsPartial(sym, ct), + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the sealed modifier on the type. + public ShapeBuilder MustNotBeSealed(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE014", + defaultTitle: "Type must not be sealed", + defaultMessage: "Type '{0}' must not be declared 'sealed'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.RemoveSealedModifier, + // Value types and static classes are implicitly sealed — ignore them + // so this check only flags explicit 'sealed' on classes that could be unsealed. + predicate: static (sym, _, _) => !sym.IsSealed || sym.IsValueType || sym.IsStatic, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid attribute on the type. + public ShapeBuilder MustNotHaveAttribute(DiagnosticSpec? spec = null) + where T : System.Attribute + => MustNotHaveAttribute(typeof(T).FullName!, spec); + + /// Forbid the attribute named by . + public ShapeBuilder MustNotHaveAttribute(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: "SCRIBE015", + defaultTitle: "Type must not carry forbidden attribute", + defaultMessage: "Type '{0}' must not be annotated with '[{1}]'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.AttributeList, + defaultFix: FixKind.RemoveAttribute, + predicate: (sym, _, _) => !HasAttribute(sym, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("attribute", interned)); + return this; + } + + /// + /// Forbid the type's from matching . + /// No auto-fix — renaming is a cross-file operation outside Scribe's automation surface. + /// + public ShapeBuilder MustNotBeNamed(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: "SCRIBE016", + defaultTitle: "Type name must not match forbidden pattern", + defaultMessage: "Type '{0}' name matches forbidden 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; + } + + /// Forbid the static modifier on the type. + public ShapeBuilder MustNotBeStatic(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE017", + defaultTitle: "Type must not be static", + defaultMessage: "Type '{0}' must not be declared 'static'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.RemoveStaticModifier, + predicate: static (sym, _, _) => !sym.IsStatic, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the type from extending . + public ShapeBuilder MustNotExtend(DiagnosticSpec? spec = null) + where T : class + => MustNotExtend(typeof(T).FullName!, spec); + + /// Forbid the type from extending the base class named by . + public ShapeBuilder MustNotExtend(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: "SCRIBE018", + defaultTitle: "Type must not extend forbidden base class", + defaultMessage: "Type '{0}' must not extend base class '{1}'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.BaseList, + defaultFix: FixKind.RemoveFromBaseList, + predicate: (sym, compilation, _) => !ExtendsByMetadataName(sym, compilation, interned), + messageArgs: sym => EquatableArray.Create(sym.Name, interned), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("baseClass", interned)); + return this; + } + + /// + /// Forbid the type's containing namespace from matching . + /// No auto-fix — moving a file is outside Scribe's automation surface. + /// + public ShapeBuilder MustNotBeInNamespace(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: "SCRIBE019", + defaultTitle: "Type must not be in forbidden namespace", + defaultMessage: "Type '{0}' is in namespace '{1}' which matches forbidden 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; + } + + /// Require the type to declare at least one generic type parameter. + public ShapeBuilder MustBeGeneric(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE020", + defaultTitle: "Type must be generic", + defaultMessage: "Type '{0}' must declare at least one generic type parameter", + 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; + } + + // ─────────────────────────────────────────────────────────────── + // Phase 8.5 — visibility + // ─────────────────────────────────────────────────────────────── + + /// Require the type to be declared public. + public ShapeBuilder MustBePublic(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE021", + defaultTitle: "Type must be public", + defaultMessage: "Type '{0}' must be declared 'public'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.SetVisibility, + predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Public, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("visibility", "public")); + return this; + } + + /// Require the type to be declared internal. + public ShapeBuilder MustBeInternal(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE022", + defaultTitle: "Type must be internal", + defaultMessage: "Type '{0}' must be declared 'internal'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.SetVisibility, + predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Internal, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("visibility", "internal")); + return this; + } + + /// + /// Require a nested type to be declared private. Top-level types cannot be + /// private — use for that case. + /// + public ShapeBuilder MustBePrivate(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE023", + defaultTitle: "Type must be private", + defaultMessage: "Type '{0}' must be declared 'private'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.SetVisibility, + predicate: static (sym, _, _) => sym.DeclaredAccessibility == Accessibility.Private, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec, + fixProperties: _ => ImmutableDictionary.Empty + .Add("visibility", "private")); + return this; + } + + /// Forbid the public visibility modifier on the type. + public ShapeBuilder MustNotBePublic(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE024", + defaultTitle: "Type must not be public", + defaultMessage: "Type '{0}' must not be declared 'public'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Public, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the internal visibility modifier on the type. + public ShapeBuilder MustNotBeInternal(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE025", + defaultTitle: "Type must not be internal", + defaultMessage: "Type '{0}' must not be declared 'internal'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Internal, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the private visibility modifier on the type. + public ShapeBuilder MustNotBePrivate(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE026", + defaultTitle: "Type must not be private", + defaultMessage: "Type '{0}' must not be declared 'private'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.ModifierList, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => sym.DeclaredAccessibility != Accessibility.Private, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + // ─────────────────────────────────────────────────────────────── + // Phase 8.5 — declaration kind + // ─────────────────────────────────────────────────────────────── + + /// Require the type to be a record (class-record or record-struct). + public ShapeBuilder MustBeRecord(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE027", + defaultTitle: "Type must be a record", + defaultMessage: "Type '{0}' must be declared as a 'record'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => sym.IsRecord, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the record keyword on the type. + public ShapeBuilder MustNotBeRecord(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE028", + defaultTitle: "Type must not be a record", + defaultMessage: "Type '{0}' must not be declared as a 'record'", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => !sym.IsRecord, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Require the type to be a value type (struct or record struct). + public ShapeBuilder MustBeValueType(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE029", + defaultTitle: "Type must be a value type", + defaultMessage: "Type '{0}' must be a value type", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => sym.IsValueType, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + /// Forbid the type from being a value type. + public ShapeBuilder MustNotBeValueType(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE030", + defaultTitle: "Type must not be a value type", + defaultMessage: "Type '{0}' must not be a value type", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.TypeKeyword, + defaultFix: FixKind.None, + predicate: static (sym, _, _) => !sym.IsValueType, + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + + // ─────────────────────────────────────────────────────────────── + // Phase 8.5 — constructors + // ─────────────────────────────────────────────────────────────── + + /// + /// Require a public parameterless constructor. Satisfied by the implicit + /// default constructor on classes with no other instance constructors; for + /// value types the default ctor is always present. Primary constructors on + /// records and structs are not considered parameterless. + /// + public ShapeBuilder MustHaveParameterlessConstructor(DiagnosticSpec? spec = null) + { + AddCheck( + defaultId: "SCRIBE031", + defaultTitle: "Type must have a public parameterless constructor", + defaultMessage: "Type '{0}' must declare a public parameterless constructor", + defaultSeverity: DiagnosticSeverity.Error, + defaultSquiggle: SquiggleAt.Identifier, + defaultFix: FixKind.AddParameterlessConstructor, + predicate: static (sym, _, _) => HasPublicParameterlessCtor(sym), + messageArgs: static sym => EquatableArray.Create(sym.Name), + spec: spec); + return this; + } + // ─────────────────────────────────────────────────────────────── // Predicate helpers (static, closure-free) // ─────────────────────────────────────────────────────────────── @@ -468,6 +851,34 @@ private static bool ExtendsByMetadataName( return false; } + private static bool HasPublicParameterlessCtor(INamedTypeSymbol symbol) + { + // Value types always have a default parameterless constructor. + if (symbol.IsValueType) + { + return true; + } + + // Static classes never need a constructor. + if (symbol.IsStatic) + { + return true; + } + + // Roslyn surfaces the compiler-synthesised public default constructor in + // InstanceConstructors, so a single check covers implicit + explicit ctors. + foreach (var ctor in symbol.InstanceConstructors) + { + if (ctor.Parameters.Length == 0 + && ctor.DeclaredAccessibility == Accessibility.Public) + { + return true; + } + } + + return false; + } + private static bool HasAttribute(INamedTypeSymbol symbol, string metadataName) { foreach (var attribute in symbol.GetAttributes()) diff --git a/Scribe/Stubs.cs b/Scribe/Stubs.cs deleted file mode 100644 index 7f80565..0000000 --- a/Scribe/Stubs.cs +++ /dev/null @@ -1,266 +0,0 @@ -// Polyfill stubs for modern C# language features on netstandard2.0. -// -// These types are recognised by the compiler by their well-known fully-qualified names. -// Marking them internal prevents conflicts with the real BCL types when the binary is -// loaded in a .NET 5+ host (which already carries the real definitions). -// -// Feature map: -// C# 9 init setters / record types → IsExternalInit -// C# 9 [ModuleInitializer] → ModuleInitializerAttribute -// C# 9 [SkipLocalsInit] → SkipLocalsInitAttribute -// C# 10 CallerArgumentExpression → CallerArgumentExpressionAttribute -// C# 10 Interpolated string handlers → InterpolatedStringHandlerAttribute -// InterpolatedStringHandlerArgumentAttribute -// C# 11 required members → RequiredMemberAttribute -// CompilerFeatureRequiredAttribute -// C# 11 scoped ref parameters → ScopedRefAttribute -// Nullable flow analysis attributes → System.Diagnostics. - -// ── System.Runtime.CompilerServices ────────────────────────────────────────── - -#if !NET5_0_OR_GREATER -#pragma warning disable IDE0130 // Namespace does not match folder structure -namespace System.Runtime.CompilerServices -#pragma warning restore IDE0130 // Namespace does not match folder structure -{ - /// - /// Marks the init accessor and is required for record types. - /// The compiler looks for this class by its fully-qualified name. - /// - internal static class IsExternalInit { } - - /// Enables the required modifier on members (C# 11). - [AttributeUsage( - AttributeTargets.Class - | AttributeTargets.Struct - | AttributeTargets.Field - | AttributeTargets.Property, - Inherited = false, - AllowMultiple = false - )] - internal sealed class RequiredMemberAttribute : Attribute { } - - /// - /// Companion to . The compiler emits this on - /// every constructor of a type that has required members (C# 11). - /// - [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)] - internal sealed class CompilerFeatureRequiredAttribute : Attribute - { - public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName; - - public string FeatureName { get; } - - /// - /// When , a compiler that does not understand this feature - /// is permitted to ignore it. When (the default), the - /// compiler must reject the construct. - /// - public bool IsOptional { get; init; } - - public const string RefStructs = nameof(RefStructs); - public const string RequiredMembers = nameof(RequiredMembers); - } - - /// Enables [ModuleInitializer] on a static void method (C# 9). - [AttributeUsage(AttributeTargets.Method, Inherited = false)] - internal sealed class ModuleInitializerAttribute : Attribute { } - - /// - /// Suppresses zero-initialisation of locals in the decorated method or type (C# 9). - /// - [AttributeUsage( - AttributeTargets.Module - | AttributeTargets.Class - | AttributeTargets.Struct - | AttributeTargets.Interface - | AttributeTargets.Constructor - | AttributeTargets.Method - | AttributeTargets.Property - | AttributeTargets.Event, - Inherited = false - )] - internal sealed class SkipLocalsInitAttribute : Attribute { } - - /// - /// Captures the source-text of the argument passed to a decorated parameter (C# 10). - /// - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class CallerArgumentExpressionAttribute : Attribute - { - public CallerArgumentExpressionAttribute(string parameterName) => - ParameterName = parameterName; - - public string ParameterName { get; } - } - - /// Marks a type as a custom interpolated string handler (C# 10). - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] - internal sealed class InterpolatedStringHandlerAttribute : Attribute { } - - /// - /// Specifies which arguments of a method call are passed to a custom interpolated - /// string handler (C# 10). - /// - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class InterpolatedStringHandlerArgumentAttribute : Attribute - { - public InterpolatedStringHandlerArgumentAttribute(string argument) => - Arguments = [argument]; - - public InterpolatedStringHandlerArgumentAttribute(params string[] arguments) => - Arguments = arguments; - - public string[] Arguments { get; } - } - - /// - /// Indicates that a parameter is scoped — its ref-safety scope does not - /// extend beyond the method boundary (C# 11). - /// - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class ScopedRefAttribute : Attribute { } -} - -// ── System.Diagnostics.CodeAnalysis — nullable flow analysis ───────────────── - -namespace System.Diagnostics.CodeAnalysis -{ - /// - /// Specifies that an output will not be even if the - /// corresponding type allows it. - /// - [AttributeUsage( - AttributeTargets.Field - | AttributeTargets.Parameter - | AttributeTargets.Property - | AttributeTargets.ReturnValue, - Inherited = false - )] - internal sealed class NotNullAttribute : Attribute { } - - /// - /// Specifies that an output may be even if the - /// corresponding type does not allow it. - /// - [AttributeUsage( - AttributeTargets.Field - | AttributeTargets.Parameter - | AttributeTargets.Property - | AttributeTargets.ReturnValue, - Inherited = false - )] - internal sealed class MaybeNullAttribute : Attribute { } - - /// Specifies that is allowed as an input even if the type does not allow it. - [AttributeUsage( - AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, - Inherited = false - )] - internal sealed class AllowNullAttribute : Attribute { } - - /// Specifies that is disallowed as an input even if the type allows it. - [AttributeUsage( - AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, - Inherited = false - )] - internal sealed class DisallowNullAttribute : Attribute { } - - /// - /// Specifies that when the method returns , the - /// decorated parameter will not be . - /// - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class NotNullWhenAttribute : Attribute - { - public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; - - public bool ReturnValue { get; } - } - - /// - /// Specifies that when the method returns , the - /// decorated parameter or return value may be . - /// - [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class MaybeNullWhenAttribute : Attribute - { - public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; - - public bool ReturnValue { get; } - } - - /// - /// Specifies that the return value of the decorated member is non-null when the - /// named parameter is non-null. - /// - [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class NotNullIfNotNullAttribute : Attribute - { - public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; - - public string ParameterName { get; } - } - - /// Applied to a method that will never return under any circumstance. - [AttributeUsage(AttributeTargets.Method, Inherited = false)] - internal sealed class DoesNotReturnAttribute : Attribute { } - - /// - /// Specifies that the method will not return if the decorated boolean parameter - /// has the specified value. - /// - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class DoesNotReturnIfAttribute : Attribute - { - public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; - - public bool ParameterValue { get; } - } - - /// - /// Specifies that the method or property ensures that the listed members are not - /// . - /// - [AttributeUsage( - AttributeTargets.Method | AttributeTargets.Property, - Inherited = false, - AllowMultiple = true - )] - internal sealed class MemberNotNullAttribute : Attribute - { - public MemberNotNullAttribute(string member) => Members = [member]; - - public MemberNotNullAttribute(params string[] members) => Members = members; - - public string[] Members { get; } - } - - /// - /// Specifies that the method or property ensures that the listed members are not - /// when returning the specified value. - /// - [AttributeUsage( - AttributeTargets.Method | AttributeTargets.Property, - Inherited = false, - AllowMultiple = true - )] - internal sealed class MemberNotNullWhenAttribute : Attribute - { - public MemberNotNullWhenAttribute(bool returnValue, string member) - { - ReturnValue = returnValue; - Members = [member]; - } - - public MemberNotNullWhenAttribute(bool returnValue, params string[] members) - { - ReturnValue = returnValue; - Members = members; - } - - public bool ReturnValue { get; } - public string[] Members { get; } - } -} -#endif diff --git a/Scribe/build/BulletsForHumanity.Scribe.props b/Scribe/build/BulletsForHumanity.Scribe.props deleted file mode 100644 index b309b80..0000000 --- a/Scribe/build/BulletsForHumanity.Scribe.props +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Scribe/build/BulletsForHumanity.Scribe.targets b/Scribe/build/BulletsForHumanity.Scribe.targets deleted file mode 100644 index 8b56345..0000000 --- a/Scribe/build/BulletsForHumanity.Scribe.targets +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/project-setup.md b/docs/project-setup.md index b8fb6af..f280633 100644 --- a/docs/project-setup.md +++ b/docs/project-setup.md @@ -73,6 +73,8 @@ If you prefer not to use the SDK, configure your project manually. Roslyn analyzers and source generators must target **netstandard2.0** — this is a hard requirement from the compiler host. +> **Note:** `Scribe.csproj` itself stays on `Microsoft.NET.Sdk` because it *produces* the Scribe runtime DLL — the Sdk cannot consume the package it ships. This is the one project in the Scribe repo that deliberately diverges from the SDK pattern; all other analyzer/generator projects in the repo (and every consumer downstream) should use `BulletsForHumanity.Scribe.Sdk`. + ### Minimal .csproj ```xml diff --git a/global.json b/global.json index 2f153fa..802692c 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "10.0.200", "rollForward": "patch", "allowPrerelease": false + }, + "msbuild-sdks": { + "BulletsForHumanity.Scribe.Sdk": "0.5.1" } }