diff --git a/Directory.Packages.props b/Directory.Packages.props index 1000e80..7223b1a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,5 +20,7 @@ + + diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index d90b37d..a27b86d 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -50,6 +50,7 @@ The analyzers enforce two families of rules: | `PSGFR36` | Extension classes must be placed in the extended type's namespace under an `Extensions` folder. | | `PSGFR37` | One extension class per receiver type; split classes that extend multiple types. | | `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component that explicitly opts out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework, otherwise the package embeds the loose framework DLL under `analyzers/`. | ## Type-library and attribute-model diagnostics diff --git a/docs/wiki/Code-Writer.md b/docs/wiki/Code-Writer.md index 055751e..64d222a 100644 --- a/docs/wiki/Code-Writer.md +++ b/docs/wiki/Code-Writer.md @@ -583,6 +583,47 @@ writer.Property("Name", TypeReference.Create(), TypeDeclarationAccessibi - Keep every value emitted through the structured API so layout stays deterministic and the analyzers can guide callers back to the best practice. +## Generators embedded in another package + +When a generator built with this framework is embedded into a different NuGet package (rather than +shipped as its own package), the outer package must make the framework's compiler-visible properties +available to its consumers, because build assets from `Purview.SourceGeneratorFramework` are not +automatically copied into the outer package. + +Ship a `.props` file with the outer package that declares each property and its +`CompilerVisibleProperty` entry, and pack it under `buildTransitive/` using the outer package's ID so +NuGet imports it for consumers: + +```xml + + false + + + + + Throws when generated source is materialized while CodeWriter scopes remain undisposed. + + +``` + +```xml + +``` + +The framework's public compiler-visible properties are +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes`, +`PurviewSourceGeneratorFrameworkEnableLogging`, +`PurviewSourceGeneratorFrameworkLoggingSessionId`, and +`PurviewSourceGeneratorFrameworkLanguageVersion`. See [Packaging.md](Packaging.md) for the full +self-contained generator packaging guidance. + ## Samples The [`SourceGeneratorFramework.ExampleGenerator`](../../src/src/SourceGeneratorFramework.ExampleGenerator) diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index d4c89b2..b483b9e 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -38,10 +38,14 @@ analyzer inputs. Use an analyzer project reference: /> ``` -The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns both the -generator and its framework dependency without adding either file to the consuming application's -runtime references. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains -supported but is not required. +The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns the generator +assembly without adding it to the consuming application's runtime references. By default the framework +returns a **merged, self-contained** generator from its intermediate output, so no loose +`Purview.SourceGeneratorFramework.dll` is needed and GASF-based packages stay self-contained. The +generator's own bin output stays unmerged, so its in-process test harness retains shared framework +type identity. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains supported but +is not required. Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the +unmerged assembly + loose framework DLL shape is required (see [Packaging.md](Packaging.md)). ### Referencing a generator from its test project @@ -74,8 +78,11 @@ Add two project references with deliberately different metadata: ``` Do not put `OutputItemType="Analyzer"` on the normal reference. The Purview SDK automatically -uses `GetSourceGeneratorAnalyzerFiles` for the analyzer reference and supplies the generator's -runtime dependencies to Roslyn. +uses `GetSourceGeneratorAnalyzerFiles` for the analyzer reference, which by default returns the +generator's **merged, self-contained** assembly from its intermediate output, so the generator loads +correctly with no extra runtime dependencies. The normal assembly reference resolves to the +generator's unmerged bin output, so the two roles do not interfere and the test compilation sees no +duplicate framework types. Because the second reference is a normal assembly reference, the generator's Roslyn dependencies also become visible to the test compilation. For a multi-target test project, build the generator @@ -180,4 +187,4 @@ dotnet add package Purview.SourceGeneratorFramework.Testing.TUnit - [Testing](Testing.md) - [Testing with TUnit](Testing-TUnit.md) - [Step-Cache Tests](Step-Cache-Tests.md) -- [Packaging](Packaging.md) \ No newline at end of file +- [Packaging](Packaging.md) diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index 466c22b..61e43ed 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -6,13 +6,18 @@ This page covers how to package a source generator that references ## How the framework packages are assembled `Purview.SourceGeneratorFramework` is dual-role: the built framework assembly ships in `lib/` so -consumers can compile generators against it, and the `analyzers/` folder carries the generator + -analyzer assemblies and their runtime dependencies. The bundled projects are: +generator projects can compile against it, and the `analyzers/` folder carries self-contained +generator, analyzer, and code-fixer assemblies. The bundled projects are: - `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; - `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; - `SourceGeneratorFramework.CodeFixers` — the code fix providers. +Each Roslyn component has the framework implementation merged and internalized into its own assembly. +The package deliberately does **not** put `Purview.SourceGeneratorFramework.dll` under +`analyzers/dotnet/cs`. Consequently, generators built against different framework versions do not +ask Roslyn to load competing versions of a same-named runtime dependency. + The shared models and helpers that used to ship as a separate `Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly (`SourceGeneratorFramework` links the `SourceGeneratorShared` sources via @@ -25,23 +30,40 @@ These projects are `IsRoslynComponent = true` and are **not** packable on their into the main package by the `SourceGeneratorFramework` project. They were previously consumed as analyzer project references, but since they now reference the framework assembly for the shared types (which would form a project-reference cycle), the `SourceGeneratorFramework` project builds them via -`GetSourceGeneratorAnalyzerFiles` and packs them under `analyzers/dotnet/cs/` in +`GetPurviewMergedAnalyzerFile` and packs them under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. The repo's pack validation (`purview-build.json`) requires the `purview.sourcegeneratorframework` package to contain, at minimum: - `lib/netstandard2.0/Purview.SourceGeneratorFramework.dll`; -- `analyzers/dotnet/cs/` versions of the framework, generators, analyzers, and code fixers; +- `analyzers/dotnet/cs/` versions of the self-contained generators, analyzers, and code fixers; - `build/Purview.SourceGeneratorFramework.props` and `build/Purview.SourceGeneratorFramework.targets`; +- `tools/net10.0/` versions of the framework-owned merge tool and its runtime files; - `README.md`, `LICENSE.md`, and `purview-logo-light.png`. PDBs are delivered only through the `.snupkg`; `*.pdb` files are forbidden inside the `.nupkg`. ## Referencing a generator from a consuming project -Use an analyzer project reference so Roslyn receives both the generator assembly and its framework -runtime dependency: +Reference the framework privately from a Roslyn component. When the generator project itself is +packed, the framework's build target replaces its output with a self-contained assembly at pack time: + +```xml + + true + + + + + +``` + +Use an analyzer project reference from a consuming project so Roslyn receives the generator +assembly: ```xml ``` -The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns both the -generator and its framework dependency without adding either file to the consuming application's -runtime references. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly remains -supported but is not required. +The Purview SDK automatically invokes `GetSourceGeneratorAnalyzerFiles`, which returns the generator +assembly without adding it to the consuming application's runtime references. By default +(`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=true`) the framework returns the **merged, +self-contained** generator from the intermediate `purview-merged/` directory, so consuming projects +and any GASF-based package compile against a generator that carries its own framework implementation +and never needs the loose `Purview.SourceGeneratorFramework.dll`. The generator's bin output is left +unmerged, so a project that references the generator assembly directly (an in-process test harness) +keeps its shared framework type identity, `InternalsVisibleTo` access, and avoids `CS0433` +collisions with the framework library. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` +explicitly remains supported but is not required. + +Set `PurviewEmbedSourceGeneratorFramework` to `false` only for a project that produces the framework +compile-time library itself. Published generator packages must not disable embedding. + +Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the generator's +analyzer-files consumers must keep the unmerged assembly + loose framework DLL shape (for example a +generator shipped into a single compiler process alongside an incompatible framework version). + +### Analyzer consumption contract + +A component that references the framework is consumed in three distinct ways, each producing a +different shape: + +| Path | Trigger | Output | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | A consuming project references the component as an analyzer | The **merged**, self-contained component, returned from the intermediate `purview-merged/` directory. The component's bin output stays unmerged, so its in-process test harness retains shared framework type identity and `InternalsVisibleTo` access without `CS0433` collisions. | +| `GetSourceGeneratorAnalyzerFiles` (opt-out) | Same, with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | Unmerged component + the loose `Purview.SourceGeneratorFramework.dll` copied from the framework package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | The framework package's own bundled-component pack, or a third-party package embedding the generator | The **merged** component from the intermediate output; the component's bin is never overwritten. | +| `GenerateNuspec` (`EmbedPurviewSourceGeneratorFrameworkForPack`) | Packing a standalone, packable generator project | The generator's bin is replaced by the **merged** self-contained DLL and the loose framework DLL is deleted before the package is written. | + +In the opt-out path the loose framework DLL is declared as a `SourceGeneratorRuntimeDependency` +(statically from the framework package `lib/` for package consumers, with a target-time fallback for +in-repo `ProjectReference` components) so the SDK copies it beside the generator before Roslyn loads +it. The merged paths never declare it. + +The merge itself (`_PurviewMergeSourceGeneratorFramework`) only writes to the component's +intermediate `purview-merged/` directory. `GetSourceGeneratorAnalyzerFiles` returns that result by +substituting the merged path into `TargetPathWithTargetPlatformMoniker` immediately before its body +runs, leaving `GetTargetPath` — which resolves assembly references — pointing at the unmerged bin. +This is what keeps the in-repo test harness working while shipped assemblies stay self-contained. + +### Self-contained analyzer validation (PSGFR39) + +The bundled `SelfContainedGeneratorAnalyzer` (PSGFR39) runs on every project that references the +framework and errors when a **non-packable** Roslyn component explicitly opts out of the default +self-contained analyzer output. Such a component, if embedded into a package through the GASF-based +pack, forces the loose `Purview.SourceGeneratorFramework.dll` under `analyzers/`, reintroducing the +shared-version hazard. + +The analyzer reads the following compiler-visible properties: +`IsRoslynComponent`, `IsPackable`, `PurviewEmbedSourceGeneratorFramework`, +`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles`, and +`PurviewSourceGeneratorFrameworkAnalyzerValidation`. It does not report when the component: + +- is not a Roslyn component, or does not reference the framework; +- disables embedding (`PurviewEmbedSourceGeneratorFramework=false`); +- is packable (`IsPackable=true`) — its own `GenerateNuspec` merge makes the package self-contained; +- keeps the default merged GASF output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=true`); +- explicitly opts out (`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`). + +Set `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` on a component that is shipped +self-contained via `GetPurviewMergedAnalyzerFile`, or that is only consumed in-repo and never packed. +Packaging an embedded generator through the raw GASF path without one of the self-contained +arrangements is an error. + +### `IsExternalInit` contract + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly** so the +framework's own bundled generators can emit `init`-based attribute types into any consumer +compilation, and so the merge step has a single marker definition to internalize. Consumers +(generator projects) must **not** declare their own `IsExternalInit`: doing so produces a duplicate +type definition against the framework reference. ### Generators embedded in another package -If the generator assembly is embedded in a different NuGet package, the outer package must make the -framework's compiler-visible properties visible to its consumers. Build assets from -`Purview.SourceGeneratorFramework` are not automatically copied into the outer package. Include a -`.props` file imported by the outer package that declares the property and its +If the generator assembly is embedded in a different NuGet package, pack the generator's **merged, +self-contained** assembly so the outer package does not ship a loose `Purview.SourceGeneratorFramework.dll`. +Call the framework's `GetPurviewMergedAnalyzerFile` target on the generator project (which merges the +framework implementation into the generator's intermediate output without touching its bin) and add +the returned file under `analyzers/dotnet/cs`, disabling the SDK's default GASF-based analyzer packing: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator assembly is also embedded at compile time for the outer package's consumers, the +outer package must make the framework's compiler-visible properties visible to those consumers. Build +assets from `Purview.SourceGeneratorFramework` are not automatically copied into the outer package. +Include a `.props` file imported by the outer package that declares the property and its `CompilerVisibleProperty` entry (see [Code-Writer.md](Code-Writer.md#generators-embedded-in-another-package)), and pack it using the outer package's ID so NuGet imports it automatically: @@ -135,6 +254,11 @@ A broadly-compatible generator project might start with: Version="$(RoslynAnalyserVersion)" PrivateAssets="all" /> + + @@ -150,6 +274,10 @@ A broadly-compatible generator project might start with: ``` +The resulting generator package contains the generator DLL under `analyzers/dotnet/cs`; it does not +contain a loose `Purview.SourceGeneratorFramework.dll`. Package validation should inspect both the +ZIP entries and the generator's assembly references to enforce that invariant. + Then centrally define: ```xml @@ -161,6 +289,29 @@ Then centrally define: The exact Roslyn baseline is a product-support decision. +## Release gates + +The following checks are the acceptance criteria for the self-contained packaging: + +1. **No assembly reference** — every shipped Roslyn component DLL + (`analyzers/dotnet/cs/*.dll`) has no assembly reference to `Purview.SourceGeneratorFramework`. + Inspect the metadata directly; do not rely on "the sample compiled". +2. **No loose framework DLL in packages** — no `.nupkg` contains + `Purview.SourceGeneratorFramework.dll` under `analyzers/`, and `*.pdb` files are forbidden in the + `.nupkg` (symbols ship only through the `.snupkg`). +3. **Merged entry points survive** — each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer`, or `CodeFixProvider` implementations. +4. **Installed-package consumer test** — a generator project that references the framework package + by `PackageReference` (with the framework's `build/` targets auto-imported) packs a single + self-contained DLL under `analyzers/dotnet/cs`, and a consumer that installs that package builds + with the generator producing output. +5. **Two-version coexistence test** — build Generator A against the current framework and + Generator B against an intentionally binary-incompatible framework version (for example a v2 + that adds a `CodeWriter` member Generator B calls). Install both packages into one consumer and + build with both package-reference orders. Both generators must run, each against its own embedded + framework copy. Under the old shared-DLL model one generator fails with `MissingMethodException` + (load-order dependent); with self-contained packaging both succeed. + ## License -This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. \ No newline at end of file +This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. diff --git a/package.json b/package.json index 17c38cd..e25a004 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-sourcegenerator-framework", - "version": "1.0.0-prerelease.47", + "version": "1.0.0-prerelease.48", "license": "MIT", "author": { "name": "Kieron Lanning", @@ -14,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/sourcegenerator-framework.git" } -} +} \ No newline at end of file diff --git a/purview-build.json b/purview-build.json index 374e2fd..b9765e0 100644 --- a/purview-build.json +++ b/purview-build.json @@ -11,12 +11,12 @@ "RequiredContent": { "purview.sourcegeneratorframework": [ "lib/netstandard2.0/Purview.SourceGeneratorFramework.dll", - "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", "build/Purview.SourceGeneratorFramework.props", "build/Purview.SourceGeneratorFramework.targets", + "tools/net10.0/Purview.SourceGeneratorFramework.MergeTool.dll", "README.md", "LICENSE.md", "purview-logo-light.png" @@ -45,4 +45,4 @@ "Release": { "Mode": "None" } -} \ No newline at end of file +} diff --git a/src/SourceGeneratorFramework.slnx b/src/SourceGeneratorFramework.slnx index 5a96ecf..189a73a 100644 --- a/src/SourceGeneratorFramework.slnx +++ b/src/SourceGeneratorFramework.slnx @@ -14,16 +14,21 @@ Path="src/SourceGeneratorFramework.CodeFixers/SourceGeneratorFramework.CodeFixers.csproj" Id="0911f5c7-139e-4ddc-9918-d4826fe0c20c" /> - - - + + + + + + + + diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 8f89971..9ce7aaf 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -14,6 +14,7 @@ PSGFR35 | Purview.SourceGeneratorFramework | Warning | Extension class name does PSGFR36 | Purview.SourceGeneratorFramework | Warning | Extension class is not placed in the extended type's namespace/folder PSGFR37 | Purview.SourceGeneratorFramework | Warning | Extension class extends multiple receiver types PSGFR38 | Purview.SourceGeneratorFramework | Warning | Extension class is missing EditorBrowsable +PSGFR39 | Purview.SourceGeneratorFramework | Error | Roslyn component must produce a self-contained analyzer TLB0014 | TypeLibrary | Warning | Type library partial extension is declared in a different namespace TLB0015 | TypeLibrary | Info | Type library partial extension must be declared 'public static partial' TLB0016 | TypeLibrary | Error | Enum value member type must be TypeIdentity or EnumValueDefinition diff --git a/src/src/SourceGeneratorFramework.Analyzers/SelfContainedGeneratorAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/SelfContainedGeneratorAnalyzer.cs new file mode 100644 index 0000000..5ebc218 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/SelfContainedGeneratorAnalyzer.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Errors when a Roslyn component (source generator, diagnostic analyzer, or code fix provider) +/// references Purview.SourceGeneratorFramework but is not configured to produce a +/// self-contained analyzer output. Packing such a component under analyzers/dotnet/cs forces +/// the loose Purview.SourceGeneratorFramework.dll into the package, reintroducing the +/// shared-version hazard where two generators with different framework versions compete in the +/// compiler process. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SelfContainedGeneratorAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR39"; + + internal const string FrameworkAssemblyName = "Purview.SourceGeneratorFramework"; + internal const string EmbedProperty = "PurviewEmbedSourceGeneratorFramework"; + internal const string MergeAnalyzerFilesProperty = "PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles"; + internal const string AnalyzerValidationProperty = "PurviewSourceGeneratorFrameworkAnalyzerValidation"; + + static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Roslyn component must produce a self-contained analyzer", + "This Roslyn component references {0} but is not configured to produce a self-contained analyzer. If it is packed (directly or embedded in another package) the package will ship the loose {0}.dll under analyzers/, reintroducing the shared-version hazard. Set {1}=true, or pack it self-contained via GetPurviewMergedAnalyzerFile (see Packaging.md). If this component is only used in-repo or is shipped via GetPurviewMergedAnalyzerFile, set {2}=false.", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Detects Roslyn components that reference Purview.SourceGeneratorFramework without producing a self-contained analyzer output, which would force the loose framework DLL to be shipped under analyzers/ in any package that embeds them.", + customTags: ["CompilationEnd"] + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(context => + { + var options = context.Options.AnalyzerConfigOptionsProvider; + var tree = context.Compilation.SyntaxTrees.FirstOrDefault(); + var analyzerOptions = tree is not null ? options.GetOptions(tree) : options.GlobalOptions; + + if (!IsExplicitlyTrue(analyzerOptions, "IsRoslynComponent")) + return; + + if (!ReferencesSourceGeneratorFramework(context.Compilation)) + return; + + // Merge disabled: only the framework compile-time library opts out of embedding. + if (IsExplicitlyFalse(analyzerOptions, EmbedProperty)) + return; + + // A packable generator merges at GenerateNuspec and deletes the loose DLL itself. + if (IsExplicitlyTrue(analyzerOptions, "IsPackable")) + return; + + // GetSourceGeneratorAnalyzerFiles returns a merged, self-contained component. + if (IsExplicitlyTrue(analyzerOptions, MergeAnalyzerFilesProperty)) + return; + + // Explicit acknowledgement that the component is never packed, or is shipped + // self-contained via GetPurviewMergedAnalyzerFile. + if (IsExplicitlyFalse(analyzerOptions, AnalyzerValidationProperty)) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + tree?.GetRoot().GetLocation() ?? Location.None, + FrameworkAssemblyName, + MergeAnalyzerFilesProperty, + AnalyzerValidationProperty + ) + ); + }); + } + + static bool ReferencesSourceGeneratorFramework(Compilation compilation) => + compilation + .References.Select(compilation.GetAssemblyOrModuleSymbol) + .OfType() + .Any(static assembly => + string.Equals(assembly.Identity.Name, FrameworkAssemblyName, StringComparison.OrdinalIgnoreCase) + ); + + static bool IsExplicitlyTrue(AnalyzerConfigOptions options, string propertyName) => + options.TryGetValue("build_property." + propertyName, out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + static bool IsExplicitlyFalse(AnalyzerConfigOptions options, string propertyName) => + options.TryGetValue("build_property." + propertyName, out var value) + && string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj index 02a0e3e..940b238 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj +++ b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj @@ -1,6 +1,7 @@  true + true - + + + <_PurviewSourceGeneratorFrameworkAssemblyFullPath>$([System.IO.Path]::GetFullPath('$(_PurviewSourceGeneratorFrameworkAssembly)')) + <_PurviewResolvedFrameworkAssembly Condition="Exists('$(_PurviewSourceGeneratorFrameworkAssemblyFullPath)')" + >$(_PurviewSourceGeneratorFrameworkAssemblyFullPath) + <_PurviewResolvedFrameworkAssembly + Condition="'$(_PurviewResolvedFrameworkAssembly)' == '' AND Exists('$(TargetDir)Purview.SourceGeneratorFramework.dll')" + >$(TargetDir)Purview.SourceGeneratorFramework.dll + + + <_PurviewSourceGeneratorFrameworkReference + Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension)' == 'Purview.SourceGeneratorFramework.dll'" + /> + + + <_PurviewResolvedFrameworkAssembly>@(_PurviewSourceGeneratorFrameworkReference->'%(FullPath)') + + + + + Purview.SourceGeneratorFramework.dll + + + + + + Purview.SourceGeneratorFramework.dll + + + - + - + + + + + <_PurviewMergedComponentDirectory>$(IntermediateOutputPath)purview-merged\ + <_PurviewMergedComponent>$(_PurviewMergedComponentDirectory)$(TargetFileName) + <_PurviewMergedComponentPdb>$(_PurviewMergedComponentDirectory)$(TargetName).pdb + + + + + + + + + + + + + + + + + + + + + + <_PurviewMergedComponentFullPath>$([System.IO.Path]::GetFullPath('$(_PurviewMergedComponent)')) + + + <_PurviewMergedAnalyzerFileOutput + Include="$(_PurviewMergedComponentFullPath)" + Condition="Exists('$(_PurviewMergedComponentFullPath)')" + /> + + + + + + + + diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index 9bbbab6..aadca28 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -1,12 +1,12 @@  true - true + false + netstandard2.0 true @@ -15,6 +15,7 @@ false + false $(TargetsForTfmSpecificContentInPackage);BuildAndPackBundledAnalyzerAssemblies @@ -48,9 +49,9 @@ > @@ -61,6 +62,10 @@ > analyzers/dotnet/cs/ + <_PurviewMergeToolPackageFile Include="..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\net10.0\*.dll;..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\net10.0\*.json"> + tools/net10.0/%(_PurviewMergeToolPackageFile.Filename)%(_PurviewMergeToolPackageFile.Extension) + + diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AnalyzerTestHelpers.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AnalyzerTestHelpers.cs index 11750ce..86f0917 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AnalyzerTestHelpers.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AnalyzerTestHelpers.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -10,24 +11,90 @@ public static async Task> GetAnalyzerDiagnosticsAsync( this DiagnosticAnalyzer analyzer, string source, CancellationToken cancellationToken = default + ) => await GetAnalyzerDiagnosticsAsync(analyzer, source, null, false, cancellationToken); + + /// + /// Runs the analyzer against the supplied source, optionally exposing build_property.* + /// values through the analyzer config options and adding a reference to + /// Purview.SourceGeneratorFramework.dll. + /// + public static async Task> GetAnalyzerDiagnosticsAsync( + this DiagnosticAnalyzer analyzer, + string source, + IReadOnlyDictionary? buildProperties, + bool referenceSourceGeneratorFramework, + CancellationToken cancellationToken = default ) { - var syntaxTree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); - var references = new[] + var compilation = CreateTestCompilation(source, referenceSourceGeneratorFramework); + + var analyzerOptions = buildProperties is null + ? null + : TestAnalyzerConfigOptions.CreateAnalyzerOptions(buildProperties); + + var compilationWithAnalyzers = analyzerOptions is null + ? compilation.WithAnalyzers([analyzer]) + : compilation.WithAnalyzers([analyzer], analyzerOptions); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken); + } + + static CSharpCompilation CreateTestCompilation(string source, bool referenceSourceGeneratorFramework) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + List references = new() { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location), MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location), }; - var compilation = CSharpCompilation.Create( - "TestAssembly", - [syntaxTree], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) - ); + if (referenceSourceGeneratorFramework) + { + references.Add(MetadataReference.CreateFromFile(typeof(CodeWriter).Assembly.Location)); + } - var compilationWithAnalyzers = compilation.WithAnalyzers([analyzer]); + CSharpCompilationOptions compilationOptions = new(OutputKind.DynamicallyLinkedLibrary); - return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken); + return CSharpCompilation.Create("TestAssembly", [syntaxTree], references, compilationOptions); + } +} + +/// +/// Minimal that exposes a fixed set of +/// build_property.* values to analyzers in an in-memory test compilation. +/// +static class TestAnalyzerConfigOptions +{ + public static AnalyzerOptions CreateAnalyzerOptions(IReadOnlyDictionary buildProperties) => + new(ImmutableArray.Empty, CreateProvider(buildProperties)); + + public static AnalyzerConfigOptionsProvider CreateProvider(IReadOnlyDictionary buildProperties) => + new Provider(new Options(ImmutableDictionary.CreateRange(StringComparer.Ordinal, buildProperties))); + + sealed class Provider(AnalyzerConfigOptions options) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions => options; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => options; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => options; + } + + sealed class Options(ImmutableDictionary values) : AnalyzerConfigOptions + { + public override bool TryGetValue( + string key, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value + ) + { + if (values.TryGetValue(key, out var resolved)) + { + value = resolved; + return true; + } + + value = null; + return false; + } } } diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SelfContainedGeneratorAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SelfContainedGeneratorAnalyzerTests.cs new file mode 100644 index 0000000..ab4ed14 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SelfContainedGeneratorAnalyzerTests.cs @@ -0,0 +1,150 @@ +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class SelfContainedGeneratorAnalyzerTests +{ + static readonly Dictionary DefaultProperties = new() + { + ["build_property.IsRoslynComponent"] = "true", + ["build_property.IsPackable"] = "false", + ["build_property.PurviewEmbedSourceGeneratorFramework"] = "true", + ["build_property.PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles"] = "false", + ["build_property.PurviewSourceGeneratorFrameworkAnalyzerValidation"] = "true", + }; + + [Test] + public async Task GivenNonSelfContainedRoslynComponent_ReportsError(CancellationToken cancellationToken) + { + // Arrange + SelfContainedGeneratorAnalyzer analyzer = new(); + + // Act + var diagnostics = await analyzer.GetAnalyzerDiagnosticsAsync( + "class C { }", + DefaultProperties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert + .That(diagnostics.Select(static d => d.Id).ToArray()) + .Contains(SelfContainedGeneratorAnalyzer.DiagnosticId); + } + + [Test] + public async Task GivenNotRoslynComponent_DoesNotReport(CancellationToken cancellationToken) + { + // Arrange + Dictionary properties = new(DefaultProperties) + { + ["build_property.IsRoslynComponent"] = "false", + }; + + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + properties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } + + [Test] + public async Task GivenNoFrameworkReference_DoesNotReport(CancellationToken cancellationToken) + { + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + DefaultProperties, + referenceSourceGeneratorFramework: false, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } + + [Test] + public async Task GivenPackableGenerator_DoesNotReport(CancellationToken cancellationToken) + { + // Arrange + Dictionary properties = new(DefaultProperties) { ["build_property.IsPackable"] = "true" }; + + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + properties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } + + [Test] + public async Task GivenMergedAnalyzerFiles_DoesNotReport(CancellationToken cancellationToken) + { + // Arrange + Dictionary properties = new(DefaultProperties) + { + ["build_property.PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles"] = "true", + }; + + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + properties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } + + [Test] + public async Task GivenValidationOptOut_DoesNotReport(CancellationToken cancellationToken) + { + // Arrange + Dictionary properties = new(DefaultProperties) + { + ["build_property.PurviewSourceGeneratorFrameworkAnalyzerValidation"] = "false", + }; + + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + properties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } + + [Test] + public async Task GivenEmbeddingDisabled_DoesNotReport(CancellationToken cancellationToken) + { + // Arrange + Dictionary properties = new(DefaultProperties) + { + ["build_property.PurviewEmbedSourceGeneratorFramework"] = "false", + }; + + // Act + var diagnostics = await new SelfContainedGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( + "class C { }", + properties, + referenceSourceGeneratorFramework: true, + cancellationToken + ); + + // Assert + await Assert.That(diagnostics.Any(static d => d.Id == SelfContainedGeneratorAnalyzer.DiagnosticId)).IsFalse(); + } +}