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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<!-- CA1707: test method names use underscores by convention.
RS2008: release tracking applies to analyzer projects, not test projects that
happen to construct DiagnosticDescriptor instances for assertions. -->
Expand All @@ -35,16 +39,14 @@
<ArtifactsPath>$(MSBuildThisFileDirectory).artifacts/</ArtifactsPath>
</PropertyGroup>

<!-- Language and quality defaults. -->
<!-- Language and quality defaults. TargetFramework and LangVersion are set by
each project (or inherited from the Scribe SDK's Defaults.props for
analyzer/library projects). -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<WarningsAsErrors>nullable</WarningsAsErrors>
<AnalysisMode>Recommended</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);NU1507;CS1591</NoWarn>
</PropertyGroup>
Expand Down
20 changes: 4 additions & 16 deletions Scribe.Ink/Scribe.Ink.csproj
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
<!--
TODO(dogfood): switch to <Project Sdk="BulletsForHumanity.Scribe.Sdk"> once the
Sdk's project-types support lands (see SCRIBE_SDK_PROJECT_TYPES_REQS.md).
Until then, use Microsoft.NET.Sdk with the same settings Scribe.Sdk's Sdk.props applies.
-->
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="BulletsForHumanity.Scribe.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>14</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageType>Analyzer</PackageType>
<IncludeSymbols>false</IncludeSymbols>
<DebugType>embedded</DebugType>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<ScribeSdkProjectType>Analyzer</ScribeSdkProjectType>
<ImplicitUsings>enable</ImplicitUsings>
<!--
RS1038: Workspaces-referencing assembly hosting an analyzer. We split usage
by class (analyzers don't touch Workspaces types; fixers do), so the Roslyn
host loads both happily. The blanket rule is conservative, so suppress it.
RS2008: analyzer release tracking is overkill for a small, versioned package.
-->
<NoWarn>$(NoWarn);NU5128;RS1038;RS2008</NoWarn>
<NoWarn>$(NoWarn);RS1038;RS2008</NoWarn>
<PackageId>BulletsForHumanity.Scribe.Ink</PackageId>
<Description>Roslyn analyzers, code fix providers, and source generators for Scribe shapes. The scribe dips the quill in ink to mark, correct, and emend.</Description>
<PackageTags>roslyn;analyzer;code-fix;source-generator;scribe</PackageTags>
Expand Down
32 changes: 32 additions & 0 deletions Scribe.Ink/Shapes/FixResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Scribe.Ink.Shapes.Fixes;
using Scribe.Shapes;

namespace Scribe.Ink.Shapes;

/// <summary>
/// Maps a <see cref="FixKind"/> to its concrete <see cref="IShapeFix"/>
/// implementation. Shared between <see cref="ShapeCodeFixProvider"/> and
/// <see cref="ShapeFixAllProvider"/>.
/// </summary>
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,
};
Comment on lines +24 to +31

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FixResolver references FixKind members (e.g., RemovePartialModifier/SetVisibility/AddParameterlessConstructor) that are not present in Scribe.Shapes.FixKind in this branch, which will not compile and also prevents diagnostics from encoding these fix kinds consistently. Update the FixKind enum (and any analyzer emission) so every case handled here exists and is stable.

Suggested change
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,
};
FixKind.RemoveSealedModifier => new RemoveSealedModifierFix(),
FixKind.RemoveStaticModifier => new RemoveStaticModifierFix(),
FixKind.RemoveAttribute => new RemoveAttributeFix(),
_ => ResolveByName(kind),
};
private static IShapeFix? ResolveByName(FixKind kind)
{
var kindName = kind.ToString();
if (kindName == "RemovePartialModifier")
{
return new RemovePartialModifierFix();
}
if (kindName == "SetVisibility")
{
return new SetVisibilityFix();
}
if (kindName == "AddParameterlessConstructor")
{
return new AddParameterlessConstructorFix();
}
return null;
}

Copilot uses AI. Check for mistakes.
}
61 changes: 61 additions & 0 deletions Scribe.Ink/Shapes/Fixes/AddBaseClassFix.cs
Original file line number Diff line number Diff line change
@@ -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<Document> 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<BaseTypeSyntax>(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;
}
}
48 changes: 48 additions & 0 deletions Scribe.Ink/Shapes/Fixes/AddParameterlessConstructorFix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Linq;

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using System.Linq; is unused in this file. Remove it to satisfy code-style/IDE analyzers and keep the fix implementation minimal.

Suggested change
using System.Linq;

Copilot uses AI. Check for mistakes.
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<Document> 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));
}
}
47 changes: 47 additions & 0 deletions Scribe.Ink/Shapes/Fixes/AddStaticModifierFix.cs
Original file line number Diff line number Diff line change
@@ -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<Document> 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));
}
}
35 changes: 35 additions & 0 deletions Scribe.Ink/Shapes/Fixes/RemoveAbstractModifierFix.cs
Original file line number Diff line number Diff line change
@@ -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<Document> 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));
}
}
94 changes: 94 additions & 0 deletions Scribe.Ink/Shapes/Fixes/RemoveAttributeFix.cs
Original file line number Diff line number Diff line change
@@ -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<Document> 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;
}
}
Loading
Loading