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
3 changes: 1 addition & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ jobs:
name: Build and test
uses: purview-dev/build/.github/workflows/purview-build.yml@main
with:
build-version: "0.2.1"
run-pack: true
validate-pack: true
secrets: inherit
secrets: inherit
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ jobs:
name: Release packages
uses: purview-dev/build/.github/workflows/purview-release.yml@main
with:
build-version: "0.2.1"
release-mode: NuGet
release-branch: main
secrets: inherit
secrets: inherit
40 changes: 40 additions & 0 deletions docs/code-writer.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ writer.Return("value"); // return value;
writer.Throw(TypeIdentity.Create<InvalidOperationException>(), "Failed."); // throw new ...;
writer.Assignment("_total", "value"); // _total = value;
writer.IfBlock("value is null", body => body.Return("null"));
writer.IfBlock("value is null", body => body.Return("null"))
.ElseIf("value is 0", body => body.Return("zero"))
.Else(body => body.Return("value"));
writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process"));
```

Expand All @@ -105,6 +108,40 @@ writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeR
// factory.Create<string>(x);
```

### Conditional statements

`IfBlock`/`IfBlockScope` write an `if` block. `ElseIf`/`ElseIfScope` chain an `else if` block after an
`if` or another `else if`, and `Else`/`ElseScope` close the chain with an `else` block. The methods
return the writer, so branches can be chained fluently:

```csharp
writer
.IfBlock("value is null", body => body.Return("null"))
.ElseIf("value is 0", body => body.Return("zero"))
.Else(body => body.Return("value"));
```

Emits:

```csharp
if (value is null)
{
return null;
}
else if (value is 0)
{
return zero;
}
else
{
return value;
}
```

`IfElse(condition, ifBody, elseBody)` is the compact two-branch form. The scope forms
`IfBlockScope`, `ElseIfScope`, and `ElseScope` write the header and return the body scope for
content that spans multiple calls.

### Conditional compilation blocks

`HashDefines`/`HashDefinesScope` write a `#if`/`#endif` block with both directives at **column zero**.
Expand Down Expand Up @@ -288,6 +325,9 @@ writer.Property("Name", TypeReference.Create<string>(), TypeDeclarationAccessibi
values manually — the `PreferMinimalCodeWriterOverloadAnalyzer` (PSGFR20) flags the verbose form.
- Prefer structured declarations and statements over raw text — `PreferStructuredCodeWriterApiAnalyzer`
(PSGFR18) and `PreferStructuredCodeWriterStatementAnalyzer` (PSGFR19) flag raw emission.
- Prefer `IfBlock`/`ElseIf`/`Else` over generic block methods for conditional content — the
`PreferStructuredCodeWriterIfBlockAnalyzer` (PSGFR23) flags `OpenBlockScope`/`OpenBlock` headers that
write an `if`, `else if`, or `else` statement, and its code fix rewrites them.
- Always consume scope-returning methods with `using` (PSGFR17).
- Keep every value emitted through the structured API so layout stays deterministic and the analyzers
can guide callers back to the best practice.
Expand Down
24 changes: 24 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,28 @@ This prevents Roslyn dependencies leaking into runtime package assets.

---

## Roslyn Component Discovery

The compiler host only loads a source generator, diagnostic analyser, or code fix provider when
three conditions hold. Missing any one means the component is **silently ignored**, which is why
"nothing shows up in Visual Studio" is usually a setup problem, not a code problem:

1. **The type is public.** Non-public component types cannot be instantiated by Roslyn
(`PSGFR27`).
2. **The type is decorated.** A generator needs `[Generator]` (`PSGFR26`), an analyser needs
`[DiagnosticAnalyzer]` (`PSGFR25`), and a code fix provider needs `[ExportCodeFixProvider]`
(`PSGFR24`).
3. **The assembly is loaded as an analyser.** In a package the component assembly must be packed
under `analyzers/dotnet/cs/`; in a project reference it must be referenced with
`OutputItemType="Analyser"`. A normal library reference never surfaces a component to Roslyn.

A code fix provider also only appears when the diagnostic ID in `FixableDiagnosticIds` is actually
produced by an analyser that is loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix
providers when the analyser set loads, so after adding or updating a fixer assembly you must
restart Visual Studio or reload the project for the fixes to appear.

---

# 19. Review Checklist

## Analyser
Expand All @@ -1970,6 +1992,8 @@ This prevents Roslyn dependencies leaking into runtime package assets.
- [ ] Is whole-compilation analysis genuinely necessary?
- [ ] Could the diagnostic reasonably have a code fix?
- [ ] Are diagnostic IDs release-tracked?
- [ ] Is the analyser type `public` and decorated with `[DiagnosticAnalyzer]`?
- [ ] Do the code fix's `FixableDiagnosticIds` match an ID the analyser actually produces?

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "purview-sourcegeneratorframework",
"version": "1.0.0-prerelease.33",
"version": "1.0.0-prerelease.34",
"private": true
}
1 change: 1 addition & 0 deletions purview-build.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"analyzers/dotnet/cs/Purview.SourceGeneratorFramework.dll",
"analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Generators.dll",
"analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Analyzers.dll",
"analyzers/dotnet/cs/Purview.SourceGeneratorFramework.CodeFixers.dll",
"analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Shared.dll",
"build/Purview.SourceGeneratorFramework.props",
"build/Purview.SourceGeneratorFramework.targets"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ PSGFR19 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWrit
PSGFR20 | Purview.SourceGeneratorFramework | Info | Prefer the minimal CodeWriter overload
PSGFR21 | Purview.SourceGeneratorFramework | Info | Prefer HashDefines for conditional compilation
PSGFR22 | Purview.SourceGeneratorFramework | Info | Prefer PragmaDisable for warning suppression
PSGFR23 | Purview.SourceGeneratorFramework | Info | Prefer the structured CodeWriter conditional API
PSGFR24 | Purview.SourceGeneratorFramework | Warning | CodeFixProvider is not marked with ExportCodeFixProvider
PSGFR25 | Purview.SourceGeneratorFramework | Warning | DiagnosticAnalyzer is not marked with DiagnosticAnalyzer
PSGFR26 | Purview.SourceGeneratorFramework | Error | Source generator is not marked with Generator
PSGFR27 | Purview.SourceGeneratorFramework | Warning | Roslyn component type must be public
PSGFR28 | Purview.SourceGeneratorFramework | Info | Code fixer targets a diagnostic no analyzer produces
ADM0001 | Target | Error | Target attribute type cannot be resolved
ADM0002 | Property | Error | Property type is not supported for attribute extraction
ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ public static bool IsPragmaWarningDirective(string value)
if (trimmed.StartsWith("if (", StringComparison.Ordinal))
return "IfBlock";

if (trimmed.StartsWith("else if (", StringComparison.Ordinal))
return "ElseIf";

if (trimmed == "else")
return "Else";

if (trimmed.StartsWith("foreach (", StringComparison.Ordinal))
return "Foreach";

Expand Down Expand Up @@ -218,4 +224,37 @@ static bool HasReceiver(string trimmed)
var openParen = trimmed.IndexOf('(');
return openParen > 0 && trimmed.LastIndexOf('.', openParen) >= 0;
}

/// <summary>
/// Classifies the header of a block- or scope-opening <c>CodeWriter</c> method and returns the
/// structured <c>if</c>/<c>else if</c>/<c>else</c> API that can express it, or
/// <see langword="null"/> when the header is not a conditional block.
/// </summary>
/// <param name="header">The header text resolved from the first argument of the block method.</param>
/// <param name="isScopeForm">Whether the block method is the scope-returning form, which selects the
/// <c>Scope</c>-suffixed suggestion.</param>
/// <returns>
/// The structured API name, or <see langword="null"/> when the header does not describe a
/// conditional block.
/// </returns>
public static string? ClassifyBlockHeader(string? header, bool isScopeForm)
{
var trimmed = header?.Trim();
if (trimmed is null || trimmed.Length == 0)
return null;

if (trimmed.EndsWith(";", StringComparison.Ordinal) || trimmed.EndsWith(")", StringComparison.Ordinal))
trimmed = trimmed.TrimEnd(';', ')').Trim();

if (trimmed.StartsWith("else if (", StringComparison.Ordinal))
return isScopeForm ? "ElseIfScope" : "ElseIf";

if (trimmed == "else")
return isScopeForm ? "ElseScope" : "Else";

if (trimmed.StartsWith("if (", StringComparison.Ordinal))
return isScopeForm ? "IfBlockScope" : "IfBlock";

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Purview.SourceGeneratorFramework.Analyzers;

/// <summary>
/// Flags <c>DiagnosticAnalyzer</c> subclasses that are not decorated with
/// <c>[DiagnosticAnalyzer]</c>, so the analyzer is never loaded and its diagnostics (and their
/// code fixes) never appear.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MissingDiagnosticAnalyzerAttributeAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "PSGFR25";

public static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
"DiagnosticAnalyzer is not registered",
"Type '{0}' derives from DiagnosticAnalyzer but is not marked [DiagnosticAnalyzer]; the analyzer will never run",
"Purview.SourceGeneratorFramework",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Diagnostic analyzers must be decorated with [DiagnosticAnalyzer] so the compiler host loads them. Without the attribute the analyzer is silently ignored."
);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

public override void Initialize(AnalysisContext context)
{
if (context is null)
throw new ArgumentNullException(nameof(context));

context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(context =>
{
var diagnosticAnalyzerType = context.Compilation.GetTypeByMetadataName(
"Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer"
);
var diagnosticAnalyzerAttributeType = context.Compilation.GetTypeByMetadataName(
"Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzerAttribute"
);

context.RegisterSymbolAction(
context => AnalyzeNamedType(context, diagnosticAnalyzerType, diagnosticAnalyzerAttributeType),
SymbolKind.NamedType
);
});
}

static void AnalyzeNamedType(
SymbolAnalysisContext context,
INamedTypeSymbol? diagnosticAnalyzerType,
INamedTypeSymbol? diagnosticAnalyzerAttributeType
)
{
if (context.Symbol is not INamedTypeSymbol type)
return;

if (!RoslynComponentDiscovery.IsDiagnosticAnalyzer(type, diagnosticAnalyzerType))
return;

if (RoslynComponentDiscovery.HasAttribute(type, diagnosticAnalyzerAttributeType))
return;

context.ReportDiagnostic(
Diagnostic.Create(
Rule,
type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None,
type.Name
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Purview.SourceGeneratorFramework.Analyzers;

/// <summary>
/// Flags <c>CodeFixProvider</c> subclasses that are not decorated with
/// <c>[ExportCodeFixProvider]</c>, so Visual Studio can never discover their fixes.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MissingExportCodeFixProviderAttributeAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "PSGFR24";

public static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
"CodeFixProvider is not exported",
"Type '{0}' derives from CodeFixProvider but is not marked [ExportCodeFixProvider]; Visual Studio will never discover its code fixes",
"Purview.SourceGeneratorFramework",
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Code fix providers must be decorated with [ExportCodeFixProvider] so Visual Studio can discover them. Without the attribute the type is silently ignored and its fixes never appear."
);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

public override void Initialize(AnalysisContext context)
{
if (context is null)
throw new ArgumentNullException(nameof(context));

context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(context =>
{
var codeFixProviderType = context.Compilation.GetTypeByMetadataName(
"Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider"
);
var exportAttributeType = context.Compilation.GetTypeByMetadataName(
"Microsoft.CodeAnalysis.CodeFixes.ExportCodeFixProviderAttribute"
);

context.RegisterSymbolAction(
context => AnalyzeNamedType(context, codeFixProviderType, exportAttributeType),
SymbolKind.NamedType
);
});
}

static void AnalyzeNamedType(
SymbolAnalysisContext context,
INamedTypeSymbol? codeFixProviderType,
INamedTypeSymbol? exportAttributeType
)
{
if (context.Symbol is not INamedTypeSymbol type)
return;

if (!RoslynComponentDiscovery.IsCodeFixProvider(type, codeFixProviderType))
return;

if (RoslynComponentDiscovery.HasAttribute(type, exportAttributeType))
return;

context.ReportDiagnostic(
Diagnostic.Create(
Rule,
type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None,
type.Name
)
);
}
}
Loading