diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8d48b3e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,108 @@ +# Default: auto-detect text and normalise to LF in the index. +# Everything below only overrides the *working tree* checkout behaviour. +* text=auto + +# --------------------------------------------------------------------------- +# .NET source and project files +# No eol= means each dev's core.autocrlf / core.eol decides. Set eol=lf here +# instead if you want to hard-enforce LF everywhere -- but keep it in sync +# with end_of_line in .editorconfig or `dotnet format --verify-no-changes` +# will fail in CI. +# --------------------------------------------------------------------------- +*.cs text diff=csharp +*.csx text diff=csharp +*.vb text +*.fs text +*.fsx text +*.csproj text +*.fsproj text +*.vbproj text +*.props text +*.targets text +*.resx text +*.config text +*.ruleset text +*.xaml text +*.cshtml text diff=html +*.razor text diff=html +*.sln text eol=lf +*.slnx text eol=lf + +# --------------------------------------------------------------------------- +# Data, config and docs +# --------------------------------------------------------------------------- +*.json text +*.xml text +*.yml text +*.yaml text +*.toml text +*.md text diff=markdown +*.svg text +*.editorconfig text +.gitattributes text +.gitignore text + +# --------------------------------------------------------------------------- +# Web assets (only relevant if the repo ships any) +# --------------------------------------------------------------------------- +*.js text +*.jsx text +*.ts text +*.tsx text +*.html text diff=html +*.css text +*.scss text + +# --------------------------------------------------------------------------- +# Scripts -- these MUST be pinned regardless of platform. +# cmd.exe misparses LF in multi-line blocks and labels; sh needs LF. +# --------------------------------------------------------------------------- +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=lf +*.psm1 text eol=lf +*.psd1 text eol=lf +Dockerfile text eol=lf +*.dockerfile text eol=lf +justfile text eol=lf +Makefile text eol=lf + +# Patches carry their own endings -- never touch them. +*.patch -text + +# --------------------------------------------------------------------------- +# Binary +# --------------------------------------------------------------------------- +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.mp4 binary +*.zip binary +*.gz binary +*.7z binary +*.tar binary +*.exe binary +*.dll binary +*.pdb binary +*.obj binary +*.vsix binary +*.nupkg binary +*.snupkg binary +*.snk binary +*.pfx binary +*.lockb binary +*.woff binary +*.woff2 binary + +# --------------------------------------------------------------------------- +# Keep repo scaffolding out of `git archive` / source packages +# --------------------------------------------------------------------------- +.gitattributes export-ignore +.gitignore export-ignore +.github/ export-ignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6aac13e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,19 @@ +# GitHub Copilot Instructions + +## Primary instruction source + +Use the repository root [`AGENTS.md`](../AGENTS.md) as the **primary** source of truth for behavior, architecture context, testing standards, and completion criteria. + +If this file and `AGENTS.md` appear to conflict, prefer `AGENTS.md` unless this file explicitly states a GitHub Copilot-only exception. + +## Copilot-specific guidance + +This file should only contain **GitHub Copilot-specific** instruction details. +Keep product, architecture, and general engineering standards centralized in `AGENTS.md`. + +## Operating expectations for Copilot + +- Apply the `AGENTS.md` testing bar strictly (TUnit/TUnit.Mocks, AAA comments, naming, cancellation token rule). +- Treat work as incomplete until relevant tests pass. +- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution quality. +- Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..a66914e --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,28 @@ +name: PR + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "10.0.x" + + - name: Run PR pipeline + run: dotnet run --project build/PipelineCLI/PipelineCLI.csproj --configuration Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a92ee18 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release packages + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "10.0.x" + + - name: Check for version bump + id: version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').version") + TAG="v$VERSION" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Version $VERSION is already tagged as $TAG. Skipping release." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + echo "New version $VERSION detected. Releasing $TAG." + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + fi + + - name: Run release pipeline + if: steps.version.outputs.should_publish == 'true' + env: + Release__ShouldPublish: true + NuGet__ApiKey: ${{ secrets.NUGET_API_KEY }} + run: dotnet run --project build/PipelineCLI/PipelineCLI.csproj --configuration Release diff --git a/.gitignore b/.gitignore index 1d0682d..1baaa36 100644 --- a/.gitignore +++ b/.gitignore @@ -659,3 +659,4 @@ sketch BenchmarkDotNet.Artifacts/ !**/Sdk/build +!build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0fa68ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Instructions + +This repository contains custom build rules, analysers, and source generators. Agents working in this codebase should follow the guidelines below. + +## Warnings and Suggestions + +Do **not** suppress or mute compiler, analyser, or build warnings by adding `` entries, `#pragma warning disable`, or similar directives in code or project files without explicit user direction. Warnings and suggestions are the responsibility of the developer/user to evaluate and mute. If a warning is raised, surface it to the user and let them decide whether to suppress it. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5fba26d..23c42bb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,24 +2,29 @@ true [4.13.0,) - [5.6.0,) - [1.61.0,) + [5.9.0,) + [1.65.51,) + [3.2.8,) - + + + + + + - - + + + - - - - + + diff --git a/Justfile b/Justfile index cb3e2f1..fb6706f 100644 --- a/Justfile +++ b/Justfile @@ -5,51 +5,108 @@ solution := root_folder / "SourceGeneratorFramework.slnx" build_configuration := "Release" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*/" +pipeline_solution := "build/Pipeline.slnx" +pipeline_project := "build/PipelineCLI/PipelineCLI.csproj" + current_version := `node -p "require('./package.json').version"` [private] default: just --list +# Run the PR pipeline (restore, build, lint, tests) +[group('Pipeline')] +pipeline-pr *args: + echo "Running PR pipeline..." + dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} {{ args }} + +# Run the build pipeline (restore, build, lint) +[group('Pipeline')] +pipeline-build *args: + echo "Running build pipeline..." + dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Build:RunTests=false --Release:Mode=None {{ args }} + +# Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) +[group('Pipeline')] +pipeline-release *args: + echo "Running release pipeline..." + dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Release:Mode=NuGet {{ args }} + +# Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) +# Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. +# Always use forward slashes for the feed path, e.g. +# just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +[group('Pipeline')] +pipeline-local-release *args: + echo "Running local release pipeline..." + dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Release:Mode=LocalNuGet {{ args }} + +# Run the pipeline with tests enabled +[group('Pipeline')] +pipeline-tests *args: + echo "Running tests pipeline..." + dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Build:RunTests=true --Release:Mode=None {{ args }} + # Build and test with the specified configuration, defaulting to "Release" +[group('Build and Test')] build solutionOrProject=solution configuration=build_configuration: echo "Building {{ BLUE }}{{ solutionOrProject }}{{ NORMAL }} with configuration {{ YELLOW }}{{ configuration }}{{ NORMAL }}" dotnet build {{ solutionOrProject }} -c {{ configuration }} # Run tests with the specified configuration, defaulting to "Release" -tests solutionOrProject=solution configuration=build_configuration filter=default_test_filter *args: +[group('Build and Test')] +test solutionOrProject=solution configuration=build_configuration filter=default_test_filter *args: echo "Running tests for {{ BLUE }}{{ solutionOrProject }}{{ NORMAL }} with configuration {{ YELLOW }}{{ configuration }}{{ NORMAL }} and filter {{ GREEN }}{{ filter }}{{ NORMAL }}" dotnet test {{ solutionOrProject }} -c {{ configuration }} --treenode-filter "{{ filter }}" {{ args }} -# Run tests with the specified configuration, defaulting to "Release" +# Clean all projects with the specified configuration, defaulting to "Release" +[group('Build and Test')] clean solutionOrProject=solution configuration=build_configuration *args: echo "Cleaning {{ BLUE }}{{ solutionOrProject }}{{ NORMAL }} with configuration {{ YELLOW }}{{ configuration }}{{ NORMAL }}" dotnet clean {{ solutionOrProject }} -c {{ configuration }} {{ args }} +# Clean all projects, across Debug and Release configurations +[group('Build and Test')] +clean-all *args: + echo "Cleaning all projects with configuration" + dotnet clean {{ solution }} -c Release {{ args }} + dotnet clean {{ solution }} -c Debug {{ args }} + # Run tests with the specified configuration, defaulting to "Release" +[group('Build and Test')] restore solutionOrProject=solution: echo "Restoring dependencies for {{ BLUE }}{{ solutionOrProject }}{{ NORMAL }}" dotnet restore {{ solutionOrProject }} # Create NuGet package for the project +[group('Build and Test')] pack solutionOrProject=solution configuration=build_configuration publish_folder=artifacts_folder: echo "Packing {{ BLUE }}{{ solutionOrProject }}{{ NORMAL }} with configuration {{ YELLOW }}{{ configuration }}{{ NORMAL }} to {{ GREEN }}{{ publish_folder }}{{ NORMAL }}" dotnet pack {{ solutionOrProject }} -c {{ configuration }} -o {{ publish_folder }} # Display the current version of the project +[group('Build and Test')] version: echo "Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }}" # Check code formatting using CSharpier +[group('Utilities')] lint-check: dotnet csharpier check . # dotnet format --verify-no-changes {{ solution }} # Fix code formatting issues using CSharpier +[group('Utilities')] lint-fix: dotnet csharpier format . # dotnet format {{ solution }} # Open the solution in Visual Studio/ Registered application +[group('Utilities')] vs: open {{ solution }} + +# Open the solution in Visual Studio/ Registered application +[group('Utilities')] +vs-pipeline: + open {{ pipeline_solution }} diff --git a/README.md b/README.md index aefb626..8609fc3 100644 --- a/README.md +++ b/README.md @@ -9,30 +9,26 @@ A set of libraries for building and testing incremental C# source generators usi | [`Purview.SourceGeneratorFramework`](src/src/SourceGeneratorFramework) | Core helpers, models, and MSBuild integration for writing incremental source generators. | Yes | | [`Purview.SourceGeneratorFramework.Testing`](src/src/SourceGeneratorFramework.Testing) | Framework-agnostic test runner and assertions for source generator unit tests. | Yes | | [`Purview.SourceGeneratorFramework.Testing.TUnit`](src/src/SourceGeneratorFramework.Testing.TUnit) | TUnit-specific test base classes and assertions for source generator tests. | Yes | -| [`SourceGeneratorFramework.Testing.Generators`](src/src/SourceGeneratorFramework.Testing.Generators) | Internal Roslyn source generator used by the framework package. | No | -| [`SourceGeneratorFramework.ExampleGenerator`](src/src/SourceGeneratorFramework.ExampleGenerator) | Reference implementation showing how to build a generator with the framework. | No | +| [`Purview.SourceGeneratorFramework.Generators`](src/src/SourceGeneratorFramework.Generators) | Internal Roslyn source generator used by the framework package. | No | +| [`Purview.SourceGeneratorFramework.ExampleGenerator`](src/src/SourceGeneratorFramework.ExampleGenerator) | Reference implementation showing how to build a generator with the framework. | No | ## Requirements -- .NET SDK 8.0 or later +- .NET SDK 10.0 or later - The test projects target `net8.0`, `net9.0`, and `net10.0` - Source generators target `netstandard2.0` ## Building -Restore and build the solution using the `just` recipes or `dotnet` directly: +Restore and build the solution: ```bash -just build -# or dotnet build src/SourceGeneratorFramework.slnx -c Release ``` ## Running tests ```bash -just tests -# or dotnet test src/SourceGeneratorFramework.slnx -c Release ``` @@ -46,7 +42,7 @@ normal reference with `ReferenceOutputAssembly="true"`. The complete pattern is ### AttributeDataModelGenerator -The framework package includes `AttributeDataModelGenerator` (implemented in `SourceGeneratorFramework.Testing.Generators`), which generates `readonly record struct` parser models for .NET attributes. It removes the repetitive boilerplate of hand-writing `FromAttributeData` methods for every attribute you want to inspect in a source generator. +The framework package includes `AttributeDataModelGenerator` (implemented in `Purview.SourceGeneratorFramework.Generators`), which generates `readonly record struct` parser models for .NET attributes. It removes the repetitive boilerplate of hand-writing `FromAttributeData` methods for every attribute you want to inspect in a source generator. Supported features: - Manual mapping of named arguments, constructor arguments by index, and constructor arguments by name @@ -56,13 +52,11 @@ Supported features: - Optional `DefaultValue` runtime fallback when a property is not found on the attribute - An `Empty` sentinel that uses `default(T)` for every property -See the [`SourceGeneratorFramework.Testing.Generators` README](src/src/SourceGeneratorFramework.Testing.Generators) for examples, including validation attributes with nested types. +See the [`Purview.SourceGeneratorFramework.Generators` README](src/src/SourceGeneratorFramework.Generators) for examples, including validation attributes with nested types. ## Packaging ```bash -just pack -# or dotnet pack src/SourceGeneratorFramework.slnx -c Release -o ./artifacts ``` diff --git a/agents/sdk-consumer-setup.md b/agents/sdk-consumer-setup.md new file mode 100644 index 0000000..b356a41 --- /dev/null +++ b/agents/sdk-consumer-setup.md @@ -0,0 +1,34 @@ +# sdk-consumer-setup (generic agent spec) + +## Goal + +Help a consuming repository adopt or troubleshoot `Purview.DotNetProjectSdk` correctly, without breaking existing build behaviour. + +## Workflow + +1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via + `` and the matching `Sdk.targets` import. +2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect + evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`. +3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI + variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of + silently skipping resolution. +4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage` + (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via + `EnsureAgentFolderInPackageTarget`. +5. For test-framework or project-shape questions, confirm the project follows repo naming and placement + conventions the SDK expects, rather than introducing bespoke structure. +6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm + the fix. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to + opt out. +- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally + project-specific. + +## Related skill + +See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference. diff --git a/build/Directory.Build.props b/build/Directory.Build.props new file mode 100644 index 0000000..b614f1c --- /dev/null +++ b/build/Directory.Build.props @@ -0,0 +1,12 @@ + + + Purview.Aspire.ResourceKit + true + + + + + + $(NoWarn);CA1062;CA1515;CA2007;CA1873; + + diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets new file mode 100644 index 0000000..a3bbd31 --- /dev/null +++ b/build/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/build/Pipeline.slnx b/build/Pipeline.slnx new file mode 100644 index 0000000..410fbba --- /dev/null +++ b/build/Pipeline.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/build/PipelineCLI/GlobalUsings.cs b/build/PipelineCLI/GlobalUsings.cs new file mode 100644 index 0000000..0835bee --- /dev/null +++ b/build/PipelineCLI/GlobalUsings.cs @@ -0,0 +1,11 @@ +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +global using ModularPipelines; +global using ModularPipelines.Extensions; +global using Octokit; +global using Octokit.Internal; +global using Purview.Aspire.ResourceKit.PipelineCLI.Helpers; +global using Purview.Aspire.ResourceKit.PipelineCLI.Modules; +global using Purview.Aspire.ResourceKit.PipelineCLI.Settings; diff --git a/build/PipelineCLI/Helpers/DotNetCLIOptions.cs b/build/PipelineCLI/Helpers/DotNetCLIOptions.cs new file mode 100644 index 0000000..7f8dc73 --- /dev/null +++ b/build/PipelineCLI/Helpers/DotNetCLIOptions.cs @@ -0,0 +1,9 @@ +using ModularPipelines.Options; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Helpers; + +public sealed record DotNetCLIOptions : CommandLineToolOptions +{ + public static DotNetCLIOptions Create(params string[] commandParts) => + new() { Tool = "dotnet", CommandParts = commandParts }; +} diff --git a/build/PipelineCLI/Helpers/PathHelpers.cs b/build/PipelineCLI/Helpers/PathHelpers.cs new file mode 100644 index 0000000..0c7f972 --- /dev/null +++ b/build/PipelineCLI/Helpers/PathHelpers.cs @@ -0,0 +1,18 @@ +namespace Purview.Aspire.ResourceKit.PipelineCLI.Helpers; + +static class PathHelpers +{ + public static string FindRepositoryRoot(string startDirectory) + { + var directory = new DirectoryInfo(startDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "package.json"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate the repository root (no package.json found)."); + } +} diff --git a/build/PipelineCLI/Helpers/TestHelpers.cs b/build/PipelineCLI/Helpers/TestHelpers.cs new file mode 100644 index 0000000..c5eaa1a --- /dev/null +++ b/build/PipelineCLI/Helpers/TestHelpers.cs @@ -0,0 +1,39 @@ +namespace Purview.Aspire.ResourceKit.PipelineCLI.Helpers; + +static class TestHelpers +{ + public static string BuildTUnitTreeNodeFilter( + string? assembly = null, + string? @namespace = null, + string? className = null, + string? testNameQuery = null + ) + { + var filter = "/"; + filter += assembly switch + { + null => "*", + _ => assembly, + }; + + filter += @namespace switch + { + null => "*", + _ => @namespace, + }; + + filter += className switch + { + null => "*", + _ => className, + }; + + filter += testNameQuery switch + { + null => "*", + _ => testNameQuery, + }; + + return filter; + } +} diff --git a/build/PipelineCLI/Modules/BuildModule.cs b/build/PipelineCLI/Modules/BuildModule.cs new file mode 100644 index 0000000..fddc45b --- /dev/null +++ b/build/PipelineCLI/Modules/BuildModule.cs @@ -0,0 +1,30 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public class BuildModule(IOptions settings) : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + return await context + .DotNet() + .Build( + new() + { + ProjectSolution = settings.Value.Solution, + Configuration = settings.Value.Configuration, + NoRestore = true, + }, + cancellationToken: cancellationToken + ); + } +} diff --git a/build/PipelineCLI/Modules/CreateGitHubReleaseModule.cs b/build/PipelineCLI/Modules/CreateGitHubReleaseModule.cs new file mode 100644 index 0000000..14de6dd --- /dev/null +++ b/build/PipelineCLI/Modules/CreateGitHubReleaseModule.cs @@ -0,0 +1,60 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.GitHub.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Release")] +[DependsOn] +[DependsOn] +public class CreateGitHubReleaseModule(IOptions releaseSettings, IOptions gitSettings) + : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + releaseSettings.Value.Mode is ReleaseMode.NuGet or ReleaseMode.GitHubRelease + ? SkipDecision.DoNotSkip + : SkipDecision.Skip( + "Release publishing is disabled. Set Release__Mode=GitHubRelease or Release__Mode=NuGet to create a GitHub release." + ) + ) + .WithSkipWhen(_ => + string.IsNullOrWhiteSpace(gitSettings.Value.GetGitHubToken()) + ? SkipDecision.Skip( + "GitHub access token is not configured. Set GitHub__AccessToken or GITHUB_TOKEN to create a GitHub release." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) + { + var versionResult = await context.GetModule(); + var version = + versionResult.ValueOrDefault + ?? throw new InvalidOperationException("The version was not produced by the version module."); + + var tag = $"v{version}"; + + var repositoryIdString = context.GitHub().EnvironmentVariables.RepositoryId; + if (!long.TryParse(repositoryIdString, out var repositoryId)) + { + throw new InvalidOperationException( + $"Failed to parse RepositoryId '{repositoryIdString}' as a valid long integer." + ); + } + + // Create a new release on GitHub with the specified tag and generate release notes + return await context + .GitHub() + .Client.Repository.Release.Create( + repositoryId, + new NewRelease(tag) { Name = tag, GenerateReleaseNotes = true } + ); + } +} diff --git a/build/PipelineCLI/Modules/LintModule.cs b/build/PipelineCLI/Modules/LintModule.cs new file mode 100644 index 0000000..77d30e8 --- /dev/null +++ b/build/PipelineCLI/Modules/LintModule.cs @@ -0,0 +1,28 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +public sealed class LintModule : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var dotnet = context.DotNet(); + await dotnet.Tool.Restore(new() { Interactive = false }, new(), cancellationToken); + + var pipelineDirectory = PipelineProjectDirectory.Find(); + var repositoryRoot = PathHelpers.FindRepositoryRoot(pipelineDirectory); + + return await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("tool", "run", "csharpier", "check", repositoryRoot), + cancellationToken: cancellationToken + ); + } +} diff --git a/build/PipelineCLI/Modules/PackModule.cs b/build/PipelineCLI/Modules/PackModule.cs new file mode 100644 index 0000000..8313cd8 --- /dev/null +++ b/build/PipelineCLI/Modules/PackModule.cs @@ -0,0 +1,55 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +[DependsOn] +[DependsOn] +public sealed class PackModule(IOptions settings, IOptions releaseSettings) + : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + releaseSettings.Value.Mode != ReleaseMode.None + ? SkipDecision.DoNotSkip + : SkipDecision.Skip( + "Packing is disabled. Set Release__Mode to something other than None to enable it." + ) + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var versionResult = await context.GetModule(); + var nugetVersion = + versionResult.ValueOrDefault + ?? throw new InvalidOperationException("The version was not produced by the version module."); + + Directory.CreateDirectory(settings.Value.ArtifactsFolder); + + var version = nugetVersion.ToString(); + return await context + .DotNet() + .Pack( + new DotNetPackOptions + { + ProjectSolution = settings.Value.Solution, + Configuration = settings.Value.Configuration, + Output = settings.Value.ArtifactsFolder, + Properties = [("PackageVersion", version), ("Version", version)], + }, + cancellationToken: cancellationToken + ); + } +} diff --git a/build/PipelineCLI/Modules/PublishLocalNuGetModule.cs b/build/PipelineCLI/Modules/PublishLocalNuGetModule.cs new file mode 100644 index 0000000..3dc4978 --- /dev/null +++ b/build/PipelineCLI/Modules/PublishLocalNuGetModule.cs @@ -0,0 +1,197 @@ +using System.ComponentModel.DataAnnotations; +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.Models; +using ModularPipelines.Modules; +using NuGet.Versioning; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public class PublishLocalNuGetModule( + IOptions localNuGetFeedSettings, + IOptions releaseSettings, + IOptions buildSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(ctx => + ctx.IsRunningLocally() + ? SkipDecision.DoNotSkip + : SkipDecision.Skip("Local NuGet Feed publishing is disabled. This module can only be run locally.") + ) + .WithSkipWhen(_ => + releaseSettings.Value.Mode == ReleaseMode.LocalNuGet + ? SkipDecision.DoNotSkip + : SkipDecision.Skip( + "Local NuGet Feed publishing is disabled. Set Release__Mode=LocalNuGet to enable it." + ) + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var localFeedPath = localNuGetFeedSettings.Value.LocalFeedPath; + + var validationResults = new List(); + var validationContext = new ValidationContext(localNuGetFeedSettings.Value); + if ( + !Validator.TryValidateObject( + localNuGetFeedSettings.Value, + validationContext, + validationResults, + validateAllProperties: true + ) + ) + { + foreach (var validationResult in validationResults) + context.Logger.LogError("{Message}", validationResult.ErrorMessage); + + throw new InvalidOperationException( + $"Invalid {nameof(PublishLocalNuGetSettings)} configuration for {nameof(PublishLocalNuGetSettings.LocalFeedPath)}. " + + "Windows paths with backslashes may have been stripped by the shell; " + + "use forward slashes, e.g. --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/." + ); + } + + var fullLocalFeedPath = Path.GetFullPath(localFeedPath); + context.Logger.LogInformation("Publishing local NuGet packages to {LocalFeedPath}.", fullLocalFeedPath); + + if (!Directory.Exists(fullLocalFeedPath)) + Directory.CreateDirectory(fullLocalFeedPath); + + var packages = Directory.GetFiles(buildSettings.Value.ArtifactsFolder, "*.s*nupkg").ToArray(); + if (packages.Length == 0) + { + throw new InvalidOperationException( + $"No packages found in {buildSettings.Value.ArtifactsFolder}. The local feed was not populated." + ); + } + + List nupkgPackages = []; + foreach (var package in packages) + { + var fileName = Path.GetFileName(package); + var destinationPath = Path.Combine(fullLocalFeedPath, fileName); + + if (Path.GetExtension(fileName) == ".nupkg") + nupkgPackages.Add(await ParsePackageDetailsAsync(package, cancellationToken)); + + if (!localNuGetFeedSettings.Value.OverwriteExistingPackages && File.Exists(destinationPath)) + { + context.Logger.LogInformation("Package {Package} already exists in local feed. Skipping.", fileName); + File.Delete(package); + + continue; + } + + File.Move(package, destinationPath, true); + context.Logger.LogInformation("Copied package {Package} to local feed.", fileName); + } + + if (localNuGetFeedSettings.Value.ClearPackageCache) + { + context.Logger.LogInformation("Clearing local NuGet package cache..."); + + var globalPackagesResult = await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("nuget", "locals", "global-packages", "--list"), + cancellationToken: cancellationToken + ); + if (globalPackagesResult.ExitCode != 0) + return globalPackagesResult; + + var httpCacheResult = await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("nuget", "locals", "http-cache", "--list"), + cancellationToken: cancellationToken + ); + if (httpCacheResult.ExitCode != 0) + return httpCacheResult; + + var globalPackagePaths = globalPackagesResult + .StandardOutput.Replace("global-packages: ", "", StringComparison.Ordinal) + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(Directory.Exists); + + var httpCachePaths = httpCacheResult + .StandardOutput.Replace("http-cache: ", "", StringComparison.Ordinal) + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Where(Directory.Exists); + + foreach (var artifact in nupkgPackages) + { +#pragma warning disable CA1308 // Normalize strings to uppercase + var loweredPackageId = artifact.PackageId.ToLowerInvariant(); + var loweredVersion = artifact.Version.ToFullString().ToLowerInvariant(); +#pragma warning restore CA1308 // Normalize strings to uppercase + + foreach (var globalPath in globalPackagePaths) + { + var packagePath = Path.Combine(globalPath, loweredPackageId, artifact.Version.ToFullString()); + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + context.Logger.LogInformation( + "Deleted package {Package} version {Version} from global packages cache.", + artifact.PackageId, + artifact.Version + ); + } + } + foreach (var httpCachePath in httpCachePaths) + { + string[] packagePaths = + [ + Path.Combine(httpCachePath, "list_" + loweredPackageId + ".dat"), + Path.Combine(httpCachePath, "list_" + loweredPackageId + "_index.dat"), + Path.Combine(httpCachePath, "list_" + loweredPackageId + "_range_*.dat"), + Path.Combine(httpCachePath, "nupkg_" + loweredPackageId + "." + loweredVersion + ".dat"), + ]; + + foreach (var path in packagePaths) + { + var directory = Path.GetDirectoryName(path); + var pattern = Path.GetFileName(path); + foreach (var file in Directory.EnumerateFiles(directory!, pattern, SearchOption.AllDirectories)) + { + File.Delete(file); + context.Logger.LogInformation( + "Deleted package {Package} version {Version} from HTTP cache.", + artifact.PackageId, + artifact.Version + ); + } + } + } + } + } + + if (localNuGetFeedSettings.Value.ShutdownDotnetBuilderServer) + { + context.Logger.LogInformation("Shutting down dotnet builder server..."); + + return await context.Shell.Command.ExecuteCommandLineTool( + DotNetCLIOptions.Create("build-server", "shutdown"), + cancellationToken: cancellationToken + ); + } + + return null; + } + + static async Task ParsePackageDetailsAsync(string artifact, CancellationToken cancellationToken) + { + using var packageReader = new NuGet.Packaging.PackageArchiveReader(artifact); + var packaging = await packageReader.GetNuspecReaderAsync(cancellationToken); + + return new(packaging.GetId(), packaging.GetVersion()); + } +} + +record struct PackageDetails(string PackageId, NuGetVersion Version); diff --git a/build/PipelineCLI/Modules/PublishNuGetModule.cs b/build/PipelineCLI/Modules/PublishNuGetModule.cs new file mode 100644 index 0000000..ae68a4c --- /dev/null +++ b/build/PipelineCLI/Modules/PublishNuGetModule.cs @@ -0,0 +1,69 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Release")] +[DependsOn] +[DependsOn] +public class PublishNuGetModule( + IOptions buildSettings, + IOptions nugetSettings, + IOptions releaseSettings +) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + releaseSettings.Value.Mode == ReleaseMode.NuGet + ? SkipDecision.DoNotSkip + : SkipDecision.Skip( + "Release publishing is disabled. Set Release__Mode=NuGet to publish packages to nuget.org." + ) + ) + .WithSkipWhen(_ => + string.IsNullOrWhiteSpace(nugetSettings.Value.GetNuGetAPIKey()) + ? SkipDecision.Skip( + "NuGet API key is not set. Set NuGet__APIKey or NUGET_APIKEY to publish packages." + ) + : SkipDecision.DoNotSkip + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var packages = Directory + .EnumerateFiles(buildSettings.Value.ArtifactsFolder, "*.nupkg", SearchOption.TopDirectoryOnly) + .ToList(); + + if (packages.Count == 0) + { + throw new InvalidOperationException($"No NuGet packages found in {buildSettings.Value.ArtifactsFolder}."); + } + + var tasks = packages.Select(package => + context + .DotNet() + .Nuget.Push( + new() + { + Path = package, + Source = nugetSettings.Value.FeedUrl, + ApiKey = nugetSettings.Value.GetNuGetAPIKey(), + SkipDuplicate = true, + }, + cancellationToken: cancellationToken + ) + ); + + return await Task.WhenAll(tasks); + } +} diff --git a/build/PipelineCLI/Modules/RestoreModule.cs b/build/PipelineCLI/Modules/RestoreModule.cs new file mode 100644 index 0000000..139883c --- /dev/null +++ b/build/PipelineCLI/Modules/RestoreModule.cs @@ -0,0 +1,25 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +public class RestoreModule(IOptions settings) : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + return await context + .DotNet() + .Restore( + new DotNetRestoreOptions { ProjectSolution = settings.Value.Solution }, + cancellationToken: cancellationToken + ); + } +} diff --git a/build/PipelineCLI/Modules/RunTestsModule.cs b/build/PipelineCLI/Modules/RunTestsModule.cs new file mode 100644 index 0000000..556a261 --- /dev/null +++ b/build/PipelineCLI/Modules/RunTestsModule.cs @@ -0,0 +1,58 @@ +using ModularPipelines.Attributes; +using ModularPipelines.Configuration; +using ModularPipelines.Context; +using ModularPipelines.DotNet.Extensions; +using ModularPipelines.DotNet.Options; +using ModularPipelines.Models; +using ModularPipelines.Modules; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +[DependsOn] +public class RunTestsModule(IOptions settings) : Module +{ + protected override ModuleConfiguration Configure() => + ModuleConfiguration + .Create() + .WithSkipWhen(_ => + settings.Value.RunTests + ? SkipDecision.DoNotSkip + : SkipDecision.Skip("Tests are disabled. Set Build__RunTests=true to run them.") + ) + .Build(); + + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var testProjects = Directory.EnumerateFiles("src/tests", "*Tests.csproj", SearchOption.AllDirectories).ToList(); + if (testProjects.Count == 0) + { + context.Logger.LogWarning( + "No test projects found in 'src/tests', despite tests being enabled. Skipping test execution." + ); + + return []; + } + + var tasks = testProjects.Select(project => + context + .DotNet() + .Test( + new DotNetTestOptions + { + Project = project, + Configuration = settings.Value.Configuration, + NoBuild = true, + NoRestore = true, + Arguments = ["--ignore-exit-code", "8", "--treenode-filter", settings.Value.TestFilter], + }, + cancellationToken: cancellationToken + ) + ); + + return await Task.WhenAll(tasks); + } +} diff --git a/build/PipelineCLI/Modules/VersionModule.cs b/build/PipelineCLI/Modules/VersionModule.cs new file mode 100644 index 0000000..80aa59f --- /dev/null +++ b/build/PipelineCLI/Modules/VersionModule.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using ModularPipelines.Attributes; +using ModularPipelines.Context; +using ModularPipelines.Modules; +using NuGet.Versioning; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Modules; + +[ModuleCategory("Build")] +public class VersionModule : Module +{ + protected override async Task ExecuteAsync( + IModuleContext context, + CancellationToken cancellationToken + ) + { + var packageJsonPath = Path.Combine(Environment.CurrentDirectory, "package.json"); + + if (!File.Exists(packageJsonPath)) + throw new FileNotFoundException($"Could not find package.json at {packageJsonPath}"); + + var packageJson = await File.ReadAllTextAsync(packageJsonPath, cancellationToken); + + using var document = JsonDocument.Parse(packageJson); + var version = document.RootElement.GetProperty("version").GetString(); + + if (string.IsNullOrWhiteSpace(version)) + throw new InvalidOperationException("The version field in package.json is missing or empty."); + + if (!NuGetVersion.TryParse(version, out var nugetVersion)) + throw new InvalidOperationException($"The version '{version}' in package.json is not a valid SemVer."); + + context.Summary.KeyValue("Version", "Package version", version); + return nugetVersion; + } +} diff --git a/build/PipelineCLI/PipelineCLI.csproj b/build/PipelineCLI/PipelineCLI.csproj new file mode 100644 index 0000000..10d8460 --- /dev/null +++ b/build/PipelineCLI/PipelineCLI.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/build/PipelineCLI/PipelineProjectDirectory.cs b/build/PipelineCLI/PipelineProjectDirectory.cs new file mode 100644 index 0000000..288cda8 --- /dev/null +++ b/build/PipelineCLI/PipelineProjectDirectory.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; + +namespace Purview.Aspire.ResourceKit.PipelineCLI; + +static class PipelineProjectDirectory +{ + const string DirectoryVariable = "MODULAR_PIPELINES_DIRECTORY"; + + public static string Find([CallerFilePath] string sourceFilePath = "") + { + var configuredDirectory = Environment.GetEnvironmentVariable(DirectoryVariable); + if (!string.IsNullOrWhiteSpace(configuredDirectory)) + { + return ValidateConfiguredDirectory(configuredDirectory); + } + + var sourceDirectory = Path.GetDirectoryName(sourceFilePath); + return IsPipelineDirectory(sourceDirectory) ? sourceDirectory! : FindFromBuildOutput(); + } + + static string ValidateConfiguredDirectory(string configuredDirectory) + { + var fullPath = Path.GetFullPath(configuredDirectory); + return IsPipelineDirectory(fullPath) + ? fullPath + : throw new InvalidOperationException( + $"{DirectoryVariable} must point to a directory containing appsettings.json and a project file." + ); + } + + static string FindFromBuildOutput() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if (IsPipelineDirectory(directory.FullName)) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"Could not locate the pipeline project directory. Set {DirectoryVariable} to its path." + ); + } + + static bool IsPipelineDirectory(string? directory) => + directory is not null + && Directory.Exists(directory) + && File.Exists(Path.Combine(directory, "appsettings.json")) + && Directory.EnumerateFiles(directory, "*.csproj").Any(); +} diff --git a/build/PipelineCLI/Program.cs b/build/PipelineCLI/Program.cs new file mode 100644 index 0000000..5d38362 --- /dev/null +++ b/build/PipelineCLI/Program.cs @@ -0,0 +1,42 @@ +var pipelineDirectory = PipelineProjectDirectory.Find(); +var repositoryRoot = PathHelpers.FindRepositoryRoot(pipelineDirectory); + +var builder = Pipeline.CreateBuilder(args); + +builder + .Configuration.AddJsonFile(Path.Combine(pipelineDirectory, "appsettings.json"), optional: false) + .AddEnvironmentVariables() + .AddCommandLine(args); + +builder.Services.Configure(builder.Configuration.GetSection(BuildSettings.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(NuGetSettings.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(PublishLocalNuGetSettings.SectionName) +); +builder.Services.Configure(builder.Configuration.GetSection(GitHubSettings.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(ReleaseSettings.SectionName)); + +builder.Services.AddSingleton(serviceProvider => +{ + var settings = serviceProvider.GetRequiredService>(); + var accessToken = settings.Value.GetGitHubToken(); + + return new GitHubClient(new(settings.Value.ProductHeader), new InMemoryCredentialStore(new(accessToken))); +}); + +Environment.CurrentDirectory = repositoryRoot; + +builder + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule() + .AddModule(); + +await using var pipeline = await builder.BuildAsync(); + +await pipeline.RunAsync(); diff --git a/build/PipelineCLI/Properties/launchSettings.json b/build/PipelineCLI/Properties/launchSettings.json new file mode 100644 index 0000000..2679915 --- /dev/null +++ b/build/PipelineCLI/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Local-NuGet": { + "commandName": "Project", + "commandLineArgs": "--Release:Mode=LocalNuGet\r\n--PublishLocalNuGet:LocalFeedPath=p:\\_sync-projects\\.local-nuget\\" + } + } +} diff --git a/build/PipelineCLI/Settings/BuildSettings.cs b/build/PipelineCLI/Settings/BuildSettings.cs new file mode 100644 index 0000000..7b91954 --- /dev/null +++ b/build/PipelineCLI/Settings/BuildSettings.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Settings; + +public sealed class BuildSettings +{ + public const string SectionName = "Build"; + + public LogLevel LogLevel { get; init; } = LogLevel.Warning; + + [Required(AllowEmptyStrings = false)] + public string Solution { get; init; } = "src/SourceGeneratorFramework.slnx"; + + [Required(AllowEmptyStrings = false)] + public string Configuration { get; init; } = "Release"; + + [Required(AllowEmptyStrings = false)] + public string ArtifactsFolder { get; init; } = "artifacts"; + + public bool RunTests { get; init; } = true; + + [Required(AllowEmptyStrings = false)] + public string TestFilter { get; init; } = "/*/*/*/*/"; +} diff --git a/build/PipelineCLI/Settings/GitHubSettings.cs b/build/PipelineCLI/Settings/GitHubSettings.cs new file mode 100644 index 0000000..d54f201 --- /dev/null +++ b/build/PipelineCLI/Settings/GitHubSettings.cs @@ -0,0 +1,22 @@ +using ModularPipelines.Attributes; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Settings; + +public sealed record GitHubSettings +{ + public const string SectionName = "GitHub"; + + [SecretValue] + public string? AccessToken { get; init; } + + [SecretValue] + [ConfigurationKeyName("GITHUB_TOKEN")] + public string? EnvAccessToken { get; init; } + + public string ProductHeader { get; init; } = "Purview.SourceGeneratorFramework.Pipeline"; + + public string? GetGitHubToken() => + !string.IsNullOrWhiteSpace(AccessToken) ? AccessToken + : !string.IsNullOrWhiteSpace(EnvAccessToken) ? EnvAccessToken + : null; +} diff --git a/build/PipelineCLI/Settings/NuGetSettings.cs b/build/PipelineCLI/Settings/NuGetSettings.cs new file mode 100644 index 0000000..a2a530b --- /dev/null +++ b/build/PipelineCLI/Settings/NuGetSettings.cs @@ -0,0 +1,22 @@ +using ModularPipelines.Attributes; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Settings; + +public sealed record NuGetSettings +{ + public const string SectionName = "NuGet"; + + [SecretValue] + public string? APIKey { get; set; } + + [SecretValue] + [ConfigurationKeyName("NUGET_APIKEY")] + public string? EnvAPIKey { get; set; } + + public string FeedUrl { get; init; } = "https://api.nuget.org/v3/index.json"; + + public string? GetNuGetAPIKey() => + !string.IsNullOrWhiteSpace(APIKey) ? APIKey + : !string.IsNullOrWhiteSpace(EnvAPIKey) ? EnvAPIKey + : null; +} diff --git a/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs b/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs new file mode 100644 index 0000000..3c5514e --- /dev/null +++ b/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; + +namespace Purview.Aspire.ResourceKit.PipelineCLI.Settings; + +public sealed record PublishLocalNuGetSettings : IValidatableObject +{ + public const string SectionName = "PublishLocalNuGet"; + + [Required(AllowEmptyStrings = false)] + public string LocalFeedPath { get; init; } = string.Empty; + + public bool OverwriteExistingPackages { get; init; } = true; + + public bool ShutdownDotnetBuilderServer { get; init; } = true; + + public bool ClearPackageCache { get; init; } = true; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(LocalFeedPath)) + { + yield return new ValidationResult("LocalFeedPath is required.", [nameof(LocalFeedPath)]); + yield break; + } + + // Path.IsPathRooted("p:foo") returns true, but a drive-relative path like "p:foo" is NOT an + // absolute path: Path.GetFullPath resolves it against the current directory and can silently + // copy packages to an unintended location. This is the classic signature of a Windows path whose + // backslashes were stripped by a sh-style shell, e.g. 'p:\_sync-projects\.local-nuget\'. + if (LocalFeedPath.Length >= 2 && LocalFeedPath[1] == ':') + { + var hasSeparatorAfterDrive = + LocalFeedPath.Length >= 3 + && ( + LocalFeedPath[2] == Path.DirectorySeparatorChar + || LocalFeedPath[2] == Path.AltDirectorySeparatorChar + ); + if (!hasSeparatorAfterDrive) + { + yield return new ValidationResult( + $"LocalFeedPath '{LocalFeedPath}' is drive-relative, not an absolute path. " + + "This is usually caused by the shell stripping backslashes from a Windows path such as " + + $"'p:\\_sync-projects\\.local-nuget\\'. Use forward slashes instead, e.g. " + + "'p:/_sync-projects/.local-nuget/'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + } + + if (!Path.IsPathRooted(LocalFeedPath)) + { + yield return new ValidationResult( + $"LocalFeedPath must be an absolute path. Received: '{LocalFeedPath}'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + + var root = Path.GetPathRoot(LocalFeedPath); + if (string.IsNullOrEmpty(root)) + { + yield return new ValidationResult( + $"LocalFeedPath could not be parsed. Received: '{LocalFeedPath}'.", + [nameof(LocalFeedPath)] + ); + yield break; + } + + var lastChar = root[^1]; + if (lastChar == Path.DirectorySeparatorChar || lastChar == Path.AltDirectorySeparatorChar) + yield break; + + if (root.StartsWith(@"\\", StringComparison.Ordinal) || root.StartsWith("//", StringComparison.Ordinal)) + yield break; + + yield return new ValidationResult( + $"LocalFeedPath must be an absolute path (e.g. 'C:\\folder' or '\\\\server\\share'). Received: '{LocalFeedPath}'.", + [nameof(LocalFeedPath)] + ); + } +} diff --git a/build/PipelineCLI/Settings/ReleaseSettings.cs b/build/PipelineCLI/Settings/ReleaseSettings.cs new file mode 100644 index 0000000..69fa2a7 --- /dev/null +++ b/build/PipelineCLI/Settings/ReleaseSettings.cs @@ -0,0 +1,19 @@ +namespace Purview.Aspire.ResourceKit.PipelineCLI.Settings; + +public enum ReleaseMode +{ + None, + + NuGet, + + GitHubRelease, + + LocalNuGet, +} + +public sealed record ReleaseSettings +{ + public const string SectionName = "Release"; + + public ReleaseMode Mode { get; set; } = ReleaseMode.None; +} diff --git a/build/PipelineCLI/appsettings.json b/build/PipelineCLI/appsettings.json new file mode 100644 index 0000000..9381c34 --- /dev/null +++ b/build/PipelineCLI/appsettings.json @@ -0,0 +1,12 @@ +{ + "NuGet": { + "FeedUrl": "https://api.nuget.org/v3/index.json" + }, + "GitHub": { + "AccessToken": null, + "ProductHeader": "Purview.SourceGeneratorFramework.Pipeline" + }, + "Release": { + "Mode": "None" + } +} diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..b124011 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,2043 @@ +--- +created: 2026-08-29 +updated: 2026-08-29 +tags: + - source-generator + - analyser + - roslyn + - best-practices +--- + +# Source Generator & Analyser Best Practices + +> Practical guidance for writing Roslyn analysers and incremental source generators that remain fast, deterministic, cache-friendly, IDE-compatible, and safe to distribute. + +--- + +## Contents + +- [1. Core Principles](#1-core-principles) +- [2. Analyser or Source Generator?](#2-analyser-or-source-generator) +- [3. Choosing an Analyser Action](#3-choosing-an-analyser-action) +- [4. Syntax vs Symbol vs Operation](#4-syntax-vs-symbol-vs-operation) +- [5. Analyser Best Practices](#5-analyser-best-practices) +- [6. Incremental Generator Golden Rules](#6-incremental-generator-golden-rules) +- [7. Pipeline Value Equality](#7-pipeline-value-equality) +- [8. Designing the Incremental Pipeline](#8-designing-the-incremental-pipeline) +- [9. Syntax Discovery](#9-syntax-discovery) +- [10. `Collect`, `Combine`, and Invalidation](#10-collect-combine-and-invalidation) +- [11. Diagnostics](#11-diagnostics) +- [12. Output Generation](#12-output-generation) +- [13. Testing Incrementally](#13-testing-incrementally) +- [14. Roslyn Version Compatibility](#14-roslyn-version-compatibility) +- [15. Visual Studio, .NET SDK, and Rider](#15-visual-studio-net-sdk-and-rider) +- [16. Multi-Version Roslyn Packaging](#16-multi-version-roslyn-packaging) +- [17. `Microsoft.CodeAnalysis.Analysers`](#17-microsoftcodeanalysisanalysers) +- [18. Recommended Project Configuration](#18-recommended-project-configuration) +- [19. Review Checklist](#19-review-checklist) + +--- + +# 1. Core Principles + +The most important rules are: + +1. **Use an analyser to validate user code.** +2. **Use an incremental generator to generate code.** +3. **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +4. **Remove Roslyn objects from the incremental pipeline as early as possible.** +5. **Every value crossing a pipeline boundary should have meaningful value equality.** +6. **Prefer many small incremental stages over one large transform.** +7. **Keep broad inputs such as `Compilation` away from downstream generation.** +8. **Generate deterministic output.** +9. **Compile against the oldest Roslyn API version you actually need.** +10. **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> **Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model.** + +--- + +# 2. Analyser or Source Generator? + +Analysers and generators have different responsibilities. + +A `DiagnosticAnalyser` answers: + +> Is the source code valid according to this library's rules? + +An `IIncrementalGenerator` answers: + +> Given valid source code, what source should be generated? + +## Decision Table + +| Requirement | Prefer | Reason | +| --- | --- | --- | +| Require a class to be `partial` | Analyser | User-code contract violation | +| Require an attribute on a declaration | Analyser | User-code contract violation | +| Validate a method signature | Analyser | Semantic validation | +| Reject unsupported property types | Analyser | Better IDE feedback | +| Detect invalid attribute arguments | Analyser | Natural diagnostic | +| Detect unsupported API usage | Analyser | Operation analysis | +| Offer an automatic fix | Analyser + `CodeFixProvider` | Code fixes operate on diagnostics | +| Generate members for a marked class | Incremental generator | Generation | +| Generate serializers/validators/mappers | Incremental generator | Generation | +| Generate a registry from discovered types | Incremental generator | Generation | +| Read an additional schema file and generate C# | Incremental generator | Generation | +| Report an unexpected internal generation failure | Generator diagnostic | Generation-specific failure | +| Validate generator-only external input | Generator diagnostic may be appropriate | Analyser may not see equivalent input | + +Prefer: + +```text +User Source + │ + ├── Analyser + │ ├── discovers relevant source + │ ├── validates the contract + │ ├── reports diagnostics + │ └── optionally provides code fixes + │ + └── Incremental Generator + ├── discovers relevant source + ├── extracts semantic values + ├── creates equatable models + └── generates deterministic source +``` + +A useful shorthand is: + +> **Analysers protect the contract. Generators implement the contract.** + +--- + +# 3. Choosing an Analyser Action + +Use the narrowest analyser API that directly represents the thing being analysed. + +Do not start with a broad syntax scan if Roslyn already exposes the concept as a symbol or operation. + +## Analyser Action Decision Matrix + +| API | Use when | Examples | Recommendation | +| --- | --- | --- | --- | +| `RegisterSyntaxNodeAction` | Exact source syntax matters | Modifier presence, declaration form | Use for syntax rules | +| `RegisterSymbolAction` | A declaration's semantic meaning matters | Accessibility, attributes, implemented interfaces | Preferred for declaration rules | +| `RegisterOperationAction` | Executable behaviour/API usage matters | Invocation, assignment, conversion, object creation | Preferred for semantic usage rules | +| `RegisterOperationBlockStartAction` | Multiple operations inside one body need shared state | Track resource use throughout a method | Use for stateful method analysis | +| `RegisterOperationBlockAction` | Whole executable body must be analysed | Final body-level validation | Prefer narrower actions where possible | +| `RegisterCodeBlockStartAction` | Syntax-oriented body analysis needs state | Stateful syntax rules | Less common than operation-block analysis | +| `RegisterCodeBlockAction` | Entire syntax code block matters | Body-level syntax rules | Use sparingly | +| `RegisterSymbolStartAction` | A symbol and its members must be analysed together | Type-wide analysis across members | Powerful but relatively expensive | +| Symbol end action | Result depends on all child/member analysis | Report once for entire type | Register from symbol-start | +| `RegisterCompilationStartAction` | Expensive semantic setup should happen once | Resolve known framework/library symbols | Good initialization boundary | +| Compilation end action | A result genuinely depends on the entire compilation | Global collision/aggregate rule | Avoid unless necessary | +| `RegisterAdditionalFileAction` | Analyze `AdditionalFiles` | Config/schema validation | Correct abstraction | +| `RegisterSemanticModelAction` | Analysis genuinely applies to an entire semantic model | Rare tree-wide semantic rule | Usually too broad | +| `RegisterSyntaxTreeAction` | Entire raw syntax tree matters | File header / file-level syntax | Prefer node actions when possible | + +--- + +# 4. Syntax vs Symbol vs Operation + +The most common analyser design decision is choosing between: + +- syntax; +- symbols; +- operations. + +## Quick Decision + +```text +Does exact source spelling/structure matter? + │ + ├── Yes ──► Syntax + │ + └── No + │ + ├── Is this a declaration? + │ └── Yes ──► Symbol + │ + └── Is this executable behaviour? + └── Yes ──► Operation +``` + +--- + +## Syntax + +Use syntax when the literal structure of the user's source matters. + +Examples: + +- is `partial` explicitly present? +- did the user write a primary constructor? +- is the namespace file-scoped? +- was an explicit modifier specified? +- is a declaration syntactically structured in a particular way? + +Example: + +```csharp +context.RegisterSyntaxNodeAction( + AnalyzeClass, + SyntaxKind.ClassDeclaration +); +``` + +Syntax is often the cheapest solution when no semantic information is required. + +Do not ask the semantic model a question that can be answered directly from syntax. + +--- + +## Symbols + +Use symbols when analysing declarations semantically. + +Examples: + +- does this type implement `IDisposable`? +- does this member have a particular attribute? +- what is the method return type? +- what is the property's accessibility? +- what generic type arguments are present? +- is this type abstract? +- which containing namespace owns this type? + +Example: + +```csharp +context.RegisterSymbolAction( + AnalyzeNamedType, + SymbolKind.NamedType +); +``` + +When comparing symbols: + +```csharp +SymbolEqualityComparer.Default.Equals(left, right) +``` + +should normally be used rather than reference equality. + +--- + +## Operations + +Use `IOperation` when analysing executable semantics. + +Examples: + +- method invocation; +- constructor invocation; +- assignment; +- conversion; +- property access; +- field access; +- argument passing; +- return values; +- `await`; +- binary/unary operations. + +For example: + +```csharp +context.RegisterOperationAction( + AnalyzeInvocation, + OperationKind.Invocation +); +``` + +Then: + +```csharp +static void AnalyzeInvocation(OperationAnalysisContext context) +{ + var invocation = (IInvocationOperation)context.Operation; + + var method = invocation.TargetMethod; + + // Semantic method information is already available. +} +``` + +This is normally preferable to: + +```csharp +context.RegisterSyntaxNodeAction( + AnalyzeInvocation, + SyntaxKind.InvocationExpression +); +``` + +followed by: + +```csharp +context.SemanticModel.GetSymbolInfo(...) +``` + +for every invocation. + +--- + +## Common Operation Kinds + +| Requirement | Operation | +| --- | --- | +| Method invocation | `OperationKind.Invocation` | +| Constructor invocation | `OperationKind.ObjectCreation` | +| Assignment | `OperationKind.SimpleAssignment` | +| Compound assignment | compound assignment operation kinds | +| Argument validation | `OperationKind.Argument` | +| Property access | `OperationKind.PropertyReference` | +| Field access | `OperationKind.FieldReference` | +| Conversion | `OperationKind.Conversion` | +| Return expression | `OperationKind.Return` | +| Await | `OperationKind.Await` | +| Binary expression | `OperationKind.Binary` | +| Unary expression | `OperationKind.Unary` | + +Use the semantic operation rather than reconstructing equivalent information from syntax whenever possible. + +--- + +# 5. Analyser Best Practices + +## Enable Concurrent Execution + +Analysers should normally enable concurrent execution: + +```csharp +public override void Initialize(AnalysisContext context) +{ + context.EnableConcurrentExecution(); + + context.ConfigureGeneratedCodeAnalysis( + GeneratedCodeAnalysisFlags.None + ); + + // Register actions. +} +``` + +Analyser callbacks can execute concurrently. + +Avoid shared mutable state. + +--- + +## Configure Generated Code Explicitly + +Do not leave generated-code handling implicit. + +For most library contract analysers: + +```csharp +context.ConfigureGeneratedCodeAnalysis( + GeneratedCodeAnalysisFlags.None +); +``` + +is appropriate. + +Only inspect generated code if the analyser explicitly needs to. + +--- + +## Resolve Known Types Once + +If an analyser needs to repeatedly compare against known framework or library types, resolve them during compilation start. + +```csharp +context.RegisterCompilationStartAction(static context => +{ + var targetType = + context.Compilation.GetTypeByMetadataName( + "MyLibrary.SomeType" + ); + + if (targetType is null) + return; + + context.RegisterOperationAction( + c => AnalyzeInvocation(c, targetType), + OperationKind.Invocation + ); +}); +``` + +This is a good reason to use `CompilationStart`. + +Do not use `CompilationStart` merely to enumerate the entire compilation. + +--- + +## Prefer Narrow Registration + +Prefer: + +```csharp +context.RegisterOperationAction( + AnalyzeInvocation, + OperationKind.Invocation +); +``` + +over an action that sees every operation. + +Prefer: + +```csharp +context.RegisterSyntaxNodeAction( + AnalyzeClass, + SyntaxKind.ClassDeclaration +); +``` + +over scanning an entire `SyntaxTree`. + +--- + +# 6. Incremental Generator Golden Rules + +Implement: + +```csharp +IIncrementalGenerator +``` + +rather than the legacy: + +```csharp +ISourceGenerator +``` + +But simply implementing `IIncrementalGenerator` does **not** make a generator meaningfully incremental. + +Incrementally depends on the equality behaviour of the values flowing through the pipeline. + +## The Golden Rule + +> **Pipeline values must be immutable and value-equatable.** + +Roslyn needs to determine: + +```text +Did this pipeline stage produce the same logical value as last time? +``` + +If the answer is yes, Roslyn can stop executing downstream stages and reuse cached results. + +--- + +# 7. Pipeline Value Equality + +## Pipeline Red List + +These should not normally survive into your generator model. + +| Type | Verdict | Why | +| --- | --- | --- | +| `ISymbol` | ❌ Never retain | Not suitable for pipeline equality; can retain old compilations | +| `INamedTypeSymbol` | ❌ Never retain | Same problem as `ISymbol` | +| `IMethodSymbol` | ❌ Never retain | Same problem as `ISymbol` | +| `IPropertySymbol` | ❌ Never retain | Same problem as `ISymbol` | +| `Compilation` | ❌ Do not propagate | Huge semantic graph and broad invalidation source | +| `SemanticModel` | ❌ Do not propagate | Bound to compilation/tree | +| `IOperation` | ❌ Do not propagate | Compiler semantic graph object | +| `SyntaxTree` | ❌ Do not propagate | Changes with source edits | +| `SyntaxNode` | ⚠ Remove ASAP | Usually loses equality after edits to its tree | +| `Location` | ⚠ Remove ASAP | Same incrementally problem as syntax | +| `AdditionalText` | ⚠ Project immediately | Host/compiler input object | +| `T[]` | ❌ Avoid in models | Reference equality | +| `List` | ❌ Avoid in models | Mutable and reference equality | +| `ImmutableArray` | ⚠ Wrap/compare explicitly | Immutable but not sequence-value-equatable for model equality | +| Mutable class | ❌ Avoid | Reference equality unless explicitly implemented | + +--- + +## Good Model + +```csharp +internal sealed record TypeModel( + string Namespace, + string Name, + string FullyQualifiedName, + Accessibility Accessibility, + EquatableArray Properties +); + +internal sealed record PropertyModel( + string Name, + string FullyQualifiedTypeName, + bool IsNullable +); +``` + +--- + +## Bad Model + +```csharp +internal sealed record TypeModel( + INamedTypeSymbol Symbol, + Compilation Compilation, + Location Location, + ImmutableArray Properties +); +``` + +Making the outer object a `record` does not magically make its members suitable for incremental equality. + +--- + +## `ImmutableArray` Is Not Enough + +`ImmutableArray` solves: + +> Can this collection be mutated? + +It does not automatically solve: + +> Do two separately-created collections containing equivalent elements compare as the same sequence for my pipeline model? + +These are different problems. + +For incremental models, prefer something such as: + +```csharp +EquatableArray +``` + +with sequence-based equality. + +Conceptually: + +```csharp +internal readonly struct EquatableArray + : IEquatable> +{ + private readonly ImmutableArray _items; + + public bool Equals(EquatableArray other) + { + if (_items.Length != other._items.Length) + return false; + + return _items + .AsSpan() + .SequenceEqual(other._items.AsSpan()); + } + + public override bool Equals(object? obj) => + obj is EquatableArray other && + Equals(other); + + public override int GetHashCode() + { + var hash = new HashCode(); + + foreach (var item in _items) + hash.Add(item); + + return hash.ToHashCode(); + } +} +``` + +The precise implementation can vary. + +The important requirement is: + +```text +same contents => equal pipeline value +``` + +--- + +# 8. Designing the Incremental Pipeline + +Think of every transformation as a cache checkpoint. + +Prefer: + +```text +Roslyn Input + │ + ▼ +Cheap discovery + │ + ▼ +Semantic extraction + │ + ▼ +Small equatable model + │ + ▼ +Validation/transformation + │ + ▼ +Generation model + │ + ▼ +Source output +``` + +Do not do: + +```text +Roslyn Input + │ + ▼ +Giant transform containing symbols + syntax + compilation + │ + ▼ +Generate everything +``` + +--- + +## Project Early + +The semantic transform should usually be the boundary where Roslyn objects disappear. + +Example: + +```csharp +static TypeModel CreateModel( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken +) +{ + var symbol = (INamedTypeSymbol)context.TargetSymbol; + + return new TypeModel( + Namespace: + symbol.ContainingNamespace.ToDisplayString(), + + Name: + symbol.Name, + + FullyQualifiedName: + symbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + + Accessibility: + symbol.DeclaredAccessibility + ); +} +``` + +Everything downstream should receive `TypeModel`, not `INamedTypeSymbol`. + +--- + +## Prefer Static Lambdas + +Prefer: + +```csharp +.Select(static (value, cancellationToken) => +{ + return Transform(value, cancellationToken); +}); +``` + +Static callbacks prevent accidental capture of generator instance state. + +Generator instances should not be treated as application services or state containers. + +--- + +## Honour Cancellation + +For non-trivial transformations: + +```csharp +.Select(static (value, cancellationToken) => +{ + cancellationToken.ThrowIfCancellationRequested(); + + return Transform(value, cancellationToken); +}); +``` + +Pass cancellation tokens into Roslyn APIs that accept them. + +--- + +## Split Transformations + +Prefer: + +```text +Syntax + ↓ +Symbol projection + ↓ +Type model + ↓ +Property models + ↓ +Generation model + ↓ +Output +``` + +over: + +```text +Syntax + ↓ +Do absolutely everything + ↓ +Output +``` + +More meaningful boundaries give Roslyn more opportunities to short-circuit downstream processing. + +--- + +# 9. Syntax Discovery + +## Prefer `ForAttributeWithMetadataName` + +Attribute-driven generation should normally start with: + +```csharp +context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: + "MyLibrary.GenerateAttribute", + + predicate: + static (node, _) => + node is TypeDeclarationSyntax, + + transform: + static (context, cancellationToken) => + CreateModel(context, cancellationToken) +); +``` + +Advantages include: + +- highly optimized discovery; +- alias support; +- direct `TargetSymbol`; +- matching `AttributeData`; +- obvious user intent; +- easier analyser integration. + +For marker-attribute generators, this should be the default. + +--- + +## Use `CreateSyntaxProvider` When Syntax Is Actually the Trigger + +Use: + +```csharp +context.SyntaxProvider.CreateSyntaxProvider(...) +``` + +when there is no appropriate marker attribute. + +Examples: + +- syntax-driven DSL; +- a generator intentionally driven by a language construct; +- a pattern that cannot reasonably use an attribute. + +The predicate must be cheap. + +Good: + +```csharp +predicate: + static (node, _) => + node is ClassDeclarationSyntax + { + AttributeLists.Count: > 0 + } +``` + +Bad: + +```csharp +predicate: + static (node, _) => + { + // expensive walking + // semantic work + // allocations + // string construction + return true; + } +``` + +The predicate runs extremely frequently. + +Semantic work belongs in the transformation callback. + +--- + +## Avoid Indirect Discovery + +Avoid designs that require discovering: + +- every indirect implementation of an interface; +- every indirect subclass; +- inherited marker attributes through arbitrary hierarchies; +- every type in a compilation followed by manual filtering. + +A change high in a type hierarchy can invalidate a large arbitrary portion of the compilation. + +Prefer explicit intent: + +```csharp +[GenerateSchema] +partial class Customer +{ +} +``` + +over: + +```text +Generate everything somewhere downstream of IBaseSchemaThing +``` + +--- + +# 10. `Collect`, `Combine`, and Invalidation + +## `Collect()` + +`Collect()` transforms: + +```csharp +IncrementalValuesProvider +``` + +into roughly: + +```csharp +IncrementalValueProvider> +``` + +This changes invalidation scope. + +Before: + +```text +A ──► output A +B ──► output B +C ──► output C +``` + +After collection: + +```text +A ─┐ +B ─┼──► [A,B,C] ──► output +C ─┘ +``` + +Changing `B` changes the aggregate `[A,B,C]`. + +--- + +## Prefer Per-Item Output + +Prefer: + +```csharp +context.RegisterSourceOutput( + models, + static (context, model) => + Emit(context, model) +); +``` + +instead of: + +```csharp +context.RegisterSourceOutput( + models.Collect(), + static (context, models) => + { + foreach (var model in models) + Emit(context, model); + } +); +``` + +unless the generation genuinely requires the complete set. + +--- + +## Good Uses of `Collect()` + +Use `Collect()` when generating something intrinsically global: + +- one registry containing every handler; +- one lookup containing every generated type; +- duplicate-name detection across all targets; +- one aggregate switch; +- one generated dependency map. + +A useful design is: + +```text + ┌──► Per-type source +Type Models ────────┤ + │ + └──► Collect() + │ + ▼ + Global registry +``` + +Only the registry should pay the global invalidation cost. + +--- + +## `Combine()` + +Use `Combine()` when one output logically depends on two providers. + +Example: + +```csharp +var generationInput = + typeModels.Combine(generatorOptions); +``` + +That means: + +```text +type changed ───────┐ + ├──► generation invalidated +option changed ─────┘ +``` + +This is correct if either input should regenerate the output. + +--- + +## Be Very Careful Combining `CompilationProvider` + +This: + +```csharp +models.Combine(context.CompilationProvider) +``` + +is often an incremental performance smell. + +Almost any semantic change can replace the compilation. + +If possible, project the compilation into the tiny fact you actually need: + +```csharp +var capabilities = + context.CompilationProvider + .Select(static (compilation, _) => + new CompilationCapabilities( + HasRequiredType: + compilation.GetTypeByMetadataName( + "MyLibrary.RequiredType" + ) is not null + ) + ); +``` + +Then: + +```csharp +models.Combine(capabilities) +``` + +At least downstream equality can now short-circuit when the relevant capability did not change. + +--- + +## `WithComparer()` + +Roslyn provides: + +```csharp +.WithComparer(...) +``` + +when default equality is insufficient. + +Example: + +```csharp +provider.WithComparer( + MyModelComparer.Instance +); +``` + +Use this when your logical equality differs from the default implementation. + +Do not use it as a way to justify retaining large compiler objects inside the model. + +This is suspicious: + +```csharp +record Model( + INamedTypeSymbol Symbol, + Compilation Compilation +); +``` + +followed by an elaborate comparer. + +The better solution is usually to redesign `Model`. + +--- + +# 11. Diagnostics + +## Prefer a Separate Analyser + +Normal user validation belongs in a `DiagnosticAnalyser`. + +Benefits include: + +- immediate IDE feedback; +- independent execution from generation; +- easier testing; +- code-fix support; +- simpler incremental generator pipelines. + +--- + +## Generator Diagnostics Are Still Valid + +Generator diagnostics make sense for things such as: + +- malformed additional files; +- invalid generator-only configuration; +- conflicting generated output discovered only during generation; +- failures that cannot naturally be expressed by a separate analyser. + +Do not turn the generator pipeline into an analyser pipeline by default. + +--- + +## Location Handling + +Analysers should report diagnostics on the most useful user-authored `Location`. + +Generators should avoid keeping `Location` inside long-lived pipeline models. + +If a generator absolutely requires source position information, convert it into a value model: + +```csharp +internal readonly record struct SourceLocationModel( + string FilePath, + int Start, + int Length +); +``` + +But even this should only be carried downstream if generation actually depends on the location. + +--- + +# 12. Output Generation + +## Output Must Be Deterministic + +For the same generator model: + +```text +input model + ↓ +identical generated source +``` + +Avoid: + +- current timestamps; +- random GUIDs; +- process IDs; +- machine-specific paths; +- unordered dictionary output; +- machine environment variables; +- current culture affecting generation. + +--- + +## Deterministic Hint Names + +Good: + +```csharp +context.AddSource( + $"{model.HintName}.g.cs", + source +); +``` + +Bad: + +```csharp +context.AddSource( + $"{Guid.NewGuid():N}.g.cs", + source +); +``` + +Hint names must be: + +- deterministic; +- unique within the generator; +- stable when irrelevant source changes. + +--- + +## Prefer Text Generation + +Do not build a complete Roslyn syntax tree merely to generate source unless there is a strong reason. + +For generator output, a small code writer or structured string builder is generally easier and faster. + +Avoid repeatedly doing: + +```csharp +syntax.NormalizeWhitespace().ToFullString() +``` + +for large generated trees. + +--- + +## Post-Initialization Output + +Use: + +```csharp +RegisterPostInitializationOutput +``` + +for source that is constant regardless of the user's compilation. + +Examples: + +- marker attributes; +- fixed helper attributes; +- static support types. + +Example: + +```csharp +context.RegisterPostInitializationOutput( + static context => + { + context.AddSource( + "GenerateAttribute.g.cs", + SourceText.From( + """ + // + + namespace MyLibrary; + + [global::System.AttributeUsage( + global::System.AttributeTargets.Class, + AllowMultiple = false, + Inherited = false)] + internal sealed class GenerateAttribute + : global::System.Attribute + { + } + """, + Encoding.UTF8 + ) + ); + } +); +``` + +--- + +# 13. Testing Incrementally + +Snapshot-testing generated source is not sufficient. + +A generator can generate perfectly correct code while defeating almost all incremental caching. + +Test both: + +```text +Correctness ++ +Incrementally +``` + +--- + +## Test Cases + +At minimum test: + +- first execution produces expected output; +- identical second execution is cached; +- unrelated source changes remain cached; +- changing one target only invalidates that target; +- changing one property only invalidates dependent stages; +- deleting a target removes its output; +- renaming a target changes the expected hint/source; +- changing global generator options invalidates appropriate output; +- changing an additional file invalidates only dependent output; +- global registry generation invalidates when expected. + +--- + +## Track Incremental Generator Steps + +Create the generator driver with tracking enabled. + +For example: + +```csharp +var driverOptions = + new GeneratorDriverOptions( + disabledOutputs: + IncrementalGeneratorOutputKind.None, + + trackIncrementalGeneratorSteps: + true + ); +``` + +Inspect tracked output reasons such as: + +```text +New +Modified +Unchanged +Cached +Removed +``` + +The exact reason expected depends on the stage and test scenario. + +The important point is that tests should prove: + +> An unrelated edit does not rerun expensive downstream generation. + +--- + +# 14. Roslyn Version Compatibility + +The most important packaging rule is: + +> **The version of `Microsoft.CodeAnalysis.*` used to compile your analyser/generator establishes a minimum compiler-host API requirement.** + +The consumer's: + +```xml +... +``` + +does not determine analyser compatibility. + +Analyser/generator code executes inside a compiler/IDE host. + +--- + +## Roslyn / Visual Studio Compatibility + +Microsoft's published compatibility baseline is: + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.0.1 | VS 2022 17.0 | C# 10 / .NET 6 | +| 4.1 | VS 2022 17.1 | C# 10 / .NET 6 | +| 4.2 | VS 2022 17.2 | C# 10 / .NET 6 | +| 4.3.1 | VS 2022 17.3 | C# 10 / .NET 6 | +| 4.4 | VS 2022 17.4 | C# 11 / .NET 7 | +| 4.5 | VS 2022 17.5 | C# 11 / .NET 7 | +| 4.6 | VS 2022 17.6 | C# 11 / .NET 7 | +| 4.7 | VS 2022 17.7 | C# 11 / .NET 7 | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.9.2 | VS 2022 17.9 | C# 12 / .NET 8 | +| 4.10 | VS 2022 17.10 | C# 12 / .NET 8 | +| 4.11 | VS 2022 17.11 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 4.13 | VS 2022 17.13 | C# 13 / .NET 9 | +| 4.14 | VS 2022 17.14 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +This table gives the **minimum documented Visual Studio host**. + +Do not interpret it as: + +```text +net8.0 application = Roslyn 4.8 analyser +``` + +That is incorrect. + +--- + +## Example + +A project may target: + +```xml +net8.0 +``` + +while being compiled by: + +```text +Visual Studio 2026 / Roslyn 5.x +``` + +An analyser compiled against Roslyn 5.0 may therefore work. + +The same `net8.0` project opened in: + +```text +Visual Studio 2022 17.8 / Roslyn 4.8 +``` + +cannot be assumed to load that Roslyn-5.0-based analyser. + +The application TFM did not change. + +The compiler host did. + +--- + +# 15. Visual Studio, .NET SDK, and Rider + +## Safe Roslyn Baselines + +Choose the oldest Roslyn version containing the APIs you require. + +Typical baseline choices are: + +| Minimum tooling you intend to support | Maximum baseline you should normally compile against | +| --- | ---: | +| VS 2022 17.8 / initial .NET 8 generation | Roslyn 4.8 | +| VS 2022 17.10 | Roslyn 4.10 | +| VS 2022 17.12 / initial .NET 9 generation | Roslyn 4.12 | +| VS 2022 17.14 | Roslyn 4.14 | +| VS 2026 18.0 / initial .NET 10 generation | Roslyn 5.0 | + +If you compile against a later package, you have deliberately raised your minimum host requirement unless you have proven otherwise. + +--- + +## .NET SDK + +The .NET SDK contains a compiler toolchain. + +Broad release alignment is: + +```text +.NET 8 / C# 12 ──► Roslyn 4.8 generation +.NET 9 / C# 13 ──► Roslyn 4.12 generation +.NET 10 / C# 14 ──► Roslyn 5.0 generation +``` + +However, SDK servicing and feature bands can contain later compiler versions. + +Therefore do not use: + +```text +TargetFramework == net10.0 +``` + +as proof that a particular Roslyn API is available to your analyser. + +Likewise: + +```xml +$(TargetFramework) +``` + +should not be used to choose the analyser binary. + +The relevant variable is the compiler host. + +--- + +## Rider + +Rider supports: + +- Roslyn analysers; +- source generators; +- generated-source navigation; +- analyser diagnostics; +- analyser quick fixes; +- source generator execution. + +However, JetBrains does not publish the same simple: + +```text +Rider Version => Maximum Microsoft.CodeAnalysis Version +``` + +matrix that Microsoft publishes for Visual Studio. + +Therefore: + +> **Do not invent a Rider/Roslyn version mapping.** + +If Rider support is part of your package contract: + +1. choose a conservative Roslyn baseline; +2. test the oldest Rider version you support; +3. test `dotnet build`; +4. test Rider design-time generation; +5. test generated-source navigation; +6. test analysers and code fixes where applicable. + +Build-time compiler compatibility and Rider IDE integration should be tested independently. + +--- + +# 16. Multi-Version Roslyn Packaging + +This area is frequently misunderstood. + +## NuGet Analyser Assets Are Not Normal TFM Assets + +Normal runtime/library assets support selection such as: + +```text +lib/net8.0/ +lib/net9.0/ +lib/net10.0/ +``` + +Analyser assets conventionally live under: + +```text +analysers/ + dotnet/ + cs/ + MyGenerator.dll +``` + +This is not a general-purpose: + +```text +Roslyn 4.8 +Roslyn 4.14 +Roslyn 5.0 +``` + +selection mechanism. + +Do not place multiple Roslyn-targeted implementations into the ordinary analyser folder and expect NuGet to automatically choose the correct one. + +--- + +## Strategy 1 — One Conservative Binary + +### Recommended Default + +Compile against the oldest Roslyn version required by your implementation. + +For example: + +```xml + +``` + +Package: + +```text +analysers/ + dotnet/ + cs/ + MyGenerator.dll +``` + +Advantages: + +- simplest; +- predictable; +- broadest compatibility; +- works naturally with IDEs; +- minimal packaging logic. + +Disadvantage: + +- cannot statically call newer Roslyn APIs. + +For most public generators, this is the correct approach. + +--- + +## Strategy 2 — Raise the Package Baseline + +If a newer Roslyn feature materially improves the generator, it may be better to explicitly raise the minimum compiler version. + +For example: + +```text +MyGenerator 2.x + Roslyn >= 4.8 + +MyGenerator 3.x + Roslyn >= 5.0 +``` + +Document the minimum IDE/compiler requirement. + +This is much easier for users to reason about than hidden runtime selection. + +--- + +## Strategy 3 — Separate Packages + +For significantly different implementations: + +```text +MyGenerator +MyGenerator.Roslyn5 +``` + +can be reasonable. + +Advantages: + +- explicit; +- predictable; +- simple runtime behaviour. + +Disadvantages: + +- more packages; +- more maintenance; +- users must select correctly. + +--- + +## Strategy 4 — MSBuild-Selected Binary + +Advanced packages can store binaries outside the automatically discovered analyser directory: + +```text +analysers/ + roslyn4.8/ + MyGenerator.dll + + roslyn5.0/ + MyGenerator.dll + +buildTransitive/ + MyGenerator.targets +``` + +Then a targets file can explicitly add exactly one: + +```xml + +``` + +depending on an intentionally selected compatibility band. + +Conceptually: + +```xml + + + + + + + +``` + +The difficult question is: + +> How is `MyGeneratorRoslynBand` determined reliably? + +There is no general NuGet analyser-asset negotiation equivalent to normal TFM selection. + +Do **not** use: + +```xml +$(TargetFramework) +``` + +for this. + +It identifies the application runtime target, not the compiler host. + +Using: + +```xml +$(NETCoreSdkVersion) +``` + +may work for a deliberately SDK-bound support model but must not be treated as universally equivalent to the active Roslyn host. + +Design-time builds, Visual Studio, Rider, CI, and explicit compiler toolsets all need testing. + +--- + +## Multi-Targeting the Generator Is Not Automatic Selection + +This: + +```xml + + netstandard2.0;net8.0 + +``` + +may produce two generator assemblies. + +It does **not** mean NuGet will choose: + +```text +netstandard2.0 analyser for old compiler +net8.0 analyser for new compiler +``` + +for you. + +Building multiple binaries and selecting analyser assets are separate problems. + +--- + +## Recommended Rule + +Unless there is a compelling requirement: + +> **Ship one `netstandard2.0` analyser/generator binary compiled against the oldest Roslyn API version you need.** + +This remains the most robust distribution strategy. + +--- + +# 17. `Microsoft.CodeAnalysis.Analysers` + +Do not confuse: + +```text +Microsoft.CodeAnalysis.CSharp +``` + +with: + +```text +Microsoft.CodeAnalysis.Analysers +``` + +They serve different purposes. + +--- + +## `Microsoft.CodeAnalysis.CSharp` + +Provides Roslyn compiler APIs used to implement your analyser/generator. + +Examples: + +```csharp +IIncrementalGenerator +DiagnosticAnalyser +SyntaxNode +Compilation +ISymbol +IOperation +``` + +--- + +## `Microsoft.CodeAnalysis.Analysers` + +This is a **meta-analyser package**. + +It analyses your analyser or source generator. + +Its purpose is to detect incorrect or unsafe usage of Roslyn/compiler APIs. + +It does not define your source-generator API baseline. + +--- + +## Current Package Version + +As of August 2026, the current stable package is: + +```text +Microsoft.CodeAnalysis.Analysers 5.9.0 +``` + +Do not assume its version must match: + +```text +Microsoft.CodeAnalysis.CSharp +``` + +For example, it is perfectly reasonable to have: + +```xml + + + +``` + +provided the meta-analyser version itself works with your build tooling. + +These represent separate concerns: + +```text +Microsoft.CodeAnalysis.CSharp + │ + └── minimum Roslyn API used by your generator + +Microsoft.CodeAnalysis.Analysers + │ + └── rules used while developing the generator +``` + +--- + +## Transitive Availability + +Roslyn compiler packages already bring `Microsoft.CodeAnalysis.Analysers` into the dependency graph as development tooling. + +You may nevertheless explicitly reference it when: + +- using Central Package Management; +- deliberately pinning meta-analyser behaviour; +- keeping analyser tooling versions consistent across a repository; +- making the analyser-project configuration obvious. + +--- + +## `PrivateAssets="all"` + +Roslyn development dependencies should normally use: + +```xml +PrivateAssets="all" +``` + +Example: + +```xml + + + +``` + +Your consumer should not gain ordinary runtime Roslyn package dependencies simply because it installed your source generator. + +--- + +## `EnforceExtendedAnalyserRules` + +Analyser and generator projects should normally enable: + +```xml + + true + +``` + +These rules detect implementation patterns that are particularly dangerous inside compiler-hosted code. + +Do not immediately suppress an `RSxxxx` diagnostic. + +First determine what compiler-host invariant the rule is protecting. + +--- + +## RS1035 + +`RS1035` bans APIs considered inappropriate for analysers. + +A common example is direct access to environment-dependent state. + +The underlying principle is: + +> Analyser/generator execution should not silently depend on machine-global environment state. + +Configuration should normally arrive through explicit compiler inputs such as: + +- analyser config options; +- additional files; +- MSBuild properties exposed through analyser config; +- source code; +- metadata references. + +--- + +## RS2008 + +`RS2008` relates to analyser diagnostic release tracking. + +If your analyser publishes public diagnostic IDs, maintain release tracking files such as: + +```text +AnalyserReleases.Shipped.md +AnalyserReleases.Unshipped.md +``` + +This helps detect accidental changes to diagnostic contracts. + +Diagnostic IDs are effectively part of your public API. + +--- + +## Treat Diagnostic Descriptors as Public Contracts + +Changing: + +```text +ZS0001 +``` + +to: + +```text +ZS0017 +``` + +may break: + +- `.editorconfig`; +- suppressions; +- CI configuration; +- documentation; +- consumer tooling. + +Similarly, changing: + +- default severity; +- category; +- diagnostic semantics; + +should be treated as a compatibility decision. + +--- + +# 18. Recommended Project Configuration + +A broadly-compatible generator project might start with: + +```xml + + + + netstandard2.0 + + latest + enable + + true + false + + true + + true + + + + + + + + + + + + + + + + + +``` + +Then centrally define: + +```xml + + 4.8.0 + 5.9.0 + +``` + +The exact Roslyn baseline is a product-support decision. + +--- + +## ProjectReference During Development + +A consuming project can reference the generator as: + +```xml + +``` + +--- + +## Separate Runtime Contracts From Compiler Tooling + +Prefer: + +```text +MyLibrary.Abstractions + │ + ├── public attributes + ├── runtime contracts + └── shared public APIs + +MyLibrary.SourceGenerators + │ + └── IIncrementalGenerator + +MyLibrary.Analysers + │ + └── DiagnosticAnalyser + +MyLibrary.CodeFixes + │ + └── CodeFixProvider +``` + +over mixing runtime APIs and compiler tooling into one assembly. + +This prevents Roslyn dependencies leaking into runtime package assets. + +--- + +# 19. Review Checklist + +## Analyser + +- [ ] Is this rule actually validation rather than generation? +- [ ] Am I using the narrowest appropriate analyser action? +- [ ] Does exact syntax matter? +- [ ] If not, should this use a symbol? +- [ ] If this is executable semantics, should this use `IOperation`? +- [ ] Is `EnableConcurrentExecution()` enabled? +- [ ] Is generated-code analysis explicitly configured? +- [ ] Are known framework/library symbols resolved once where appropriate? +- [ ] Are symbols compared semantically rather than by reference? +- [ ] Is whole-compilation analysis genuinely necessary? +- [ ] Could the diagnostic reasonably have a code fix? +- [ ] Are diagnostic IDs release-tracked? + +--- + +## Incremental Generator + +- [ ] Uses `IIncrementalGenerator`. +- [ ] Uses `ForAttributeWithMetadataName` where appropriate. +- [ ] Syntax predicates are extremely cheap. +- [ ] Semantic extraction happens once. +- [ ] `ISymbol` never enters persistent model state. +- [ ] `Compilation` does not propagate downstream. +- [ ] `SemanticModel` does not propagate downstream. +- [ ] `IOperation` does not propagate downstream. +- [ ] `SyntaxTree` does not propagate downstream. +- [ ] `SyntaxNode` is removed as early as possible. +- [ ] `Location` is removed as early as possible. +- [ ] Pipeline models are immutable. +- [ ] Pipeline models have value equality. +- [ ] Collection members have sequence equality. +- [ ] Arrays are not relied upon for model equality. +- [ ] `ImmutableArray` is wrapped or explicitly compared where equality matters. +- [ ] Transform callbacks are static where practical. +- [ ] Cancellation is honoured. +- [ ] `Collect()` is only used where global knowledge is necessary. +- [ ] Per-target output remains per-target. +- [ ] `Combine()` does not unnecessarily broaden invalidation. +- [ ] `CompilationProvider` is not casually combined into output. +- [ ] `WithComparer()` represents real logical equality. +- [ ] Hint names are deterministic. +- [ ] Generated text is deterministic. +- [ ] Constant source uses post-initialization output. +- [ ] Normal source validation lives in an analyser. +- [ ] Incremental caching behaviour has tests. + +--- + +## Packaging + +- [ ] Generator/analyser binaries are packed as analyser assets. +- [ ] Compiler tooling is not accidentally shipped as runtime `lib` output. +- [ ] Roslyn package dependencies are private. +- [ ] The Roslyn API baseline is intentional. +- [ ] The minimum supported Visual Studio version is documented. +- [ ] The minimum supported SDK/compiler environment is tested. +- [ ] Rider support is tested rather than inferred. +- [ ] Multi-targeting is not being mistaken for analyser asset selection. +- [ ] Multiple Roslyn binaries are not placed in the normal analyser folder expecting automatic selection. +- [ ] Any custom MSBuild analyser selection works during design-time builds. +- [ ] `Microsoft.CodeAnalysis.Analysers` is enabled. +- [ ] `EnforceExtendedAnalyserRules` is enabled. +- [ ] `RSxxxx` diagnostics are investigated rather than reflexively suppressed. + +--- + +# Summary + +The shortest version of this guide is: + +> **Analyser for validation; generator for generation.** +> **Syntax for syntax, symbols for declarations, operations for executable semantics.** +> **Use `ForAttributeWithMetadataName` whenever possible.** +> **`ISymbol`, `Compilation`, `SemanticModel`, and `IOperation` do not belong in incremental pipeline models.** +> **Remove `SyntaxNode` and `Location` as soon as possible.** +> **Immutable does not mean equatable: arrays, lists, and `ImmutableArray` require deliberate sequence equality.** +> **Use `EquatableArray` or an equivalent value-equatable collection abstraction.** +> **Avoid `Collect()` until global knowledge is genuinely required.** +> **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** +> **Compile against the oldest Roslyn API version containing the functionality you need.** +> **The consumer TFM does not determine analyser compatibility—the compiler host does.** +> **NuGet does not automatically choose between Roslyn-version-specific analyser binaries.** +> **Test incrementally and compatibility, not just generated source.** diff --git a/global.json b/global.json index 7c80d95..932522c 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "10.0.300", + "version": "10.0.400", "rollForward": "latestMajor", "allowPrerelease": false }, "msbuild-sdks": { - "Purview.DotNetProjectSdk": "1.0.0-prerelease.40" + "Purview.DotNetProjectSdk": "1.0.0-prerelease.42" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/msbuild-dotnet-test.binlog b/msbuild-dotnet-test.binlog deleted file mode 100644 index 5e53bb0..0000000 Binary files a/msbuild-dotnet-test.binlog and /dev/null differ diff --git a/msbuild.binlog b/msbuild.binlog deleted file mode 100644 index 4dca1a0..0000000 Binary files a/msbuild.binlog and /dev/null differ diff --git a/package.json b/package.json index 7806221..cc7d9e4 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.25", + "version": "1.0.0-prerelease.26", "private": true } diff --git a/prompts/sdk-diagnose-agent-folder-copy.md b/prompts/sdk-diagnose-agent-folder-copy.md new file mode 100644 index 0000000..49ad1c2 --- /dev/null +++ b/prompts/sdk-diagnose-agent-folder-copy.md @@ -0,0 +1,26 @@ +# sdk-diagnose-agent-folder-copy (generic prompt spec) + +Diagnose why the bundled `.agents/**` folder from `Purview.DotNetProjectSdk` did not appear at the expected +destination in a consuming repository. + +## Required behaviour + +1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available). +2. Confirm the consuming project is packable/buildable and imports the SDK via + `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build. +3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file, + `Directory.Build.props`, or command-line `-p:` overrides). +4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with + `-p:AgentPackDestinationFolder=`. +5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control + root metadata. +6. Re-run the build and confirm the destination folder now contains the copied files (including the + generated `.gitignore` for skill/prompt/agent subfolders). + +## Suggested output + +- A short root-cause explanation (missing import, disabled flag, wrong destination override, or repo-root + discovery miss). +- The exact command used to reproduce/verify the fix (for example + `dotnet build -p:AgentPackDestinationFolder=`). +- Confirmation that the expected files exist at the resolved destination path. diff --git a/skills/project-placement-defaults/.gitignore b/skills/project-placement-defaults/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/skills/project-placement-defaults/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/skills/sdk-configuration-reference/.gitignore b/skills/sdk-configuration-reference/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/skills/sdk-configuration-reference/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/skills/sdk-project-behavior-and-detection/.gitignore b/skills/sdk-project-behavior-and-detection/.gitignore new file mode 100644 index 0000000..2799754 --- /dev/null +++ b/skills/sdk-project-behavior-and-detection/.gitignore @@ -0,0 +1,8 @@ +# Ignore all files +* + +# Don't ignore directories, so Git can traverse them +!*/ + +# Keep this file +!.gitignore \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3998623..04454a4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -28,7 +28,7 @@ - + $(TestingTargetFrameworks) diff --git a/src/SourceGeneratorFramework.slnx b/src/SourceGeneratorFramework.slnx index e53a109..3889899 100644 --- a/src/SourceGeneratorFramework.slnx +++ b/src/SourceGeneratorFramework.slnx @@ -10,18 +10,30 @@ + + + + - + + + diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 0dc4d87..a8b7f1c 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,6 +1,9 @@ ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- +PSGF001 | Purview.SourceGeneratorFramework | Error | Generation capabilities must be a record PSGFR11 | Purview.SourceGeneratorFramework | Warning | Prefer ForAttributeWithMetadataName over CreateSyntaxProvider PSGFR12 | Purview.SourceGeneratorFramework | Warning | Use IIncrementalGenerator instead of ISourceGenerator PSGFR14 | Purview.SourceGeneratorFramework | Warning | Avoid RegisterImplementationSourceOutput +PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection lacks sequence equality +ADM0010 | Property | Error | Attribute data model property type is not cacheable | diff --git a/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelSymbolPropertyAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelSymbolPropertyAnalyzer.cs new file mode 100644 index 0000000..475a326 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelSymbolPropertyAnalyzer.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AttributeDataModelSymbolPropertyAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor Rule = new( + "ADM0010", + "Attribute data model property type is not cacheable", + "Property '{0}' type '{1}' is not cacheable for attribute data extraction. Use Purview.SourceGeneratorFramework.TypeIdentity or a string/string? type to capture type identity in a cacheable form.", + "Property", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + 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.RegisterCompilationStartAction(context => + { + var symbolType = context.Compilation.GetTypeByMetadataName("Microsoft.CodeAnalysis.ISymbol"); + var systemType = context.Compilation.GetTypeByMetadataName("System.Type"); + var typeIdentityType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.TypeIdentity" + ); + + context.RegisterSymbolAction( + context => AnalyzeNamedType(context, symbolType, systemType, typeIdentityType), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? symbolType, + INamedTypeSymbol? systemType, + INamedTypeSymbol? typeIdentityType + ) + { + if (context.Symbol is not INamedTypeSymbol typeSymbol) + return; + + if (typeSymbol.TypeKind is not TypeKind.Struct and not TypeKind.Class) + return; + + if ( + !typeSymbol + .GetAttributes() + .Any(a => + a.AttributeClass?.ToDisplayString() + == "Purview.SourceGeneratorFramework.Generators.GenerateAttribute" + ) + ) + return; + + foreach (var constructor in typeSymbol.InstanceConstructors) + { + foreach (var parameter in constructor.Parameters) + { + if (!parameter.Locations.Any(static loc => loc.IsInSource)) + continue; + + if (IsNonCacheableType(parameter.Type, symbolType, systemType, typeIdentityType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + parameter.Locations.FirstOrDefault(static loc => loc.IsInSource) + ?? typeSymbol.Locations.FirstOrDefault(), + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + } + } + } + + static bool IsNonCacheableType( + ITypeSymbol typeSymbol, + INamedTypeSymbol? symbolType, + INamedTypeSymbol? systemType, + INamedTypeSymbol? typeIdentityType + ) + { + if ( + typeIdentityType is not null + && SymbolEqualityComparer.Default.Equals(typeSymbol.OriginalDefinition, typeIdentityType) + ) + return false; + + if (typeSymbol.SpecialType == SpecialType.System_String) + return false; + + if (systemType is not null && SymbolEqualityComparer.Default.Equals(typeSymbol.OriginalDefinition, systemType)) + return true; + + if (symbolType is not null) + { + if (SymbolEqualityComparer.Default.Equals(typeSymbol.OriginalDefinition, symbolType)) + return true; + + if ( + typeSymbol is INamedTypeSymbol namedType + && namedType.AllInterfaces.Contains(symbolType, SymbolEqualityComparer.Default) + ) + return true; + } + + return false; + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/AvoidRegisterImplementationSourceOutputAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AvoidRegisterImplementationSourceOutputAnalyzer.cs index da98eae..38a0c46 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AvoidRegisterImplementationSourceOutputAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/AvoidRegisterImplementationSourceOutputAnalyzer.cs @@ -11,7 +11,7 @@ public sealed class AvoidRegisterImplementationSourceOutputAnalyzer : Diagnostic { public const string DiagnosticId = "PSGFR14"; - static readonly DiagnosticDescriptor Rule = new( + public static readonly DiagnosticDescriptor Rule = new( DiagnosticId, "Avoid RegisterImplementationSourceOutput", "Avoid RegisterImplementationSourceOutput unless the generator explicitly produces implementation-only sources; prefer RegisterSourceOutput", diff --git a/src/src/SourceGeneratorFramework.Analyzers/GenerationCapabilitiesMustBeRecordAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/GenerationCapabilitiesMustBeRecordAnalyzer.cs new file mode 100644 index 0000000..973a215 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/GenerationCapabilitiesMustBeRecordAnalyzer.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GenerationCapabilitiesMustBeRecordAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGF001"; + + public static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: "Generation capabilities must be a record", + messageFormat: "Capabilities type '{0}' must be declared as a record", + category: "Purview.SourceGeneratorFramework", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + 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.RegisterCompilationStartAction(static context => + { + var generationContextType = context.Compilation.GetTypeByMetadataName(typeof(GenerationContext<>).FullName); + if (generationContextType is null) + return; + + context.RegisterSyntaxNodeAction( + context => AnalyzeGenericName(context, generationContextType), + SyntaxKind.GenericName + ); + }); + } + + static void AnalyzeGenericName(SyntaxNodeAnalysisContext context, INamedTypeSymbol generationContextType) + { + var genericName = (GenericNameSyntax)context.Node; + var typeInfo = context.SemanticModel.GetTypeInfo(genericName); + if (typeInfo.Type is not INamedTypeSymbol constructedType) + return; + + if (!SymbolEqualityComparer.Default.Equals(constructedType.OriginalDefinition, generationContextType)) + return; + + if (constructedType.TypeArguments[0] is not INamedTypeSymbol capabilitiesType) + return; + + if (capabilitiesType.IsRecord) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + genericName.TypeArgumentList.Arguments[0].GetLocation(), + capabilitiesType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/PipelineModelReferenceEqualityCollectionAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PipelineModelReferenceEqualityCollectionAnalyzer.cs new file mode 100644 index 0000000..27de22d --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/PipelineModelReferenceEqualityCollectionAnalyzer.cs @@ -0,0 +1,287 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PipelineModelReferenceEqualityCollectionAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR15"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Pipeline model collection lacks sequence equality", + "Pipeline model member '{0}' uses '{1}', which does not provide sequence equality for incremental caching. Use EquatableArray or an equivalent value-equatable collection instead.", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Incremental source generator pipeline models should use value-equatable collections for members so that value equality compares contents rather than references.", + customTags: WellKnownDiagnosticTags.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(static context => + { + var compilation = context.Compilation; + var pipelineTypes = ResolvePipelineTypes(compilation); + var collectionTypes = ResolveCollectionTypes(compilation); + var referenceEqualityCollectionTypes = ResolveReferenceEqualityCollectionTypes(compilation); + + if (pipelineTypes.IsEmpty || referenceEqualityCollectionTypes.IsEmpty) + return; + + var modelTypes = CollectPipelineModelTypes(compilation, pipelineTypes, collectionTypes); + + foreach (var modelType in modelTypes) + { + AnalyzeModelType(context, modelType, referenceEqualityCollectionTypes); + } + }); + } + + static ImmutableArray ResolvePipelineTypes(Compilation compilation) + { + var builder = ImmutableArray.CreateBuilder(); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("Purview.SourceGeneratorFramework.GeneratorResult`1")); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("Microsoft.CodeAnalysis.IncrementalValuesProvider`1")); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("Microsoft.CodeAnalysis.IncrementalValueProvider`1")); + return builder.ToImmutable(); + } + + static ImmutableArray ResolveCollectionTypes(Compilation compilation) + { + var builder = ImmutableArray.CreateBuilder(); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("Purview.SourceGeneratorFramework.EquatableArray`1")); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableArray`1")); + return builder.ToImmutable(); + } + + static ImmutableArray ResolveReferenceEqualityCollectionTypes(Compilation compilation) + { + var builder = ImmutableArray.CreateBuilder(); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableArray`1")); + AddIfNotNull(builder, compilation.GetTypeByMetadataName("System.Collections.Generic.List`1")); + return builder.ToImmutable(); + } + + static void AddIfNotNull(ImmutableArray.Builder builder, INamedTypeSymbol? type) + { + if (type is not null) + builder.Add(type); + } + + static HashSet CollectPipelineModelTypes( + Compilation compilation, + ImmutableArray pipelineTypes, + ImmutableArray collectionTypes + ) + { + var directModels = new HashSet(SymbolEqualityComparer.Default); + + foreach (var typeSymbol in GetAllTypes(compilation.GlobalNamespace)) + { + foreach (var member in typeSymbol.GetMembers()) + { + var memberType = GetMemberType(member); + if (memberType is null) + continue; + + CollectPipelineTypeArguments(memberType, pipelineTypes, directModels); + } + } + + var expanded = new HashSet(SymbolEqualityComparer.Default); + var worklist = new Queue(directModels); + + while (worklist.Count > 0) + { + var modelType = worklist.Dequeue(); + if (!ShouldExpand(modelType) || !expanded.Add(modelType)) + continue; + + foreach (var member in modelType.GetMembers()) + { + var memberType = GetMemberType(member); + if (memberType is not INamedTypeSymbol namedMemberType) + continue; + + if (namedMemberType.IsGenericType) + { + var originalDefinition = namedMemberType.OriginalDefinition; + if (collectionTypes.Any(c => SymbolEqualityComparer.Default.Equals(originalDefinition, c))) + { + foreach (var typeArgument in namedMemberType.TypeArguments) + { + if (typeArgument is INamedTypeSymbol namedTypeArgument && ShouldExpand(namedTypeArgument)) + worklist.Enqueue(namedTypeArgument); + } + + continue; + } + } + + if (ShouldExpand(namedMemberType)) + worklist.Enqueue(namedMemberType); + } + } + + return expanded; + } + + static bool ShouldExpand(INamedTypeSymbol typeSymbol) => typeSymbol.Locations.Any(static loc => loc.IsInSource); + + static void CollectPipelineTypeArguments( + ITypeSymbol typeSymbol, + ImmutableArray pipelineTypes, + HashSet modelTypes + ) + { + if (typeSymbol is not INamedTypeSymbol namedType) + return; + + if (namedType.IsGenericType) + { + var originalDefinition = namedType.OriginalDefinition; + foreach (var pipelineType in pipelineTypes) + { + if (SymbolEqualityComparer.Default.Equals(originalDefinition, pipelineType)) + { + foreach (var typeArgument in namedType.TypeArguments) + { + if ( + typeArgument is INamedTypeSymbol namedTypeArgument + && namedTypeArgument.Locations.Any(static loc => loc.IsInSource) + ) + modelTypes.Add(namedTypeArgument); + } + } + } + + foreach (var typeArgument in namedType.TypeArguments) + { + CollectPipelineTypeArguments(typeArgument, pipelineTypes, modelTypes); + } + } + } + + static void AnalyzeModelType( + CompilationAnalysisContext context, + INamedTypeSymbol modelType, + ImmutableArray referenceEqualityCollectionTypes + ) + { + foreach (var member in modelType.GetMembers()) + { + if (member.IsImplicitlyDeclared) + continue; + + var memberType = member switch + { + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + _ => null, + }; + + if (memberType is null) + continue; + + if ( + memberType is IArrayTypeSymbol + || IsReferenceEqualityCollectionType(memberType, referenceEqualityCollectionTypes) + ) + { + ReportDiagnostic(context, member, memberType); + } + } + } + + static bool IsReferenceEqualityCollectionType( + ITypeSymbol typeSymbol, + ImmutableArray referenceEqualityCollectionTypes + ) + { + if (typeSymbol is not INamedTypeSymbol namedType || !namedType.IsGenericType) + return false; + + var originalDefinition = namedType.OriginalDefinition; + foreach (var collectionType in referenceEqualityCollectionTypes) + { + if (SymbolEqualityComparer.Default.Equals(originalDefinition, collectionType)) + return true; + } + + return false; + } + + static void ReportDiagnostic(CompilationAnalysisContext context, ISymbol member, ITypeSymbol memberType) + { + var location = GetMemberLocation(member); + if (location is null) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + location, + member.Name, + memberType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + static Location? GetMemberLocation(ISymbol member) + { + return member.Locations.FirstOrDefault(static loc => loc.IsInSource); + } + + static ITypeSymbol? GetMemberType(ISymbol member) + { + return member switch + { + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + IParameterSymbol parameter => parameter.Type, + IMethodSymbol method when method.AssociatedSymbol is null => method.ReturnType, + IEventSymbol eventSymbol => eventSymbol.Type, + _ => null, + }; + } + + static IEnumerable GetAllTypes(INamespaceSymbol namespaceSymbol) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol nestedNamespace) + { + foreach (var type in GetAllTypes(nestedNamespace)) + yield return type; + } + else if (member is INamedTypeSymbol type) + { + yield return type; + foreach (var nested in GetAllTypes(type)) + yield return nested; + } + } + } + + static IEnumerable GetAllTypes(INamedTypeSymbol typeSymbol) + { + foreach (var nested in typeSymbol.GetTypeMembers()) + { + yield return nested; + foreach (var deeper in GetAllTypes(nested)) + yield return deeper; + } + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferForAttributeWithMetadataNameAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferForAttributeWithMetadataNameAnalyzer.cs index 39b79e1..7a19980 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/PreferForAttributeWithMetadataNameAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferForAttributeWithMetadataNameAnalyzer.cs @@ -11,7 +11,7 @@ public sealed class PreferForAttributeWithMetadataNameAnalyzer : DiagnosticAnaly { public const string DiagnosticId = "PSGFR11"; - static readonly DiagnosticDescriptor Rule = new( + public static readonly DiagnosticDescriptor Rule = new( DiagnosticId, "Prefer ForAttributeWithMetadataName over CreateSyntaxProvider", "Prefer SyntaxProvider.ForAttributeWithMetadataName for attribute-based detection; it is faster and more incremental-friendly than CreateSyntaxProvider", diff --git a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj index a4cd984..01905a5 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj +++ b/src/src/SourceGeneratorFramework.Analyzers/SourceGeneratorFramework.Analyzers.csproj @@ -1,11 +1,42 @@ - + true $(RootNamespace) + + + + + + + + + %(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension) + + + + + + $(MSBuildThisFileDirectory)..\SourceGeneratorShared\bin\$(Configuration)\netstandard2.0\Purview.SourceGeneratorFramework.Shared.dll + + + + + diff --git a/src/src/SourceGeneratorFramework.Analyzers/UseIncrementalGeneratorAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/UseIncrementalGeneratorAnalyzer.cs index 1dd4baa..d582ede 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/UseIncrementalGeneratorAnalyzer.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/UseIncrementalGeneratorAnalyzer.cs @@ -11,7 +11,7 @@ public sealed class UseIncrementalGeneratorAnalyzer : DiagnosticAnalyzer { public const string DiagnosticId = "PSGFR12"; - static readonly DiagnosticDescriptor Rule = new( + public static readonly DiagnosticDescriptor Rule = new( DiagnosticId, "Use IIncrementalGenerator instead of ISourceGenerator", "Source generators should implement IIncrementalGenerator and use RegisterSourceOutput for incremental, cache-friendly generation", diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/AttributeDataModelGeneratorBenchmarks.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/AttributeDataModelGeneratorBenchmarks.cs new file mode 100644 index 0000000..56128a3 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/AttributeDataModelGeneratorBenchmarks.cs @@ -0,0 +1,43 @@ +using BenchmarkDotNet.Attributes; +using Purview.SourceGeneratorFramework.Generators; +using Purview.SourceGeneratorFramework.Testing; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +[MemoryDiagnoser] +public class AttributeDataModelGeneratorBenchmarks +{ + readonly SourceGeneratorTestRunner _runner = new(); + string _source; + + [GlobalSetup] + public void Setup() + { + _source = """ + using Purview.SourceGeneratorFramework; + using Purview.SourceGeneratorFramework.Generators; + + namespace Benchmarks + { + [GenerateAttributeDataModel(typeof(MyAttribute))] + public class MyAttribute : System.Attribute + { + public string Name { get; set; } = default!; + } + } + """; + } + + [Benchmark] + public async Task RunAsync() + { + var options = new SourceGeneratorTestOptions + { + CompileToAssembly = false, + EnableLogging = false, + ValidateCodeWriterScopes = false, + }; + + await _runner.RunAsync(_source, options); + } +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/CodeWriterBenchmarks.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/CodeWriterBenchmarks.cs new file mode 100644 index 0000000..dc1f8b6 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/CodeWriterBenchmarks.cs @@ -0,0 +1,32 @@ +using BenchmarkDotNet.Attributes; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +[MemoryDiagnoser] +public class CodeWriterBenchmarks +{ + GenerationSettings _settings; + + [GlobalSetup] + public void Setup() + { + _settings = new GenerationSettings("Benchmark", "1.0.0"); + } + + [Benchmark] + public string WriteManyClasses() + { + var writer = new CodeWriter(_settings); + writer.WriteAutoGeneratedHeader(); + + for (var i = 0; i < 1000; i++) + { + using (writer.WriteClassScope(new TypeDeclarationOptions($"Class{i}", TypeDeclarationAccessibility.Public))) + { + writer.WriteLine("// body"); + } + } + + return writer.ToString(); + } +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/EquatableArrayBenchmarks.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/EquatableArrayBenchmarks.cs new file mode 100644 index 0000000..a1a727b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/EquatableArrayBenchmarks.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; +using BenchmarkDotNet.Attributes; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +[MemoryDiagnoser] +public class EquatableArrayBenchmarks +{ + [Params(10, 100, 1000)] + public int Count { get; set; } + + EquatableArray _equatableArray1; + EquatableArray _equatableArray2; + ImmutableArray _immutableArray1; + ImmutableArray _immutableArray2; + + [GlobalSetup] + public void Setup() + { + var values1 = Enumerable.Range(0, Count).ToArray(); + var values2 = Enumerable.Range(0, Count).ToArray(); + + _equatableArray1 = EquatableArray.Create(values1); + _equatableArray2 = EquatableArray.Create(values2); + _immutableArray1 = [.. values1]; + _immutableArray2 = [.. values2]; + } + + [Benchmark(Baseline = true)] + public bool EquatableArrayEquals() => _equatableArray1.Equals(_equatableArray2); + + [Benchmark] + public bool ImmutableArrayReferenceEquals() => _immutableArray1.Equals(_immutableArray2); + + [Benchmark] + public bool ImmutableArraySequenceEqual() => _immutableArray1.SequenceEqual(_immutableArray2); +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SimpleGenerator.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SimpleGenerator.cs new file mode 100644 index 0000000..33541c7 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SimpleGenerator.cs @@ -0,0 +1,13 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +public sealed class SimpleGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + ctx.AddSource("Simple.g.cs", "namespace Benchmarks { public static class Simple { } }") + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SourceGeneratorTestRunnerBenchmarks.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SourceGeneratorTestRunnerBenchmarks.cs new file mode 100644 index 0000000..3f51089 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/SourceGeneratorTestRunnerBenchmarks.cs @@ -0,0 +1,45 @@ +using System.Text; +using BenchmarkDotNet.Attributes; +using Purview.SourceGeneratorFramework.Testing; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +[MemoryDiagnoser] +public class SourceGeneratorTestRunnerBenchmarks +{ + [Params(1, 10, 100)] + public int ClassCount { get; set; } + + [Params(false, true)] + public bool CompileToAssembly { get; set; } + + string _source; + readonly SourceGeneratorTestRunner _runner = new(); + + [GlobalSetup] + public void Setup() + { + var builder = new StringBuilder(); + for (var i = 0; i < ClassCount; i++) + { + builder.Append("public sealed class Class"); + builder.Append(i); + builder.AppendLine(" { }"); + } + + _source = builder.ToString(); + } + + [Benchmark] + public async Task RunAsync() + { + var options = new SourceGeneratorTestOptions + { + CompileToAssembly = CompileToAssembly, + EnableLogging = false, + ValidateCodeWriterScopes = false, + }; + + await _runner.RunAsync(_source, options); + } +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/TypeIdentityBenchmarks.cs b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/TypeIdentityBenchmarks.cs new file mode 100644 index 0000000..43a41da --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Benchmarks/TypeIdentityBenchmarks.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using BenchmarkDotNet.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +[MemoryDiagnoser] +public class TypeIdentityBenchmarks +{ + INamedTypeSymbol _int32Symbol; + INamedTypeSymbol _stringSymbol; + INamedTypeSymbol _listOfStringSymbol; + INamedTypeSymbol _dictionarySymbol; + INamedTypeSymbol _nestedGenericSymbol; + + [GlobalSetup] + public void Setup() + { + var references = new MetadataReference[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ImmutableArray<>).Assembly.Location), + }; + + var compilation = CSharpCompilation.Create( + "TypeIdentityBenchmarks", + [], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + _int32Symbol = compilation.GetSpecialType(SpecialType.System_Int32); + _stringSymbol = compilation.GetSpecialType(SpecialType.System_String); + + var list = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1"); + _listOfStringSymbol = list.Construct(_stringSymbol); + + var dictionary = compilation.GetTypeByMetadataName("System.Collections.Generic.Dictionary`2"); + _dictionarySymbol = dictionary.Construct(_stringSymbol, _int32Symbol); + + var immutableArray = compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableArray`1"); + _nestedGenericSymbol = immutableArray.Construct(list.Construct(_stringSymbol)); + } + + [Benchmark] + public TypeIdentity Int32Identity() => new(_int32Symbol); + + [Benchmark] + public TypeIdentity StringIdentity() => new(_stringSymbol); + + [Benchmark] + public TypeIdentity ListOfStringIdentity() => new(_listOfStringSymbol); + + [Benchmark] + public TypeIdentity DictionaryIdentity() => new(_dictionarySymbol); + + [Benchmark] + public TypeIdentity NestedGenericIdentity() => new(_nestedGenericSymbol); +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.props b/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.props new file mode 100644 index 0000000..74be1a1 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + true + true + + diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.targets b/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.targets new file mode 100644 index 0000000..df77b5d --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Directory.Build.targets @@ -0,0 +1,6 @@ + + + diff --git a/src/src/SourceGeneratorFramework.Benchmarks/GlobalUsings.cs b/src/src/SourceGeneratorFramework.Benchmarks/GlobalUsings.cs new file mode 100644 index 0000000..cded6c4 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using System; +global using System.Linq; +global using System.Threading.Tasks; diff --git a/src/src/SourceGeneratorFramework.Benchmarks/Program.cs b/src/src/SourceGeneratorFramework.Benchmarks/Program.cs new file mode 100644 index 0000000..64ca211 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/Program.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.Emit; + +namespace Purview.SourceGeneratorFramework.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) + { + var config = ManualConfig + .CreateEmpty() + .AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance)) + .AddLogger(ConsoleLogger.Default) + .AddExporter(MarkdownExporter.Default, HtmlExporter.Default) + .AddColumnProvider(DefaultColumnProviders.Instance) + .WithOptions(ConfigOptions.DisableOptimizationsValidator); + + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); + } +} diff --git a/src/src/SourceGeneratorFramework.Benchmarks/SourceGeneratorFramework.Benchmarks.csproj b/src/src/SourceGeneratorFramework.Benchmarks/SourceGeneratorFramework.Benchmarks.csproj new file mode 100644 index 0000000..c77cfe3 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Benchmarks/SourceGeneratorFramework.Benchmarks.csproj @@ -0,0 +1,24 @@ + + + Exe + net8.0;net9.0;net10.0 + false + false + $(NoWarn);IDE0130;CA1515; + + + + + + + + + + + + + + + + + diff --git a/src/src/SourceGeneratorFramework.CodeFixers/AttributeDataModelSymbolPropertyCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/AttributeDataModelSymbolPropertyCodeFixProvider.cs new file mode 100644 index 0000000..82182be --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/AttributeDataModelSymbolPropertyCodeFixProvider.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeDataModelSymbolPropertyCodeFixProvider))] +public sealed class AttributeDataModelSymbolPropertyCodeFixProvider : CodeFixProvider +{ + internal const string TypeIdentityEquivalenceKey = "TypeIdentity"; + internal const string StringEquivalenceKey = "string"; + + public override ImmutableArray FixableDiagnosticIds => + [AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable.Id]; + + public override FixAllProvider GetFixAllProvider() => new AttributeDataModelFixAllProvider(); + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (!TryGetTypeSyntax(node, out var typeSyntax, out var isNullable)) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Use TypeIdentity", + _ => + ReplaceTypeAsync( + context.Document, + root, + typeSyntax, + isNullable, + "global::Purview.SourceGeneratorFramework.TypeIdentity" + ), + TypeIdentityEquivalenceKey + ), + diagnostic + ); + + context.RegisterCodeFix( + CodeAction.Create( + "Use string", + _ => ReplaceTypeAsync(context.Document, root, typeSyntax, isNullable, "string"), + StringEquivalenceKey + ), + diagnostic + ); + } + } + + internal static bool TryGetTypeSyntax(SyntaxNode node, out TypeSyntax typeSyntax, out bool isNullable) + { + isNullable = false; + typeSyntax = null!; + + TypeSyntax? candidate = null; + if (node is TypeSyntax typeSyntaxNode) + { + candidate = typeSyntaxNode; + } + else if (node is ParameterSyntax parameter) + { + candidate = parameter.Type; + } + else if (node.AncestorsAndSelf().OfType().FirstOrDefault() is { } ancestorParameter) + { + candidate = ancestorParameter.Type; + } + else if (node is PropertyDeclarationSyntax property) + { + candidate = property.Type; + } + else if (node.AncestorsAndSelf().OfType().FirstOrDefault() is { } ancestorProperty) + { + candidate = ancestorProperty.Type; + } + else if (node is VariableDeclaratorSyntax { Parent: VariableDeclarationSyntax { Type: { } declarationType } }) + { + candidate = declarationType; + } + else if ( + node.AncestorsAndSelf().OfType().FirstOrDefault() is + { Type: { } ancestorDeclarationType } + ) + { + candidate = ancestorDeclarationType; + } + + if (candidate is null) + return false; + + typeSyntax = candidate; + isNullable = candidate is NullableTypeSyntax; + return true; + } + + internal static Task ReplaceTypeAsync( + Document document, + SyntaxNode root, + TypeSyntax typeSyntax, + bool isNullable, + string newTypeName + ) + { + var newType = SyntaxFactory + .ParseTypeName(newTypeName + (isNullable ? "?" : "")) + .WithTriviaFrom(typeSyntax) + .WithLeadingTrivia(typeSyntax.GetLeadingTrivia()) + .WithTrailingTrivia(typeSyntax.GetTrailingTrivia()); + + var newRoot = root.ReplaceNode(typeSyntax, newType); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + internal sealed class AttributeDataModelFixAllProvider : DocumentBasedFixAllProvider + { + protected override async Task FixAllAsync( + FixAllContext fixAllContext, + Document document, + ImmutableArray diagnostics + ) + { + if (fixAllContext is null) + throw new ArgumentNullException(nameof(fixAllContext)); + if (document is null) + throw new ArgumentNullException(nameof(document)); + + var root = await document.GetSyntaxRootAsync(fixAllContext.CancellationToken).ConfigureAwait(false); + if (root is null) + return null; + + var isTypeIdentity = fixAllContext.CodeActionEquivalenceKey == TypeIdentityEquivalenceKey; + var newTypeName = isTypeIdentity ? "global::Purview.SourceGeneratorFramework.TypeIdentity" : "string"; + + var replacements = new List<(SyntaxNode oldNode, SyntaxNode newNode)>(); + foreach (var diagnostic in diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (!TryGetTypeSyntax(node, out var typeSyntax, out var isNullable)) + continue; + + var newType = SyntaxFactory + .ParseTypeName(newTypeName + (isNullable ? "?" : "")) + .WithTriviaFrom(typeSyntax) + .WithLeadingTrivia(typeSyntax.GetLeadingTrivia()) + .WithTrailingTrivia(typeSyntax.GetTrailingTrivia()); + + replacements.Add((typeSyntax, newType)); + } + + if (replacements.Count == 0) + return document; + + var newRoot = root.ReplaceNodes( + replacements.Select(static r => r.oldNode), + (original, _) => replacements.First(r => r.oldNode == original).newNode + ); + + return document.WithSyntaxRoot(newRoot); + } + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PipelineModelReferenceEqualityCollectionCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PipelineModelReferenceEqualityCollectionCodeFixProvider.cs new file mode 100644 index 0000000..76da431 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PipelineModelReferenceEqualityCollectionCodeFixProvider.cs @@ -0,0 +1,80 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PipelineModelReferenceEqualityCollectionCodeFixProvider))] +public sealed class PipelineModelReferenceEqualityCollectionCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "UseEquatableArray"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR15"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var semanticModel = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (!AttributeDataModelSymbolPropertyCodeFixProvider.TryGetTypeSyntax(node, out var typeSyntax, out _)) + continue; + + var typeInfo = semanticModel.GetTypeInfo(typeSyntax); + if (typeInfo.Type is not INamedTypeSymbol namedType || !namedType.IsGenericType) + continue; + + var originalDefinition = namedType.OriginalDefinition; + var immutableArrayType = semanticModel.Compilation.GetTypeByMetadataName( + "System.Collections.Immutable.ImmutableArray`1" + ); + if (immutableArrayType is null) + continue; + + if (!SymbolEqualityComparer.Default.Equals(originalDefinition, immutableArrayType)) + continue; + + var elementType = namedType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + + context.RegisterCodeFix( + CodeAction.Create( + "Use EquatableArray", + _ => ReplaceTypeAsync(context.Document, root, typeSyntax, elementType), + EquivalenceKey + ), + diagnostic + ); + } + } + + static Task ReplaceTypeAsync( + Document document, + SyntaxNode root, + TypeSyntax typeSyntax, + string elementType + ) + { + var newType = SyntaxFactory + .ParseTypeName($"global::Purview.SourceGeneratorFramework.EquatableArray<{elementType}>") + .WithTriviaFrom(typeSyntax) + .WithLeadingTrivia(typeSyntax.GetLeadingTrivia()) + .WithTrailingTrivia(typeSyntax.GetTrailingTrivia()); + + var newRoot = root.ReplaceNode(typeSyntax, newType); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/SourceGeneratorFramework.CodeFixers.csproj b/src/src/SourceGeneratorFramework.CodeFixers/SourceGeneratorFramework.CodeFixers.csproj new file mode 100644 index 0000000..3371f58 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/SourceGeneratorFramework.CodeFixers.csproj @@ -0,0 +1,43 @@ + + + true + $(RootNamespace) + + + + + + + + + + + + + + + + %(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension) + + + + + + $(MSBuildThisFileDirectory)..\SourceGeneratorShared\bin\$(Configuration)\netstandard2.0\Purview.SourceGeneratorFramework.Shared.dll + + + + + + diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs new file mode 100644 index 0000000..3a6dd60 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs @@ -0,0 +1,15 @@ +#if NETSTANDARD2_0 || NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + +using System.ComponentModel; + +// Compilation error of CS0518 IsExternalInit is not defined when using .NET Standard. +// re: https://mking.net/blog/error-cs0518-isexternalinit-not-defined +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace System.Runtime.CompilerServices; + +#pragma warning restore IDE0130 // Namespace does not match folder structure + +[EditorBrowsable(EditorBrowsableState.Never)] +static class IsExternalInit; + +#endif diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/StringExtension.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/StringExtension.cs new file mode 100644 index 0000000..a1ebdc9 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/Extensions/System/StringExtension.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace System; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class StringExtension +{ + extension(string? value) + { + /// + /// Surrounds the string with the specified string. Default is double quotes. + /// + /// The string to surround the value with. + /// The surrounded string. + public string Surround(string surroundWith = "\"") => $"{surroundWith}{value}{surroundWith}"; + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/GlobalUsings.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/GlobalUsings.cs new file mode 100644 index 0000000..03744f4 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Microsoft.CodeAnalysis; +global using Purview.SourceGeneratorFramework.Generators; +global using Purview.SourceGeneratorFramework.Helpers; +global using Purview.SourceGeneratorFramework.Logging; diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorModels.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs similarity index 72% rename from src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorModels.cs rename to src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs index 8fbbeb7..9d66717 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorModels.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/Models.cs @@ -55,3 +55,23 @@ public readonly partial record struct GenerateServiceAttributeData( string? Lifetime, [Property] string? Name ); + +/// +/// Describes a discovered service target. +/// +readonly record struct ServiceTarget(string TypeName, string ClassName, string Name, string LifetimeMemberName) +{ + /// + /// An empty . + /// + public static readonly ServiceTarget Empty; +} + +/// +/// Aggregated generation inputs for the service registration generator. +/// +readonly record struct ServiceRegistrationGenerationModel( + GenerationContext Context, + EquatableArray Targets, + bool EmitServiceInfo = false +); diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorPropertyLibrary.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/PropertyLibrary.cs similarity index 57% rename from src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorPropertyLibrary.cs rename to src/src/SourceGeneratorFramework.ExampleGenerator/PropertyLibrary.cs index e4f4ed7..08fc907 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorPropertyLibrary.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/PropertyLibrary.cs @@ -3,15 +3,17 @@ namespace Purview.SourceGeneratorFramework.ExampleGenerator; /// /// Provides compiler-visible property names used by the service registration generator. /// -static class ServiceRegistrationGeneratorPropertyLibrary +static class PropertyLibrary { /// /// When set to , disables the service registration generator. /// - public const string DisableServiceRegistrationGenerator = "DisableServiceRegistrationGenerator"; + public const string DisableServiceRegistrationGenerator = + SourceGeneratorBuildProperties.BuildProperty + "DisableServiceRegistrationGenerator"; /// /// When set to , emits the optional ServiceInfo class. /// - public const string EmitServiceRegistrationInfo = "EmitServiceRegistrationInfo"; + public const string EmitServiceRegistrationInfo = + SourceGeneratorBuildProperties.BuildProperty + "EmitServiceRegistrationInfo"; } diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs index f872906..39a9905 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs @@ -12,27 +12,18 @@ static class ServiceRegistrationEmitter /// Emits the GenerateServiceAttribute attribute and ServiceLifetime enum /// via . /// - public static void EmitAttributeAndEnum( - IncrementalGeneratorPostInitializationContext spc, - string generatorName, - string? generatorVersion - ) + public static void EmitAttributeAndEnum(IncrementalGeneratorPostInitializationContext spc) { - var writer = new CodeWriter( - generatorName: generatorName, - generatorVersion: generatorVersion ?? "1.0.0.0", + CodeWriter writer = new( + GenerationSettings.Create(), throwOnUnclosedScopes: false ); writer.WriteAutoGeneratedHeader(); - writer.WriteFileScopedNamespace(ServiceRegistrationGeneratorTypeLibrary.GenerateServiceAttribute); + writer.WriteFileScopedNamespace(TypeLibrary.GenerateServiceAttribute); writer.WriteEnum( - new TypeDeclarationOptions(ServiceRegistrationGeneratorTypeLibrary.ServiceLifetime) - { - Accessibility = TypeDeclarationAccessibility.Public, - IsPartial = false, - }, + new(TypeLibrary.ServiceLifetime, TypeDeclarationAccessibility.Public) { IsPartial = false }, ew => { ew.WriteLine("Singleton = 0,"); @@ -42,43 +33,26 @@ public static void EmitAttributeAndEnum( ); writer.WriteAttributeClass( - new TypeDeclarationOptions(ServiceRegistrationGeneratorTypeLibrary.GenerateServiceAttribute) - { - Accessibility = TypeDeclarationAccessibility.Public, - IsPartial = false, - }, + new(TypeLibrary.GenerateServiceAttribute, TypeDeclarationAccessibility.Public) { IsPartial = false }, AttributeTargets.Class, cw => { cw.WriteConstructor( - new ConstructorDeclarationOptions("GenerateServiceAttribute") + new("GenerateServiceAttribute", TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [ - new ParameterDeclarationOptions( - "lifetime", - ServiceRegistrationGeneratorTypeLibrary.ServiceLifetime - ) - { - DefaultValue = "ServiceLifetime.Singleton", - }, + new("lifetime", TypeLibrary.ServiceLifetime) { DefaultValue = "ServiceLifetime.Singleton" }, ], }, body => body.WriteLine("Lifetime = lifetime;") ); - cw.WriteProperty( - new PropertyDeclarationOptions("Lifetime", ServiceRegistrationGeneratorTypeLibrary.ServiceLifetime) - { - Accessibility = TypeDeclarationAccessibility.Public, - } - ); + cw.WriteProperty(new("Lifetime", TypeLibrary.ServiceLifetime, TypeDeclarationAccessibility.Public)); cw.WriteProperty( - new PropertyDeclarationOptions("Name", PurviewTypeLibrary.System.String.MakeNullable()) + new("Name", PurviewTypeLibrary.System.String.MakeNullable(), TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, HasSetter = true, Initializer = "null", } @@ -108,38 +82,26 @@ public static void Execute(SourceProductionContext spc, ServiceRegistrationGener var writer = model.Context.CreateCodeWriter(); writer.WriteAutoGeneratedHeader(); - writer.WriteFileScopedNamespace(ServiceRegistrationGeneratorTypeLibrary.ServiceCollectionExtensions); + writer.WriteFileScopedNamespace(TypeLibrary.ServiceCollectionExtensions); writer.WriteClass( - new TypeDeclarationOptions(ServiceRegistrationGeneratorTypeLibrary.ServiceCollectionExtensions) + new(TypeLibrary.ServiceCollectionExtensions, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, IsPartial = false, }, cw => cw.WriteMethod( - new MethodDeclarationOptions( + new( "AddExampleServices", - ServiceRegistrationGeneratorTypeLibrary - .Microsoft - .Extensions - .DependencyInjection - .IServiceCollection + TypeLibrary.Microsoft.Extensions.DependencyInjection.IServiceCollection, + TypeDeclarationAccessibility.Public ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [ - new ParameterDeclarationOptions( - "services", - ServiceRegistrationGeneratorTypeLibrary - .Microsoft - .Extensions - .DependencyInjection - .IServiceCollection - ) + new("services", TypeLibrary.Microsoft.Extensions.DependencyInjection.IServiceCollection) { IsThis = true, }, @@ -151,7 +113,7 @@ public static void Execute(SourceProductionContext spc, ServiceRegistrationGener { body.Comment($"Service name: {target.Name}"); body.WriteLine( - $"{ServiceRegistrationGeneratorTypeLibrary.Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions}.Add{target.LifetimeMemberName}<{target.TypeName}>(services);" + $"{TypeLibrary.Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions}.Add{target.LifetimeMemberName}<{target.TypeName}>(services);" ); } @@ -170,21 +132,16 @@ static void EmitServiceInfo(SourceProductionContext spc, ServiceRegistrationGene { var writer = model.Context.CreateCodeWriter(); writer.WriteAutoGeneratedHeader(); - writer.WriteFileScopedNamespace(ServiceRegistrationGeneratorTypeLibrary.ServiceInfo); + writer.WriteFileScopedNamespace(TypeLibrary.ServiceInfo); writer.WriteClass( - new TypeDeclarationOptions(ServiceRegistrationGeneratorTypeLibrary.ServiceInfo) - { - Accessibility = TypeDeclarationAccessibility.Public, - IsStatic = true, - IsPartial = false, - }, + new(TypeLibrary.ServiceInfo, TypeDeclarationAccessibility.Public) { IsStatic = true, IsPartial = false }, cw => { foreach (var target in model.Targets) { cw.WriteClass( - new TypeDeclarationOptions(target.ClassName) + new(target.ClassName) { Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, @@ -193,33 +150,32 @@ static void EmitServiceInfo(SourceProductionContext spc, ServiceRegistrationGene inner => { inner.WriteProperty( - new PropertyDeclarationOptions( + new( "Name", - TypeValueObject.Create().AsTypeReference() + TypeIdentity.Create().AsTypeReference(), + TypeDeclarationAccessibility.Public ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, ExpressionBody = $"\"{target.Name}\"", } ); inner.WriteProperty( - new PropertyDeclarationOptions( + new( "Lifetime", - TypeValueObject.Create().AsTypeReference() + TypeIdentity.Create().AsTypeReference(), + TypeDeclarationAccessibility.Public ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, ExpressionBody = $"\"{target.LifetimeMemberName}\"", } ); inner.WriteProperty( - new PropertyDeclarationOptions("Type", PurviewTypeLibrary.System.Type) + new("Type", PurviewTypeLibrary.System.Type, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, ExpressionBody = $"typeof({target.TypeName})", } diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGenerator.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGenerator.cs index 7e6ce5b..7959c7a 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGenerator.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGenerator.cs @@ -6,36 +6,29 @@ namespace Purview.SourceGeneratorFramework.Examples; [Generator] public partial class ServiceRegistrationGenerator : IIncrementalGenerator { - const string GeneratorName = "ServiceRegistrationGenerator"; - const string GeneratorVersion = "1.0.0"; - /// /// Initializes the generator pipeline. /// public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterEmbeddedAttribute(GeneratorName, GeneratorVersion); - - context.RegisterPostInitializationOutput(spc => - ServiceRegistrationEmitter.EmitAttributeAndEnum(spc, GeneratorName, GeneratorVersion) - ); + context + .RegisterEmbeddedAttribute() + .RegisterPostInitializationOutput(ServiceRegistrationEmitter.EmitAttributeAndEnum); var emitServiceInfo = IncrementalPipeline.PropertyValueProvider( context, - ServiceRegistrationGeneratorPropertyLibrary.EmitServiceRegistrationInfo, + PropertyLibrary.EmitServiceRegistrationInfo, value => bool.TryParse(value, out var result) && result ); - var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( context, - GeneratorName, - GeneratorVersion, - ServiceRegistrationGeneratorPropertyLibrary.DisableServiceRegistrationGenerator + PropertyLibrary.DisableServiceRegistrationGenerator ); var targets = IncrementalPipeline.ForAttributeWithMetadataName( context, - ServiceRegistrationGeneratorTypeLibrary.GenerateServiceAttribute, + TypeLibrary.GenerateServiceAttribute, CreateServiceTarget ); @@ -57,7 +50,7 @@ static ServiceTarget CreateServiceTarget(GeneratorAttributeSyntaxContext ctx, Ca return ServiceTarget.Empty; var attributeData = ctx.Attributes.FirstOrDefault(a => - ServiceRegistrationGeneratorTypeLibrary.GenerateServiceAttribute.Equals(a.AttributeClass) + TypeLibrary.GenerateServiceAttribute.Equals(a.AttributeClass) ); if (attributeData is null) return ServiceTarget.Empty; @@ -66,7 +59,7 @@ static ServiceTarget CreateServiceTarget(GeneratorAttributeSyntaxContext ctx, Ca if (!model.Exists) return ServiceTarget.Empty; - var lifetime = model.Lifetime ?? "Purview.SourceGeneratorFramework.Examples.ServiceLifetime.Singleton"; + var lifetime = model.Lifetime ?? TypeLibrary.ServiceLifetime.StaticMember("Singleton"); var memberName = lifetime.Substring(lifetime.LastIndexOf('.') + 1); return new ServiceTarget( @@ -77,23 +70,3 @@ static ServiceTarget CreateServiceTarget(GeneratorAttributeSyntaxContext ctx, Ca ); } } - -/// -/// Describes a discovered service target. -/// -readonly record struct ServiceTarget(string TypeName, string ClassName, string Name, string LifetimeMemberName) -{ - /// - /// An empty . - /// - public static readonly ServiceTarget Empty; -} - -/// -/// Aggregated generation inputs for the service registration generator. -/// -readonly record struct ServiceRegistrationGenerationModel( - GenerationContext Context, - EquatableArray Targets, - bool EmitServiceInfo = false -); diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj b/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj index 812002d..3d24ab0 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/SourceGeneratorFramework.ExampleGenerator.csproj @@ -6,13 +6,15 @@ - - + + + - - - - - - - - - - diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorTypeLibrary.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/TypeLibrary.cs similarity index 53% rename from src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorTypeLibrary.cs rename to src/src/SourceGeneratorFramework.ExampleGenerator/TypeLibrary.cs index a1c0460..16bf75f 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationGeneratorTypeLibrary.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/TypeLibrary.cs @@ -1,10 +1,12 @@ namespace Purview.SourceGeneratorFramework.ExampleGenerator; /// -/// Provides instances used by the service registration generator. +/// Provides instances used by the service registration generator. /// -static class ServiceRegistrationGeneratorTypeLibrary +static class TypeLibrary { + const string ExpamplesNamespace = "Purview.SourceGeneratorFramework.Examples"; + /// /// Common types from the Microsoft.Extensions.DependencyInjection namespace. /// @@ -20,20 +22,19 @@ public static class Extensions /// public static class DependencyInjection { + const string DINamespace = "Microsoft.Extensions.DependencyInjection"; + /// /// Microsoft.Extensions.DependencyInjection.IServiceCollection. /// - public static readonly TypeValueObject IServiceCollection = new( - "IServiceCollection", - "Microsoft.Extensions.DependencyInjection" - ); + public static readonly TypeIdentity IServiceCollection = new(nameof(IServiceCollection), DINamespace); /// /// Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions. /// - public static readonly TypeValueObject ServiceCollectionServiceExtensions = new( - "ServiceCollectionServiceExtensions", - "Microsoft.Extensions.DependencyInjection" + public static readonly TypeIdentity ServiceCollectionServiceExtensions = new( + nameof(ServiceCollectionServiceExtensions), + DINamespace ); } } @@ -42,32 +43,26 @@ public static class DependencyInjection /// /// The [GenerateService] attribute type. /// - public static readonly TypeValueObject GenerateServiceAttribute = new( - "GenerateServiceAttribute", - "Purview.SourceGeneratorFramework.Examples" + public static readonly TypeIdentity GenerateServiceAttribute = new( + nameof(GenerateServiceAttribute), + ExpamplesNamespace ); /// /// The ServiceLifetime enum type. /// - public static readonly TypeValueObject ServiceLifetime = new( - "ServiceLifetime", - "Purview.SourceGeneratorFramework.Examples" - ); + public static readonly TypeIdentity ServiceLifetime = new(nameof(ServiceLifetime), ExpamplesNamespace); /// /// The static ServiceCollectionExtensions class. /// - public static readonly TypeValueObject ServiceCollectionExtensions = new( - "ServiceCollectionExtensions", - "Purview.SourceGeneratorFramework.Examples" + public static readonly TypeIdentity ServiceCollectionExtensions = new( + nameof(ServiceCollectionExtensions), + ExpamplesNamespace ); /// /// The static ServiceInfo class. /// - public static readonly TypeValueObject ServiceInfo = new( - "ServiceInfo", - "Purview.SourceGeneratorFramework.Examples" - ); + public static readonly TypeIdentity ServiceInfo = new(nameof(ServiceInfo), ExpamplesNamespace); } diff --git a/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md index ad83b78..51b3992 100644 --- a/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md @@ -8,6 +8,5 @@ | ADM0005 | DefaultValue | Error | Default value cannot be emitted for the property type | | ADM0006 | DefaultValue | Error | Non-nullable reference type property requires a default value | | ADM0007 | AutoDiscovery | Error | Auto-discovery requires a target attribute type | -| ADM0008 | TypeArgument | Error | Type argument property type must be a symbol type | +| ADM0008 | TypeArgument | Error | Type argument property type must be TypeIdentity | | ADM0009 | Property | Error | IsEnum property must be a string type | -| PSG0001 | LoggingSupport | Warning | Source generator must be partial | diff --git a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs index 15e9ea4..74a70f0 100644 --- a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs +++ b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Generators.Helpers; using Purview.SourceGeneratorFramework.Logging; namespace Purview.SourceGeneratorFramework.Generators; @@ -7,39 +8,45 @@ namespace Purview.SourceGeneratorFramework.Generators; [Generator] public sealed class AttributeDataModelGenerator : IIncrementalGenerator { - static TypeReferenceOptions TypeReference(string typeName) => new(new TypeValueObject(typeName, null)); + static TypeReference TypeReference(string typeName) => new(new TypeIdentity(typeName, null)); public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterEmbeddedAttribute(typeof(AttributeDataModelGenerator).FullName!, AssemblyInfo.Version); - - context.RegisterPostInitializationOutput(context => - { - foreach (var (hintName, source) in SourceEmitter.AttributeDataEmit()) - context.AddSource(hintName, source); - }); + context + .RegisterEmbeddedAttribute() + .RegisterPostInitializationOutput(context => + { + foreach (var (hintName, source) in SourceEmitter.AttributeDataEmit()) + context.AddSource(hintName, source); + }); - var targets = AttributeDataModelLibrary.GetTargets(context, logger: null); - var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + var targetPipeline = AttributeDataModelLibrary.GetAttributeTargetPipeline(context); + var contextPipeline = IncrementalPipeline.DefaultGenerationContextValueProvider( context, - nameof(AttributeDataModelGenerator), - AssemblyInfo.Version, PropertyLibrary.DisableAttributeDataSourceGenerator ); - IncrementalPipeline.RegisterSourceOutput( - context, - targets, - generationContext, - static (spc, target, generationContext) => + var outputPipeline = targetPipeline.CombineWithContext(contextPipeline); + + context.RegisterSourceOutput( + outputPipeline, + static (spc, outputContext) => { + var (target, generationContext) = outputContext; + if (generationContext.Settings.IsSourceGeneratorDisabled) { generationContext.Info("AttributeDataModelGenerator is disabled."); return; } - GenerateAttributeDataModel(spc, target, generationContext); + if (target.HasDiagnostics) + spc.ReportDiagnostics(target.Diagnostics); + + if (!target.ShouldProcess) + return; + + GenerateAttributeDataModel(spc, target.Value, generationContext); } ); } @@ -47,7 +54,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static void GenerateAttributeDataModel( SourceProductionContext spc, AttributeDataModelTarget target, - GenerationContext generationContext + GenerationContext generationContext ) { generationContext.Debug("Generating AttributeDataModel for {0}", target.StructName); @@ -55,7 +62,7 @@ GenerationContext generationContext var writer = generationContext.CreateCodeWriter(); writer.WriteAutoGeneratedHeader(); - writer.WriteUsing("Purview.SourceGeneratorFramework.Extensions").NewLine(); + writer.WriteUsing("Purview.SourceGeneratorFramework").NewLine(); if (string.IsNullOrEmpty(target.Namespace)) { @@ -72,22 +79,15 @@ GenerationContext generationContext static void WriteStruct(CodeWriter writer, AttributeDataModelTarget target) { - TypeDeclarationOptions options = new(target.StructName) + TypeDeclarationOptions options = new(target.StructName, target.Accessibility) { - Accessibility = target.Accessibility, IsReadOnly = target.IsReadOnly, IsPartial = true, }; using (target.IsRecord ? writer.WriteRecordStructScope(options) : writer.WriteStructScope(options)) { - writer.WriteProperty( - new("Exists", TypeReference("bool")) - { - Accessibility = TypeDeclarationAccessibility.Public, - HasSetter = false, - } - ); + writer.WriteProperty(new("Exists", TypeReference("bool"), TypeDeclarationAccessibility.Public)); foreach (var property in target.Properties) { @@ -95,10 +95,12 @@ static void WriteStruct(CodeWriter writer, AttributeDataModelTarget target) continue; writer.WriteProperty( - new(property.PropertyName, TypeReference(property.FullyQualifiedTypeName)) + new( + property.PropertyName, + TypeReference(property.FullyQualifiedTypeName), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, - HasSetter = true, IsInitOnly = true, } ); @@ -109,11 +111,24 @@ static void WriteStruct(CodeWriter writer, AttributeDataModelTarget target) WriteEmptyField(writer, target); WriteAllAttributeDataMethod(writer, target); WriteAllAttributeDataSymbolMethod(writer, target); + WriteFromAttributeDataArrayMethod(writer, target); + + // ISymbol attribute extraction methods with out parameters WriteFromAttributeDataSymbolMethod(writer, target); + WriteTryFromAttributeDataSymbolMethod(writer, target); + + // AttributeData array extraction methods with out parameters WriteFromAttributeDataArrayWithOutMethod(writer, target); + WriteTryFromAttributeDataArrayWithOutMethod(writer, target); + + // ISymbol attribute extraction methods with out parameters WriteFromAttributeDataSymbolWithOutMethod(writer, target); + WriteTryFromAttributeDataSymbolWithOutMethod(writer, target); + + // AttributeData extraction methods WriteFromAttributeDataMethod(writer, target); + WriteTryFromAttributeDataMethod(writer, target); } } @@ -128,19 +143,18 @@ static void WriteConstructor(CodeWriter writer, AttributeDataModelTarget target) } writer.WriteConstructor( - new(target.StructName) + new(target.StructName, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [.. parameters], Initializer = GetPrimaryConstructorInitializer(target), }, body => { - body.WriteLine("Exists = exists;"); + body.WriteAssignment("Exists", "exists"); foreach (var property in target.Properties) { if (!property.IsExplicit) - body.WriteLine($"this.{property.PropertyName} = {property.PropertyName};"); + body.WriteAssignment($"this.{property.PropertyName}", property.PropertyName); } } ); @@ -155,17 +169,16 @@ static void WriteConstructor(CodeWriter writer, AttributeDataModelTarget target) static void WriteTargetAttributeField(CodeWriter writer, AttributeDataModelTarget target) { - var namespaceValue = target.TargetAttribute.Namespace is null - ? "null" - : $"\"{target.TargetAttribute.Namespace}\""; - writer.WriteField( - new("TargetAttribute", GeneratorTypeLibrary.TypeValueObject.AsTypeReference()) + new( + "TargetAttribute", + GeneratorTypeLibrary.TypeValueObject.AsTypeReference(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, IsReadOnly = true, - Initializer = $"new(\"{target.TargetAttribute.TypeName}\", {namespaceValue})", + Initializer = $"new(\"{target.TargetAttribute.Name}\", \"{target.TargetAttribute.Namespace}\")", } ); } @@ -183,9 +196,8 @@ static void WriteEmptyField(CodeWriter writer, AttributeDataModelTarget target) } writer.WriteField( - new FieldDeclarationOptions("Empty", TypeReference(target.StructName)) + new("Empty", TypeReference(target.StructName), TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, IsReadOnly = true, Initializer = $"new({string.Join(", ", values)})", @@ -198,9 +210,12 @@ static void WriteAllAttributeDataMethod(CodeWriter writer, AttributeDataModelTar var returnType = TypeReference( $"global::System.Collections.Generic.IEnumerable<({target.StructName} Instance, global::Microsoft.CodeAnalysis.AttributeData Attribute)>" ); - var methodOptions = new MethodDeclarationOptions("AllAttributeData", returnType) + var methodOptions = new MethodDeclarationOptions( + "AllAttributeData", + returnType, + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [ @@ -240,9 +255,8 @@ static void WriteAllAttributeDataSymbolMethod(CodeWriter writer, AttributeDataMo $"global::System.Collections.Generic.IEnumerable<({target.StructName} Instance, global::Microsoft.CodeAnalysis.AttributeData Attribute)>" ); writer.WriteMethod( - new MethodDeclarationOptions("AllAttributeData", returnType) + new MethodDeclarationOptions("AllAttributeData", returnType, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [new("symbol", TypeReference("global::Microsoft.CodeAnalysis.ISymbol"))], }, @@ -252,9 +266,12 @@ static void WriteAllAttributeDataSymbolMethod(CodeWriter writer, AttributeDataMo static void WriteFromAttributeDataArrayMethod(CodeWriter writer, AttributeDataModelTarget target) { - MethodDeclarationOptions methodOptions = new("FromAttributeData", TypeReference(target.StructName)) + MethodDeclarationOptions methodOptions = new( + "FromAttributeData", + TypeReference(target.StructName), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [ @@ -289,9 +306,8 @@ static void WriteFromAttributeDataArrayMethod(CodeWriter writer, AttributeDataMo static void WriteFromAttributeDataSymbolMethod(CodeWriter writer, AttributeDataModelTarget target) { writer.WriteMethod( - new MethodDeclarationOptions("FromAttributeData", TypeReference(target.StructName)) + new("FromAttributeData", TypeReference(target.StructName), TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [new("symbol", TypeReference("global::Microsoft.CodeAnalysis.ISymbol"))], }, @@ -299,11 +315,30 @@ static void WriteFromAttributeDataSymbolMethod(CodeWriter writer, AttributeDataM ); } + static void WriteTryFromAttributeDataSymbolMethod(CodeWriter writer, AttributeDataModelTarget target) + { + writer.WriteMethod( + new("TryFromAttributeData", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + IsStatic = true, + Parameters = + [ + new("symbol", TypeReference("global::Microsoft.CodeAnalysis.ISymbol")), + new("attributeData", TypeReference(target.StructName), ParameterModifier.Out), + ], + }, + body => body.WriteLine("return TryFromAttributeData(symbol.GetAttributes(), out attributeData, out _);") + ); + } + static void WriteFromAttributeDataArrayWithOutMethod(CodeWriter writer, AttributeDataModelTarget target) { - var methodOptions = new MethodDeclarationOptions("FromAttributeData", TypeReference(target.StructName)) + var methodOptions = new MethodDeclarationOptions( + "FromAttributeData", + TypeReference(target.StructName), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [ @@ -347,12 +382,70 @@ static void WriteFromAttributeDataArrayWithOutMethod(CodeWriter writer, Attribut ); } + static void WriteTryFromAttributeDataArrayWithOutMethod(CodeWriter writer, AttributeDataModelTarget target) + { + var methodOptions = new MethodDeclarationOptions( + "TryFromAttributeData", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + Parameters = + [ + new( + "attributes", + TypeReference( + "global::System.Collections.Immutable.ImmutableArray" + ) + ), + new("attributeData", TypeReference(target.StructName)) { Modifier = ParameterModifier.Out }, + new("attribute", TypeReference("global::Microsoft.CodeAnalysis.AttributeData?")) + { + Modifier = ParameterModifier.Out, + }, + ], + }; + + writer.WriteMethod( + methodOptions, + w => + { + w.WriteAssignment("attributeData", "Empty"); + w.WriteAssignment("attribute", "null"); + + w.WriteLine("for (var i = 0; i < attributes.Length; i++)"); + w.WriteBlock( + null, + body => + { + body.WriteLine("var result = FromAttributeData(attributes[i]);"); + body.WriteLine("if (result.Exists)"); + body.WriteBlock( + null, + inner => + { + inner.WriteLine("attribute = attributes[i];"); + inner.WriteLine("attributeData = result;"); + inner.WriteLine("return true;"); + } + ); + } + ); + w.WriteLine("return false;"); + } + ); + } + static void WriteFromAttributeDataSymbolWithOutMethod(CodeWriter writer, AttributeDataModelTarget target) { writer.WriteMethod( - new MethodDeclarationOptions("FromAttributeData", TypeReference(target.StructName)) + new MethodDeclarationOptions( + "FromAttributeData", + TypeReference(target.StructName), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [ @@ -367,11 +460,43 @@ static void WriteFromAttributeDataSymbolWithOutMethod(CodeWriter writer, Attribu ); } + static void WriteTryFromAttributeDataSymbolWithOutMethod(CodeWriter writer, AttributeDataModelTarget target) + { + writer.WriteMethod( + new MethodDeclarationOptions( + "TryFromAttributeData", + TypeReference(PurviewTypeLibrary.System.Boolean), + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + Parameters = + [ + new("symbol", TypeReference("global::Microsoft.CodeAnalysis.ISymbol")), + new("attributeData", TypeReference(target.StructName), ParameterModifier.Out), + new( + "attribute", + TypeReference("global::Microsoft.CodeAnalysis.AttributeData?"), + ParameterModifier.Out + ) + { + Modifier = ParameterModifier.Out, + }, + ], + }, + body => + body.WriteLine("return TryFromAttributeData(symbol.GetAttributes(), out attributeData, out attribute);") + ); + } + static void WriteFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarget target) { - var methodOptions = new MethodDeclarationOptions("FromAttributeData", TypeReference(target.StructName)) + var methodOptions = new MethodDeclarationOptions( + "FromAttributeData", + TypeReference(target.StructName), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, Parameters = [new("attributeData", TypeReference("global::Microsoft.CodeAnalysis.AttributeData"))], }; @@ -409,11 +534,66 @@ static void WriteFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTa ); } + static void WriteTryFromAttributeDataMethod(CodeWriter writer, AttributeDataModelTarget target) + { + var methodOptions = new MethodDeclarationOptions( + "TryFromAttributeData", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) + { + IsStatic = true, + Parameters = + [ + new("attributeData", TypeReference("global::Microsoft.CodeAnalysis.AttributeData")), + new("attribute", TypeReference(target.StructName), ParameterModifier.Out), + ], + }; + + writer.WriteMethod( + methodOptions, + w => + { + w.WriteAssignment("attribute", "Empty"); + if (target.MatchByInheritance) + { + w.WriteLine( + "if (attributeData.AttributeClass is null || (!TargetAttribute.Equals(attributeData.AttributeClass) && !global::Purview.SourceGeneratorFramework.Helpers.TypeHelpers.InheritsFrom(attributeData.AttributeClass, TargetAttribute)))" + ); + } + else + { + w.WriteLine("if (!TargetAttribute.Equals(attributeData.AttributeClass))"); + } + w.WriteBlock(null, body => body.WriteLine("return false;")); + + foreach (var property in target.Properties) + { + WritePropertyExtraction(w, property); + } + + var returnValues = new List { "true" }; + foreach (var property in target.Properties) + { + var value = ToCamelCase(property.PropertyName); + returnValues.Add(property.IsNonNullableReferenceType ? value + "!" : value); + } + + w.WriteAssignment("attribute", $"new({string.Join(", ", returnValues)})"); + + w.WriteReturn("attribute.Exists"); + } + ); + } + static void WritePropertyExtraction(CodeWriter writer, AttributeDataModelProperty property) { var variableName = ToCamelCase(property.PropertyName); var typeName = GetNonNullableTypeName(property.FullyQualifiedTypeName); - var localTypeName = property.IsNonNullableReferenceType ? typeName + "?" : property.FullyQualifiedTypeName; + var localTypeName = + property.IsNullableValueType ? typeName + : property.IsNonNullableReferenceType ? typeName + "?" + : property.FullyQualifiedTypeName; if (property.IsNestedModel) { @@ -427,7 +607,7 @@ static void WritePropertyExtraction(CodeWriter writer, AttributeDataModelPropert return; } - var sources = property.Sources; + var sources = property.Sources.AsImmutableArray(); if (sources.IsEmpty) { writer.WriteLine($"var {variableName} = default({typeName});"); @@ -536,7 +716,7 @@ void WriteFallback(int index) static void WriteEnumPropertyExtraction(CodeWriter writer, AttributeDataModelProperty property, string variableName) { - var sources = property.Sources; + var sources = property.Sources.AsImmutableArray(); var defaultValueExpression = property.HasDefaultValue ? property.DefaultValueExpression : "null"; if (sources.Length == 1) diff --git a/src/src/SourceGeneratorFramework.Generators/GlobalUsings.cs b/src/src/SourceGeneratorFramework.Generators/GlobalUsings.cs index f8c3372..3ed497c 100644 --- a/src/src/SourceGeneratorFramework.Generators/GlobalUsings.cs +++ b/src/src/SourceGeneratorFramework.Generators/GlobalUsings.cs @@ -1,3 +1,2 @@ global using Purview.SourceGeneratorFramework.Generators.Model; global using Purview.SourceGeneratorFramework.Helpers; -global using Purview.SourceGeneratorFramework.Models; diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs similarity index 79% rename from src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelLibrary.cs rename to src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs index f401443..1c3076f 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs @@ -3,15 +3,13 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Purview.SourceGeneratorFramework.Logging; -namespace Purview.SourceGeneratorFramework.Generators.Model; +namespace Purview.SourceGeneratorFramework.Generators.Helpers; static class AttributeDataModelLibrary { - public static IncrementalValuesProvider> GetTargets( - IncrementalGeneratorInitializationContext context, - ISourceGenLogger? logger + public static IncrementalValuesProvider> GetAttributeTargetPipeline( + IncrementalGeneratorInitializationContext context ) { return IncrementalPipeline @@ -21,17 +19,17 @@ public static IncrementalValuesProvider { var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.TargetNode, ct); - return symbol is not INamedTypeSymbol { TypeKind: TypeKind.Struct } structSymbol + return symbol is not INamedTypeSymbol structSymbol ? GeneratorResult.Empty - : BuildTarget(structSymbol, logger, ct); - } + : BuildTarget(structSymbol, ct); + }, + predicate: (ctx, ct) => ctx is StructDeclarationSyntax or RecordDeclarationSyntax ) .WithTrackingName("GetAttributeDataTargets"); } static GeneratorResult BuildTarget( INamedTypeSymbol structSymbol, - ISourceGenLogger? logger, CancellationToken cancellationToken ) { @@ -40,43 +38,31 @@ CancellationToken cancellationToken if (generateAttribute is null) return GeneratorResult.Empty; + ITypeSymbol? targetAttributeType = null; + TypeIdentity targetAttribute = default; if (generateAttribute.ConstructorArguments.Length == 0) { diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.TargetAttributeNotResolved, - structSymbol.Locations.FirstOrDefault(static loc => loc.IsInSource), - structSymbol.Name - ) + DiagnosticInfo.Create(DiagnosticLibrary.TargetAttributeNotResolved, structSymbol, structSymbol.Name) ); - - return GeneratorResult.Fail([.. diagnostics]); - } - - var firstArgument = generateAttribute.ConstructorArguments[0].Value; - ITypeSymbol? targetAttributeType = null; - TypeValueObject targetAttribute; - - if (firstArgument is ITypeSymbol typeSymbol) - { - targetAttributeType = typeSymbol; - targetAttribute = new TypeValueObject(typeSymbol); - } - else if (firstArgument is string targetAttributeName) - { - targetAttribute = ParseTypeValueObject(targetAttributeName); } else { - diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.TargetAttributeNotResolved, - structSymbol.Locations.FirstOrDefault(static loc => loc.IsInSource), - structSymbol.Name - ) - ); + var firstArgument = generateAttribute.ConstructorArguments[0].Value; - return GeneratorResult.Fail([.. diagnostics]); + if (firstArgument is ITypeSymbol typeSymbol) + { + targetAttributeType = typeSymbol; + targetAttribute = new(typeSymbol); + } + else if (firstArgument is string targetAttributeName) + targetAttribute = ParseTypeValueObject(targetAttributeName); + else + { + diagnostics.Add( + DiagnosticInfo.Create(DiagnosticLibrary.TargetAttributeNotResolved, structSymbol, structSymbol.Name) + ); + } } var matchByInheritance = GetNamedArgument( @@ -91,23 +77,10 @@ CancellationToken cancellationToken ); if (autoDiscover && targetAttributeType is null) - { - diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.AutoDiscoverRequiresType, - structSymbol.Locations.FirstOrDefault(static loc => loc.IsInSource) - ) - ); - } + diagnostics.Add(DiagnosticInfo.Create(DiagnosticLibrary.AutoDiscoverRequiresType, structSymbol)); var excludedNames = new HashSet(StringComparer.Ordinal); - var explicitProperties = ReadExplicitProperties( - structSymbol, - excludedNames, - diagnostics, - logger, - cancellationToken - ); + var explicitProperties = ReadExplicitProperties(structSymbol, excludedNames, diagnostics, cancellationToken); var discoveredProperties = autoDiscover && targetAttributeType is not null ? DiscoverProperties( @@ -115,7 +88,6 @@ CancellationToken cancellationToken explicitProperties, excludedNames, diagnostics, - logger, cancellationToken ) : []; @@ -143,8 +115,8 @@ CancellationToken cancellationToken ); return diagnostics.Count > 0 - ? GeneratorResult.Fail([.. diagnostics]) - : GeneratorResult.Ok(target); + ? GeneratorResult.Create([.. diagnostics]) + : GeneratorResult.Create(target); } static EquatableArray GetPrimaryConstructorArguments( @@ -176,7 +148,6 @@ static ImmutableArray ReadExplicitProperties( INamedTypeSymbol structSymbol, HashSet excludedNames, ImmutableArray.Builder diagnostics, - ISourceGenLogger? logger, CancellationToken cancellationToken ) { @@ -195,7 +166,20 @@ CancellationToken cancellationToken { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.PropertyTypeNotSupported, + DiagnosticLibrary.PropertyTypeNotSupported, + parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), + propertyName, + TypeHelpers.ToFullyQualifiedDisplayString(propertyType) + ) + ); + continue; + } + + if (IsSymbolOrSystemType(propertyType)) + { + diagnostics.Add( + DiagnosticInfo.Create( + AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), propertyName, TypeHelpers.ToFullyQualifiedDisplayString(propertyType) @@ -204,7 +188,7 @@ CancellationToken cancellationToken continue; } - var info = ReadParameterAttributes(parameter, propertyName, logger); + var info = ReadParameterAttributes(parameter, propertyName); if (info.IsExcluded) { @@ -216,18 +200,18 @@ CancellationToken cancellationToken { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.NestedModelNotGenerated, + DiagnosticLibrary.NestedModelNotGenerated, parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), TypeHelpers.ToFullyQualifiedDisplayString(propertyType) ) ); } - if (info.IsTypeArgument && !IsTypeSymbolType(propertyType)) + if (info.IsTypeArgument && !IsTypeIdentityType(propertyType)) { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.TypeArgumentPropertyTypeInvalid, + DiagnosticLibrary.TypeArgumentPropertyTypeInvalid, parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), propertyName, TypeHelpers.ToFullyQualifiedDisplayString(propertyType) @@ -239,7 +223,7 @@ CancellationToken cancellationToken { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.IsEnumRequiresStringType, + DiagnosticLibrary.IsEnumRequiresStringType, parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), propertyName, TypeHelpers.ToFullyQualifiedDisplayString(propertyType) @@ -249,10 +233,7 @@ CancellationToken cancellationToken var sources = info.Sources; if (sources.IsEmpty) - { sources = [new(AttributePropertySource.NamedArgument, propertyName, -1)]; - logger?.Debug($"Parameter '{propertyName}' has no source attribute; defaulting to named argument."); - } var (modelTypeName, isNonNullableReferenceType) = GetModelTypeInfo(propertyType, autoDiscover: false); var defaultValueExpression = GetDefaultValueExpression( @@ -263,7 +244,7 @@ CancellationToken cancellationToken ); properties.Add( - new AttributeDataModelProperty( + new( PropertyName: propertyName, FullyQualifiedTypeName: modelTypeName, Sources: sources, @@ -273,6 +254,9 @@ CancellationToken cancellationToken IsNonNullableReferenceType: isNonNullableReferenceType, IsNestedModel: info.IsNestedModel, IsEnum: info.IsEnum, + IsTypeIdentity: IsTypeIdentityType(propertyType), + IsNullableValueType: propertyType.IsValueType + && propertyType.NullableAnnotation == NullableAnnotation.Annotated, NestedModelTypeName: info.IsNestedModel ? modelTypeName : null ) ); @@ -281,11 +265,7 @@ CancellationToken cancellationToken return properties.ToImmutable(); } - static ParameterAttributeInfo ReadParameterAttributes( - IParameterSymbol parameter, - string propertyName, - ISourceGenLogger? logger - ) + static ParameterAttributeInfo ReadParameterAttributes(IParameterSymbol parameter, string propertyName) { var sources = ImmutableArray.CreateBuilder(); var isExcluded = false; @@ -295,12 +275,9 @@ static ParameterAttributeInfo ReadParameterAttributes( var excludeAttribute = GetAttribute(parameter, GeneratorTypeLibrary.Attirbutes.ExcludeAttribute); if (excludeAttribute is not null) - { isExcluded = true; - logger?.Debug($"Parameter '{propertyName}' is excluded from the attribute model."); - } - var nestedModelInfo = ReadNestedModelAttributeInfo(parameter, propertyName, isExcluded, logger); + var nestedModelInfo = ReadNestedModelAttributeInfo(parameter, isExcluded); var isNestedModel = nestedModelInfo.IsNestedModel; if (nestedModelInfo.IsNestedModel) { @@ -312,9 +289,7 @@ static ParameterAttributeInfo ReadParameterAttributes( { var (IsTypeArgument, Sources, DefaultValue, HasDefaultValue) = ReadTypeArgumentAttributeInfo( parameter, - propertyName, - isExcluded, - logger + isExcluded ); isTypeArgument = IsTypeArgument; @@ -339,15 +314,9 @@ static ParameterAttributeInfo ReadParameterAttributes( isEnum = GetNamedArgument(ctorAttribute, "IsEnum", false); if (ctorName is not null) - { sources.Add(new PropertySource(AttributePropertySource.ConstructorName, ctorName, -1)); - logger?.Debug($"Parameter '{propertyName}' maps to constructor parameter '{ctorName}'."); - } else if (ctorIndex >= 0) - { sources.Add(new PropertySource(AttributePropertySource.ConstructorIndex, null, ctorIndex)); - logger?.Debug($"Parameter '{propertyName}' maps to constructor argument index {ctorIndex}."); - } if (ctorDefaultValue is not null) { @@ -355,12 +324,6 @@ static ParameterAttributeInfo ReadParameterAttributes( hasDefaultValue = true; } } - else if (ctorAttribute is not null) - { - logger?.Debug( - $"Parameter '{propertyName}' has conflicting attributes; [{GeneratorTypeLibrary.Attirbutes.ArgumentAttribute.RenderTypeName}] is ignored." - ); - } var namedAttribute = GetAttribute(parameter, GeneratorTypeLibrary.Attirbutes.PropertyAttribute); if (namedAttribute is not null && !hasExclusive) @@ -374,7 +337,6 @@ static ParameterAttributeInfo ReadParameterAttributes( isEnum = isEnum || GetNamedArgument(namedAttribute, "IsEnum", false); sources.Add(new PropertySource(AttributePropertySource.NamedArgument, namedName ?? propertyName, -1)); - logger?.Debug($"Parameter '{propertyName}' maps to named argument '{namedName ?? propertyName}'."); if (namedDefaultValue is not null) { @@ -382,12 +344,6 @@ static ParameterAttributeInfo ReadParameterAttributes( hasDefaultValue = true; } } - else if (namedAttribute is not null) - { - logger?.Debug( - $"Parameter '{propertyName}' has conflicting attributes; [{GeneratorTypeLibrary.Attirbutes.PropertyAttribute.RenderTypeName}] is ignored." - ); - } return new ParameterAttributeInfo( isExcluded, @@ -415,29 +371,18 @@ bool IsEnum ImmutableArray Sources, object? DefaultValue, bool HasDefaultValue - ) ReadNestedModelAttributeInfo( - IParameterSymbol parameter, - string propertyName, - bool isExcluded, - ISourceGenLogger? logger - ) + ) ReadNestedModelAttributeInfo(IParameterSymbol parameter, bool isExcluded) { var nestedModelAttribute = GetAttribute(parameter, GeneratorTypeLibrary.Attirbutes.NestedModelAttribute); if (nestedModelAttribute is null) return (false, [], null, false); if (isExcluded) - { - logger?.Debug( - $"Parameter '{propertyName}' has both [{GeneratorTypeLibrary.Attirbutes.ExcludeAttribute.RenderTypeName}] and [{GeneratorTypeLibrary.Attirbutes.NestedModelAttribute.RenderTypeName}]; excluding takes precedence." - ); return (false, [], null, false); - } var defaultValue = GetNamedArgument(nestedModelAttribute, "DefaultValue", (object?)null); var sources = ImmutableArray.CreateBuilder(); sources.Add(new PropertySource(AttributePropertySource.NestedModel, null, -1)); - logger?.Debug($"Parameter '{propertyName}' is a nested model."); return (true, sources.ToImmutable(), defaultValue, defaultValue is not null); } @@ -447,12 +392,7 @@ bool HasDefaultValue ImmutableArray Sources, object? DefaultValue, bool HasDefaultValue - ) ReadTypeArgumentAttributeInfo( - IParameterSymbol parameter, - string propertyName, - bool isExcluded, - ISourceGenLogger? logger - ) + ) ReadTypeArgumentAttributeInfo(IParameterSymbol parameter, bool isExcluded) { var typeArgumentAttribute = GetAttribute( parameter, @@ -462,12 +402,7 @@ bool HasDefaultValue return (false, [], null, false); if (isExcluded) - { - logger?.Debug( - $"Parameter '{propertyName}' has both [{GeneratorTypeLibrary.Attirbutes.ExcludeAttribute.RenderTypeName}] and [{GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute.RenderTypeName}]; excluding takes precedence." - ); return (false, [], null, false); - } var defaultValue = GetNamedArgument(typeArgumentAttribute, "DefaultValue", (object?)null); var hasDefaultValue = defaultValue is not null; @@ -476,16 +411,12 @@ bool HasDefaultValue var typeArgName = GetNamedArgument(typeArgumentAttribute, "Name", (string?)null); var typeArgIndex = GetNamedArgument(typeArgumentAttribute, "Index", -1); if (typeArgName is not null) - { sources.Add(new PropertySource(AttributePropertySource.TypeArgument, typeArgName, -1)); - logger?.Debug($"Parameter '{propertyName}' maps to generic type argument '{typeArgName}'."); - } else { sources.Add( new PropertySource(AttributePropertySource.TypeArgument, null, typeArgIndex >= 0 ? typeArgIndex : 0) ); - logger?.Debug($"Parameter '{propertyName}' maps to generic type argument index {typeArgIndex}."); } return (true, sources.ToImmutable(), defaultValue, hasDefaultValue); @@ -511,7 +442,6 @@ static ImmutableArray DiscoverProperties( ImmutableArray explicitProperties, HashSet excludedNames, ImmutableArray.Builder diagnostics, - ISourceGenLogger? logger, CancellationToken cancellationToken ) { @@ -534,12 +464,7 @@ CancellationToken cancellationToken continue; if (excludedNames.Contains(propertyName)) - { - logger?.Debug( - $"Skipping discovered constructor parameter '{propertyName}' because it is explicitly excluded." - ); continue; - } if (explicitProperties.Any(p => p.PropertyName == propertyName)) continue; @@ -548,7 +473,20 @@ CancellationToken cancellationToken { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.PropertyTypeNotSupported, + DiagnosticLibrary.PropertyTypeNotSupported, + Location.None, + propertyName, + TypeHelpers.ToFullyQualifiedDisplayString(parameter.Type) + ) + ); + continue; + } + + if (IsSymbolOrSystemType(parameter.Type)) + { + diagnostics.Add( + DiagnosticInfo.Create( + AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, Location.None, propertyName, TypeHelpers.ToFullyQualifiedDisplayString(parameter.Type) @@ -566,13 +504,18 @@ CancellationToken cancellationToken new AttributeDataModelProperty( PropertyName: propertyName, FullyQualifiedTypeName: modelTypeName, - Sources: [new PropertySource(AttributePropertySource.ConstructorName, parameter.Name, i)], + Sources: EquatableArray.Create( + new PropertySource(AttributePropertySource.ConstructorName, parameter.Name, i) + ), DefaultValueExpression: defaultValueExpression, HasDefaultValue: parameter.HasExplicitDefaultValue, IsExplicit: false, IsNonNullableReferenceType: isNonNullableReferenceType, IsNestedModel: false, IsEnum: false, + IsTypeIdentity: IsTypeIdentityType(parameter.Type), + IsNullableValueType: parameter.Type.IsValueType + && parameter.Type.NullableAnnotation == NullableAnnotation.Annotated, NestedModelTypeName: null ) ); @@ -591,12 +534,7 @@ CancellationToken cancellationToken continue; if (excludedNames.Contains(propertyName)) - { - logger?.Debug( - $"Skipping discovered named property '{propertyName}' because it is explicitly excluded." - ); continue; - } if (explicitProperties.Any(p => p.PropertyName == propertyName)) continue; @@ -605,7 +543,20 @@ CancellationToken cancellationToken { diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.PropertyTypeNotSupported, + DiagnosticLibrary.PropertyTypeNotSupported, + Location.None, + propertyName, + TypeHelpers.ToFullyQualifiedDisplayString(property.Type) + ) + ); + continue; + } + + if (IsSymbolOrSystemType(property.Type)) + { + diagnostics.Add( + DiagnosticInfo.Create( + AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, Location.None, propertyName, TypeHelpers.ToFullyQualifiedDisplayString(property.Type) @@ -620,16 +571,21 @@ CancellationToken cancellationToken var defaultValueExpression = GetDefaultValueExpression(null, modelTypeName, property.Type, diagnostics); discovered.Add( - new AttributeDataModelProperty( + new( PropertyName: propertyName, FullyQualifiedTypeName: modelTypeName, - Sources: [new PropertySource(AttributePropertySource.NamedArgument, property.Name, -1)], + Sources: EquatableArray.Create( + new PropertySource(AttributePropertySource.NamedArgument, property.Name, -1) + ), DefaultValueExpression: defaultValueExpression, HasDefaultValue: false, IsExplicit: false, IsNonNullableReferenceType: isNonNullableReferenceType, IsNestedModel: false, IsEnum: false, + IsTypeIdentity: IsTypeIdentityType(property.Type), + IsNullableValueType: property.Type.IsValueType + && property.Type.NullableAnnotation == NullableAnnotation.Annotated, NestedModelTypeName: null ) ); @@ -676,7 +632,7 @@ ImmutableArray.Builder diagnostics diagnostics.Add( DiagnosticInfo.Create( - AttributeDataModelDiagnosticDescriptors.DefaultValueNotSupported, + DiagnosticLibrary.DefaultValueNotSupported, Location.None, defaultValue.ToString() ?? "null", TypeHelpers.ToFullyQualifiedDisplayString(originalType) @@ -776,9 +732,6 @@ static string EscapeString(string value) bool autoDiscover ) { - if (IsSystemType(typeSymbol)) - return ("global::Microsoft.CodeAnalysis.INamedTypeSymbol?", false); - var knownType = KnownLangTypes.Get(typeSymbol.SpecialType); var typeName = knownType.IsEmpty ? TypeHelpers.ToFullyQualifiedDisplayString(typeSymbol) : knownType.Keyword; @@ -819,6 +772,33 @@ static bool IsTypeSymbolType(ITypeSymbol typeSymbol) && namedType.Name is "ITypeSymbol" or "INamedTypeSymbol" or "ISymbol"; } + static bool IsSymbolOrSystemType(ITypeSymbol typeSymbol) => + IsTypeSymbolType(typeSymbol) || IsSystemType(typeSymbol); + + static bool IsTypeIdentityType(ITypeSymbol typeSymbol) + { + var candidate = typeSymbol; + if ( + candidate is INamedTypeSymbol nullableType + && nullableType.IsValueType + && nullableType.ContainingNamespace?.ToDisplayString() == "System" + && nullableType.Name == "Nullable" + && nullableType.TypeArguments.Length == 1 + ) + { + candidate = nullableType.TypeArguments[0]; + } + + if (candidate is not INamedTypeSymbol namedType) + return false; + + var namespaceName = namedType.ContainingNamespace.IsGlobalNamespace + ? null + : namedType.ContainingNamespace.ToDisplayString(); + + return namespaceName == "Purview.SourceGeneratorFramework" && namedType.Name == "TypeIdentity"; + } + static bool IsSupportedType(ITypeSymbol typeSymbol) { return typeSymbol.TypeKind is not TypeKind.Array and not TypeKind.Pointer and not TypeKind.FunctionPointer; @@ -831,7 +811,7 @@ static bool IsGeneratedAttributeModel(ITypeSymbol typeSymbol) : GetAttribute(namedType, GeneratorTypeLibrary.Attirbutes.GenerateAttribute) is not null; } - static AttributeData? GetAttribute(ISymbol symbol, TypeValueObject attributeType) + static AttributeData? GetAttribute(ISymbol symbol, TypeIdentity attributeType) { foreach (var attribute in symbol.GetAttributes()) { @@ -842,15 +822,15 @@ static bool IsGeneratedAttributeModel(ITypeSymbol typeSymbol) return null; } - static TypeValueObject ParseTypeValueObject(string fullyQualifiedName) + static TypeIdentity ParseTypeValueObject(string fullyQualifiedName) { var lastDot = fullyQualifiedName.LastIndexOf('.'); if (lastDot < 0) - return new TypeValueObject(fullyQualifiedName, null); + return new TypeIdentity(fullyQualifiedName, null); var typeName = fullyQualifiedName.Substring(lastDot + 1); var namespaceName = fullyQualifiedName.Substring(0, lastDot); - return new TypeValueObject(typeName, namespaceName); + return new TypeIdentity(typeName, namespaceName); } static T? GetNamedArgument(AttributeData attributeData, string name, T? defaultValue) diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelDiagnosticDescriptors.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs similarity index 90% rename from src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelDiagnosticDescriptors.cs rename to src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs index 5cdf074..acefcc0 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelDiagnosticDescriptors.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; -namespace Purview.SourceGeneratorFramework.Generators.Model; +namespace Purview.SourceGeneratorFramework.Generators.Helpers; -static class AttributeDataModelDiagnosticDescriptors +static class DiagnosticLibrary { public static readonly DiagnosticDescriptor TargetAttributeNotResolved = new( "ADM0001", @@ -69,8 +69,8 @@ static class AttributeDataModelDiagnosticDescriptors public static readonly DiagnosticDescriptor TypeArgumentPropertyTypeInvalid = new( "ADM0008", - "Type argument property type must be a symbol type", - "Type argument property '{0}' type '{1}' must be ITypeSymbol or INamedTypeSymbol", + "Type argument property type must be TypeIdentity", + "Type argument property '{0}' type '{1}' must be Purview.SourceGeneratorFramework.TypeIdentity", "TypeArgument", DiagnosticSeverity.Error, true diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/GeneratorTypeLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/GeneratorTypeLibrary.cs new file mode 100644 index 0000000..a1661d7 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/GeneratorTypeLibrary.cs @@ -0,0 +1,52 @@ +namespace Purview.SourceGeneratorFramework.Generators.Helpers; + +static class GeneratorTypeLibrary +{ + const string GeneratorsNamespace = "Purview.SourceGeneratorFramework.Generators"; + + public static readonly TypeIdentity TypeValueObject = TypeIdentity.Create(); + + public static class Attirbutes + { + public static readonly TypeIdentity GenerateAttribute = new(nameof(GenerateAttribute), GeneratorsNamespace); + + public static readonly TypeIdentity PropertyAttribute = new(nameof(PropertyAttribute), GeneratorsNamespace); + + public static readonly TypeIdentity ArgumentAttribute = new(nameof(ArgumentAttribute), GeneratorsNamespace); + + public static readonly TypeIdentity NestedModelAttribute = new( + nameof(NestedModelAttribute), + GeneratorsNamespace + ); + + public static readonly TypeIdentity ExcludeAttribute = new(nameof(ExcludeAttribute), GeneratorsNamespace); + + public static readonly TypeIdentity GenericTypeArgumentAttribute = new( + nameof(GenericTypeArgumentAttribute), + GeneratorsNamespace + ); + } + + public static class System + { + public static readonly TypeIdentity Action = TypeIdentity.Create(); + + public static readonly TypeIdentity Object = TypeIdentity.Create(); + + public static readonly TypeIdentity String = TypeIdentity.Create(); + + public static readonly TypeIdentity Int32 = TypeIdentity.Create(); + } + + public static class CodeAnalysis + { + public static readonly TypeIdentity IIncrementalGenerator = + TypeIdentity.Create(); + + public static readonly TypeIdentity ISourceGenerator = + TypeIdentity.Create(); + + public static readonly TypeIdentity EmbeddedAttribute = + TypeIdentity.Create(); + } +} diff --git a/src/src/SourceGeneratorFramework.Generators/Model/PropertyLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/PropertyLibrary.cs similarity index 68% rename from src/src/SourceGeneratorFramework.Generators/Model/PropertyLibrary.cs rename to src/src/SourceGeneratorFramework.Generators/Helpers/PropertyLibrary.cs index 5c32d2d..157997a 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/PropertyLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/PropertyLibrary.cs @@ -1,4 +1,4 @@ -namespace Purview.SourceGeneratorFramework.Generators.Model; +namespace Purview.SourceGeneratorFramework.Generators.Helpers; static class PropertyLibrary { diff --git a/src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.AttributeData.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.AttributeData.cs similarity index 58% rename from src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.AttributeData.cs rename to src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.AttributeData.cs index 1d89874..661b606 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.AttributeData.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.AttributeData.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis.Text; +using Purview.SourceGeneratorFramework.Generators.Helpers; namespace Purview.SourceGeneratorFramework.Generators.Model; @@ -6,10 +7,7 @@ partial class SourceEmitter { static SourceText GenerateAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.GenerateAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.GenerateAttribute); return writer .XmlSummary("Generates parsing members for an attribute-data model.") @@ -21,9 +19,8 @@ static SourceText GenerateAttribute() bodyWriter .XmlSummary("Initializes the attribute for the target attribute type.") .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.GenerateAttribute) + new(GeneratorTypeLibrary.Attirbutes.GenerateAttribute, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [new("targetAttribute", PurviewTypeLibrary.System.Type)], }, constructorWriter => @@ -35,9 +32,8 @@ static SourceText GenerateAttribute() bodyWriter .XmlSummary("Initializes the attribute for the target attribute name.") .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.GenerateAttribute) + new(GeneratorTypeLibrary.Attirbutes.GenerateAttribute, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [new("targetAttributeName", PurviewTypeLibrary.System.String)], }, constructorWriter => @@ -49,27 +45,32 @@ static SourceText GenerateAttribute() bodyWriter .XmlSummary("Gets the attribute type represented by the generated model.") .WriteProperty( - new("TargetAttribute", PurviewTypeLibrary.System.Type.MakeNullable()) - { - Accessibility = TypeDeclarationAccessibility.Public, - } + new( + "TargetAttribute", + PurviewTypeLibrary.System.Type.MakeNullable(), + TypeDeclarationAccessibility.Public + ) ); bodyWriter .XmlSummary("Gets the attribute type name represented by the generated model.") .WriteProperty( - new("TargetAttributeName", PurviewTypeLibrary.System.String.MakeNullable()) - { - Accessibility = TypeDeclarationAccessibility.Public, - } + new( + "TargetAttributeName", + PurviewTypeLibrary.System.String.MakeNullable(), + TypeDeclarationAccessibility.Public + ) ); bodyWriter .XmlSummary("Gets or sets whether derived attribute types are accepted.") .WriteProperty( - new("MatchByInheritance", PurviewTypeLibrary.System.Boolean) + new( + "MatchByInheritance", + PurviewTypeLibrary.System.Boolean, + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -77,9 +78,8 @@ static SourceText GenerateAttribute() bodyWriter .XmlSummary("Gets or sets whether the attribute should be automatically discovered.") .WriteProperty( - new("AutoDiscover", PurviewTypeLibrary.System.Boolean) + new("AutoDiscover", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -89,10 +89,7 @@ static SourceText GenerateAttribute() static SourceText PropertyAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.PropertyAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.PropertyAttribute); return writer .XmlSummary("Marks a record parameter as a named attribute argument.") @@ -106,9 +103,8 @@ static SourceText PropertyAttribute() $"Initializes a new instance of the class." ) .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.PropertyAttribute) + new(GeneratorTypeLibrary.Attirbutes.PropertyAttribute, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [ new("defaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) @@ -123,9 +119,12 @@ static SourceText PropertyAttribute() bodyWriter .XmlSummary("Gets or sets an optional named-property mapping.") .WriteProperty( - new("Name", PurviewTypeLibrary.System.String.MakeNullable()) + new( + "Name", + PurviewTypeLibrary.System.String.MakeNullable(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -133,9 +132,12 @@ static SourceText PropertyAttribute() bodyWriter .XmlSummary("Gets or sets the value used when the named argument is not specified.") .WriteProperty( - new("DefaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) + new( + "DefaultValue", + PurviewTypeLibrary.System.Object.MakeNullable(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -145,9 +147,8 @@ static SourceText PropertyAttribute() "Gets or sets a value indicating whether the property represents an enum whose type is not known to the generator." ) .WriteProperty( - new("IsEnum", PurviewTypeLibrary.System.Boolean) + new("IsEnum", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -157,10 +158,7 @@ static SourceText PropertyAttribute() static SourceText ArgumentAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.ArgumentAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute); return writer .XmlSummary("Marks a record parameter as a constructor argument.") @@ -173,57 +171,68 @@ static SourceText ArgumentAttribute() .XmlSummary( $"Initializes a new instance of the class." ) + .XmlParam( + "name", + "The name of the constructor parameter. If this value is not specified, the parameter name will be used." + ) + .XmlParam("defaultValue", "The default value of the constructor parameter.") .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute) + new(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, + Parameters = + [ + new("name", PurviewTypeLibrary.System.String.MakeNullable()) + { + DefaultValue = "null", + }, + new("defaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) + { + DefaultValue = "null", + }, + ], }, - bodyWriter => bodyWriter.Comment("Empty") + writerBody => + writerBody + .WriteAssignment("Name", "name") + .WriteAssignment("DefaultValue", "defaultValue") ); - bodyWriter.WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute) - { - Accessibility = TypeDeclarationAccessibility.Public, - Parameters = - [ - new("name", PurviewTypeLibrary.System.String), - new("defaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) - { - DefaultValue = "null", - }, - ], - }, - writerBody => - writer - .WriteLine( - "Name = name ?? throw new global::System.ArgumentNullException(nameof(name));" - ) - .WriteLine("DefaultValue = defaultValue;") - ); - - bodyWriter.WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute) - { - Accessibility = TypeDeclarationAccessibility.Public, - Parameters = - [ - new("index", PurviewTypeLibrary.System.Int32), - new("defaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) - { - DefaultValue = "null", - }, - ], - }, - writerBody => writer.WriteLine("Index = index;").WriteLine("DefaultValue = defaultValue;") - ); + bodyWriter + .XmlSummary( + $"Initializes a new instance of the class." + ) + .XmlParam("index", "The index of the constructor parameter.") + .XmlParam("defaultValue", "The default value of the constructor parameter.") + .WriteConstructor( + new(GeneratorTypeLibrary.Attirbutes.ArgumentAttribute, TypeDeclarationAccessibility.Public) + { + Parameters = + [ + new("index", PurviewTypeLibrary.System.Int32), + new("defaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) + { + DefaultValue = "null", + }, + ], + }, + writerBody => + writerBody.WriteLine("Index = index;").WriteLine("DefaultValue = defaultValue;") + ); bodyWriter .XmlSummary("Gets or sets the constructor parameter name.") + .XmlRemarks( + "If the property is -1, this value will be used to match the constructor parameter.", + "The property uses a camel-case comparison to match the parameter name.", + $"A property name of {CodeWriter.XmlInlineCode("MyProperty")} will match a constructor parameter named {CodeWriter.XmlInlineCode("myProperty")}." + ) .WriteProperty( - new("Name", PurviewTypeLibrary.System.String.MakeNullable()) + new( + "Name", + PurviewTypeLibrary.System.String.MakeNullable(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -231,9 +240,8 @@ static SourceText ArgumentAttribute() bodyWriter .XmlSummary("Gets or sets the constructor argument index.") .WriteProperty( - new("Index", PurviewTypeLibrary.System.Int32) + new("Index", PurviewTypeLibrary.System.Int32, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, Initializer = "-1", } @@ -242,9 +250,12 @@ static SourceText ArgumentAttribute() bodyWriter .XmlSummary("Gets or sets the value used when the constructor argument is not specified.") .WriteProperty( - new("DefaultValue", PurviewTypeLibrary.System.Object.MakeNullable()) + new( + "DefaultValue", + PurviewTypeLibrary.System.Object.MakeNullable(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -254,9 +265,8 @@ static SourceText ArgumentAttribute() "Gets or sets a value indicating whether the argument represents an enum whose type is not known to the generator." ) .WriteProperty( - new("IsEnum", PurviewTypeLibrary.System.Boolean) + new("IsEnum", PurviewTypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -266,10 +276,7 @@ static SourceText ArgumentAttribute() static SourceText NestedModelAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.NestedModelAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.NestedModelAttribute); return writer .XmlSummary("Marks a record parameter as a nested generated attribute-data model.") .WriteAttributeClass( @@ -281,10 +288,7 @@ static SourceText NestedModelAttribute() static SourceText ExcludeAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.ExcludeAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.ExcludeAttribute); return writer .XmlSummary("Excludes a record parameter from the generated attribute-data model.") .WriteAttributeClass( @@ -296,10 +300,7 @@ static SourceText ExcludeAttribute() static SourceText GenericTypeArgumentAttribute() { - var writer = CreateWriter( - nameof(AttributeDataModelGenerator), - GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute - ); + var writer = CreateWriter(GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute); return writer .XmlSummary("Marks a record parameter as a generic type argument of the attribute class.") @@ -311,19 +312,21 @@ static SourceText GenericTypeArgumentAttribute() bodyWriter .XmlSummary("Initializes a new instance marking the first type argument.") .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute) - { - Accessibility = TypeDeclarationAccessibility.Public, - }, + new( + GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute, + TypeDeclarationAccessibility.Public + ), constructorWriter => constructorWriter.Comment("Empty") ); bodyWriter .XmlSummary("Initializes a new instance marking the type argument at the specified index.") .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute) + new( + GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute, + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [new("index", PurviewTypeLibrary.System.Int32)], }, constructorWriter => constructorWriter.WriteLine("Index = index;") @@ -334,9 +337,11 @@ static SourceText GenericTypeArgumentAttribute() "Initializes a new instance marking the type argument with the specified type parameter name." ) .WriteConstructor( - new(GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute) + new( + GeneratorTypeLibrary.Attirbutes.GenericTypeArgumentAttribute, + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, Parameters = [new("name", PurviewTypeLibrary.System.String)], }, constructorWriter => @@ -348,9 +353,12 @@ static SourceText GenericTypeArgumentAttribute() bodyWriter .XmlSummary("Gets or sets the type parameter name.") .WriteProperty( - new("Name", PurviewTypeLibrary.System.String.MakeNullable()) + new( + "Name", + PurviewTypeLibrary.System.String.MakeNullable(), + TypeDeclarationAccessibility.Public + ) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, } ); @@ -358,9 +366,8 @@ static SourceText GenericTypeArgumentAttribute() bodyWriter .XmlSummary("Gets or sets the type argument index.") .WriteProperty( - new("Index", PurviewTypeLibrary.System.Int32) + new("Index", PurviewTypeLibrary.System.Int32, TypeDeclarationAccessibility.Public) { - Accessibility = TypeDeclarationAccessibility.Public, IsInitOnly = true, Initializer = "-1", } diff --git a/src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.cs similarity index 85% rename from src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.cs rename to src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.cs index be5b2d0..34979af 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/SourceEmitter.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/SourceEmitter.cs @@ -14,9 +14,9 @@ static partial class SourceEmitter yield return (GetHintName(nameof(GenericTypeArgumentAttribute)), GenericTypeArgumentAttribute()); } - static CodeWriter CreateWriter(string generatorName, TypeReferenceOptions type) + static CodeWriter CreateWriter(TypeReference type) { - CodeWriter writer = new(generatorName, AssemblyInfo.Version); + CodeWriter writer = new(GenerationSettings.Create()); return writer.WriteAutoGeneratedHeader().WriteFileScopedNamespace(type); } diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataGenerationModel.cs b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataGenerationModel.cs index d67f342..e69de29 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataGenerationModel.cs +++ b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataGenerationModel.cs @@ -1,13 +0,0 @@ -using System.Collections.Immutable; - -namespace Purview.SourceGeneratorFramework.Generators.Model; - -sealed record AttributeDataGenerationModel(bool IsDisabled, GenerationContext GenerationContext) -{ - public ImmutableArray -#pragma warning disable format - > AttributeDataTargets { get; set; } = []; -#pragma warning restore format - - public ImmutableArray Diagnostics { get; set; } = []; -} diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelProperty.cs b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelProperty.cs index 46c7ae1..bfbd02c 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelProperty.cs +++ b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelProperty.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; - namespace Purview.SourceGeneratorFramework.Generators.Model; sealed record PropertySource(AttributePropertySource Source, string? MappedName, int ConstructorIndex); @@ -7,13 +5,15 @@ sealed record PropertySource(AttributePropertySource Source, string? MappedName, sealed record AttributeDataModelProperty( string PropertyName, string FullyQualifiedTypeName, - ImmutableArray Sources, + EquatableArray Sources, string DefaultValueExpression, bool HasDefaultValue, bool IsExplicit, bool IsNonNullableReferenceType, bool IsNestedModel, bool IsEnum, + bool IsTypeIdentity, + bool IsNullableValueType, string? NestedModelTypeName = null ) { diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs index 87774d9..9c5aca4 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs +++ b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs @@ -6,7 +6,7 @@ sealed record AttributeDataModelTarget( TypeDeclarationAccessibility? Accessibility, bool IsRecord, bool IsReadOnly, - TypeValueObject TargetAttribute, + TypeIdentity TargetAttribute, bool MatchByInheritance, bool AutoDiscover, EquatableArray PrimaryConstructorArguments, diff --git a/src/src/SourceGeneratorFramework.Generators/Model/GeneratorTypeLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Model/GeneratorTypeLibrary.cs deleted file mode 100644 index 634134a..0000000 --- a/src/src/SourceGeneratorFramework.Generators/Model/GeneratorTypeLibrary.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace Purview.SourceGeneratorFramework.Generators.Model; - -static class GeneratorTypeLibrary -{ - const string GeneratorsNamespace = "Purview.SourceGeneratorFramework.Generators"; - - public static readonly TypeValueObject TypeValueObject = TypeValueObject.Create(); - - public static class Attirbutes - { - public static readonly TypeValueObject GenerateAttribute = new(nameof(GenerateAttribute), GeneratorsNamespace); - - public static readonly TypeValueObject PropertyAttribute = new(nameof(PropertyAttribute), GeneratorsNamespace); - - public static readonly TypeValueObject ArgumentAttribute = new(nameof(ArgumentAttribute), GeneratorsNamespace); - - public static readonly TypeValueObject NestedModelAttribute = new( - nameof(NestedModelAttribute), - GeneratorsNamespace - ); - - public static readonly TypeValueObject ExcludeAttribute = new(nameof(ExcludeAttribute), GeneratorsNamespace); - - public static readonly TypeValueObject GenericTypeArgumentAttribute = new( - nameof(GenericTypeArgumentAttribute), - GeneratorsNamespace - ); - } - - public static class System - { - public static readonly TypeValueObject Action = TypeValueObject.Create(); - - public static readonly TypeValueObject Object = TypeValueObject.Create(); - - public static readonly TypeValueObject String = TypeValueObject.Create(); - - public static readonly TypeValueObject Int32 = TypeValueObject.Create(); - } - - public static class CodeAnalysis - { - public static readonly TypeValueObject IIncrementalGenerator = - TypeValueObject.Create(); - - public static readonly TypeValueObject ISourceGenerator = - TypeValueObject.Create(); - - public static readonly TypeValueObject EmbeddedAttribute = - TypeValueObject.Create(); - } -} diff --git a/src/src/SourceGeneratorFramework.Generators/Model/SourceGenDiagnosticDescriptors.cs b/src/src/SourceGeneratorFramework.Generators/Model/SourceGenDiagnosticDescriptors.cs deleted file mode 100644 index 58e4974..0000000 --- a/src/src/SourceGeneratorFramework.Generators/Model/SourceGenDiagnosticDescriptors.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework.Generators.Model; - -static class SourceGenDiagnosticDescriptors -{ - public static readonly DiagnosticDescriptor SourceGeneratorMustBePartial = new( - "PSG0001", - "Source generator must be partial", - "Class '{0}' implements a source generator interface but is not marked partial. Logging support cannot be generated.", - "LoggingSupport", - DiagnosticSeverity.Warning, - true - ); -} diff --git a/src/src/SourceGeneratorFramework.Generators/README.md b/src/src/SourceGeneratorFramework.Generators/README.md index 2c872fd..a964b57 100644 --- a/src/src/SourceGeneratorFramework.Generators/README.md +++ b/src/src/SourceGeneratorFramework.Generators/README.md @@ -14,17 +14,22 @@ The generator emits marker attributes into your compilation: - `[GenerateAttributeDataModel(Type targetAttribute)]` — placed on a `readonly partial record struct` to opt into generation. - `[GenerateAttributeDataModel(string targetAttribute)]` — same as above, but resolves the attribute by fully-qualified name. Use this when the attribute type is not available in the generator's compilation (e.g., `LengthAttribute` in .NET 8+ or a self-generated attribute). -- `[AttributeNamedProperty]` — a record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). -- `[AttributeNamedProperty(string name)]` — explicit named property source. -- `[AttributeCtorProperty]` — a record parameter is populated from a constructor argument (the parameter name is inferred from the parameter name unless overridden). -- `[AttributeCtorProperty(string name)]` — constructor argument by parameter name. -- `[AttributeCtorProperty(int index)]` — constructor argument by parameter index. -- `[AttributeNestedModelProperty]` — a record parameter is populated by recursively calling `FromAttributeData` on a nested generated model. -- `[AttributeExcludeProperty]` — skips auto-discovery for this parameter. +- `[Property]` — a record parameter is populated from a named attribute property (the property name is inferred from the parameter name unless overridden). +- `[Property(string name)]` — explicit named property source. +- `[Property(..., DefaultValue = ...)]` — provides a fallback value when the named property is not present. +- `[Argument]` — a record parameter is populated from a constructor argument by parameter name. +- `[Argument(int index)]` — constructor argument by parameter index. +- `[Argument(string name)]` — constructor argument by parameter name. +- `[Argument(..., DefaultValue = ...)]` — provides a fallback value when the constructor argument is not present. +- `[NestedModel]` — a record parameter is populated by recursively calling `FromAttributeData` on a nested generated model. +- `[Exclude]` — skips auto-discovery for this parameter. +- `[GenericTypeArgument]` — a record parameter is populated from a generic type argument of the attribute class. +- `[GenericTypeArgument(int index)]` — generic type argument by position. +- `[GenericTypeArgument(string name)]` — generic type argument by type parameter name. ### Default values -`DefaultValue` is a runtime fallback. If the requested property is not found on the attribute, the generated `FromAttributeData` method uses the supplied `DefaultValue` instead of `default(T)`. +`DefaultValue` is a runtime fallback. If the requested property or argument is not found on the attribute, the generated `FromAttributeData` method uses the supplied `DefaultValue` instead of `default(T)`. The `Empty` sentinel always uses `default(T)` for every property, including an `Exists` field set to `false`. @@ -84,8 +89,8 @@ A plain type name (`"RequiredAttribute"`) can also be used, which matches an att ```csharp [GenerateAttributeDataModel(typeof(LengthAttribute))] public readonly partial record struct LengthAttributeData( - [AttributeCtorProperty(0)] int MinimumLength, - [AttributeCtorProperty(1)] int MaximumLength + [Argument(0)] int MinimumLength, + [Argument(1)] int MaximumLength ); ``` @@ -94,7 +99,7 @@ Or by constructor parameter name: ```csharp [GenerateAttributeDataModel(typeof(StringLengthAttribute))] public readonly partial record struct StringLengthAttributeData( - [AttributeCtorProperty("maximumLength", DefaultValue = 2147483647)] int MaximumLength, + [Argument("maximumLength", DefaultValue = 2147483647)] int MaximumLength, int MinimumLength ); ``` @@ -106,20 +111,33 @@ Any property whose type is itself annotated with `[GenerateAttributeDataModel]` ```csharp [GenerateAttributeDataModel(typeof(ValidationAttribute), MatchByInheritance = true)] public readonly partial record struct ValidationAttributeData( - string? ErrorMessage, - string? ErrorMessageResourceName, - ITypeSymbol? ErrorMessageResourceType + [Property] string? ErrorMessage, + [Property] string? ErrorMessageResourceName, + [Property] ITypeSymbol? ErrorMessageResourceType ); [GenerateAttributeDataModel(typeof(RequiredAttribute))] public readonly partial record struct RequiredAttributeData( bool AllowEmptyStrings, - [AttributeNestedModelProperty] ValidationAttributeData ValidationAttribute + [NestedModel] ValidationAttributeData ValidationAttribute ); ``` Because `ValidationAttributeData` uses `MatchByInheritance = true`, it matches any attribute that derives from `ValidationAttribute`, including `RequiredAttribute`. +### Generic type arguments + +If the attribute class is generic, a record parameter can be populated from the attribute's type argument: + +```csharp +[GenerateAttributeDataModel(typeof(MyGenericAttribute<>))] +public readonly partial record struct MyGenericAttributeData( + [GenericTypeArgument] T Value +); +``` + +Use `[GenericTypeArgument(0)]` or `[GenericTypeArgument("TValue")]` to disambiguate when the attribute has multiple type parameters. + ### Auto-discovery For simple attributes you can let the generator discover all constructor parameters and public named properties automatically. @@ -133,13 +151,13 @@ This generates the same `RequiredAttributeData` as the manual example above. Nes ### Default values -Use `DefaultValue` to provide a runtime fallback when the attribute does not contain the requested property. The `Empty` sentinel still uses `default(T)`. +Use `DefaultValue` to provide a runtime fallback when the attribute does not contain the requested property or argument. The `Empty` sentinel still uses `default(T)`. ```csharp [GenerateAttributeDataModel(typeof(HostKitAttribute))] public readonly partial record struct HostKitAttributeData( - [AttributeCtorProperty("name", DefaultValue = "MyApp")] string Name, - [AttributeCtorProperty("generateOptions", DefaultValue = true)] bool GenerateOptions + [Argument("name", DefaultValue = "MyApp")] string Name, + [Argument("generateOptions", DefaultValue = true)] bool GenerateOptions ); ``` diff --git a/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj b/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj index 9834834..01905a5 100644 --- a/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj +++ b/src/src/SourceGeneratorFramework.Generators/SourceGeneratorFramework.Generators.csproj @@ -5,123 +5,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + %(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension) + + + + + + $(MSBuildThisFileDirectory)..\SourceGeneratorShared\bin\$(Configuration)\netstandard2.0\Purview.SourceGeneratorFramework.Shared.dll + + + + + diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs index 130b583..d2ca40b 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs @@ -5,9 +5,31 @@ namespace Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; -[EditorBrowsable(EditorBrowsableState.Never)] +/// +/// Contains assertion methods for and . +/// public static partial class DiagnosticAssertions { + /// + /// Asserts that the driver run contains the expected total number of generator and analyzer diagnostics. + /// + /// The result of the driver run to check. + /// The expected total number of diagnostics. + /// The diagnostics when the assertion passes. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics(this DriverRunResult diagnostic, int count) + { + if (diagnostic is null) + return AssertionResult.Failed($"expected {nameof(DriverRunResult)} is null"); + + // Not null... process + return HasDiagnostics( + diagnostic.DriverResult.Diagnostics.Concat(diagnostic.AnalyzerResult?.Diagnostics ?? []), + count + ); + } + /// /// Asserts that the contains a diagnostic with the same Id as the . /// @@ -29,7 +51,9 @@ DiagnosticDescriptor expected if (expected is null) return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null"); - var matchingDiagnostic = diagnostic.DriverResult.Diagnostics.FirstOrDefault(d => d.Id == expected.Id); + var matchingDiagnostic = + diagnostic.DriverResult.Diagnostics.FirstOrDefault(d => d.Id == expected.Id) + ?? diagnostic.AnalyzerResult?.Diagnostics.FirstOrDefault(d => d.Id == expected.Id); return matchingDiagnostic is null ? (AssertionResult) AssertionResult.Failed($"expected to contain diagnostic with Id {expected.Id}") @@ -61,7 +85,9 @@ int count if (expected is null) return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null"); - var matchingDiagnostic = diagnostic.DriverResult.Diagnostics.Where(d => d.Id == expected.Id); + var matchingDiagnostic = diagnostic + .DriverResult.Diagnostics.Where(d => d.Id == expected.Id) + .Concat(diagnostic.AnalyzerResult?.Diagnostics.Where(d => d.Id == expected.Id) ?? []); return matchingDiagnostic is null ? (AssertionResult>) AssertionResult.Failed($"expected to contain {count} diagnostic(s) with Id {expected.Id}") @@ -85,7 +111,10 @@ public static AssertionResult HasDiagnostic(this DriverRunResult dia if (string.IsNullOrWhiteSpace(expected)) return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor.Id)} is null/ empty/ whitespace"); - var matchedDiagnotics = diagnostic.DriverResult.Diagnostics.FirstOrDefault(d => d.Id == expected); + var matchedDiagnotics = + diagnostic.DriverResult.Diagnostics.FirstOrDefault(d => d.Id == expected) + ?? diagnostic.AnalyzerResult?.Diagnostics.FirstOrDefault(d => d.Id == expected); + ; return matchedDiagnotics is null ? (AssertionResult)AssertionResult.Failed($"expected to contain diagnostic with Id {expected}") : AssertionResult.Passed(matchedDiagnotics); @@ -115,7 +144,9 @@ int count if (string.IsNullOrWhiteSpace(expected)) return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor.Id)} is null/ empty/ whitespace"); - var matchedDiagnotics = diagnostic.DriverResult.Diagnostics.Where(d => d.Id == expected); + var matchedDiagnotics = diagnostic + .DriverResult.Diagnostics.Where(d => d.Id == expected) + .Concat(diagnostic.AnalyzerResult?.Diagnostics.Where(d => d.Id == expected) ?? []); return matchedDiagnotics is null ? (AssertionResult>) AssertionResult.Failed($"expected to contain {count} diagnostic(s) with Id {expected}") @@ -139,7 +170,8 @@ public static AssertionResult DoesNotHaveDiagnostic(this DriverRunResult result, return expected is null ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null") : AssertionResult.FailIf( - result.DriverResult.Diagnostics.Any(d => d.Id == expected.Id), + result.DriverResult.Diagnostics.Any(d => d.Id == expected.Id) + || result.AnalyzerResult?.Diagnostics.Any(d => d.Id == expected.Id) == true, $"expected not to contain diagnostic with Id {expected.Id}" ); } @@ -161,7 +193,8 @@ public static AssertionResult DoesNotHaveDiagnostic(this DriverRunResult result, return expected is null ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null") : AssertionResult.FailIf( - result.DriverResult.Diagnostics.Any(d => d.Id == expected), + result.DriverResult.Diagnostics.Any(d => d.Id == expected) + || result.AnalyzerResult?.Diagnostics.Any(d => d.Id == expected) == true, $"expected not to contain diagnostic with Id {expected}" ); } @@ -186,7 +219,10 @@ string startsWithValue return startsWithValue is null ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null") : AssertionResult.FailIf( - result.DriverResult.Diagnostics.Any(d => d.Id.StartsWith(startsWithValue, StringComparison.Ordinal)), + result.DriverResult.Diagnostics.Any(d => d.Id.StartsWith(startsWithValue, StringComparison.Ordinal)) + || result.AnalyzerResult?.Diagnostics.Any(d => + d.Id.StartsWith(startsWithValue, StringComparison.Ordinal) + ) == true, $"expected not to contain Diagnostic with Id starting with {startsWithValue}" ); } @@ -205,8 +241,304 @@ public static AssertionResult HasNoErrorDiagnostics(this DriverRunResult result) // Not null... process return AssertionResult.FailIf( - result.DriverResult.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error), + result.DriverResult.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error) + || result.AnalyzerResult?.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error) == true, "expected no error diagnostics to be reported by the generator" ); } + + /// + /// Asserts that the does not contain any diagnostics. + /// + /// The result of the driver run to check for diagnostics. + /// An indicating whether the assertion passed or failed. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNoDiagnostics(this DriverRunResult result) + { + if (result == null) + return AssertionResult.Failed($"expected {nameof(DriverRunResult)} is null"); + + // Not null... process + return AssertionResult.FailIf( + result.DriverResult.Diagnostics.Any() || result.AnalyzerResult?.Diagnostics.Any() == true, + "expected no diagnostics to be reported by the generator" + ); + } + + /// Asserts that an analyzer result contains the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasDiagnostic( + this AnalyzerTestResult diagnostic, + DiagnosticDescriptor expected + ) => HasDiagnostic(diagnostic?.Diagnostics, expected); + + /// Asserts that an analyzer result contains the expected total number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this AnalyzerTestResult diagnostic, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, count); + + /// Asserts that an analyzer result contains the expected number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this AnalyzerTestResult diagnostic, + DiagnosticDescriptor expected, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); + + /// Asserts that an analyzer result contains the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasDiagnostic(this AnalyzerTestResult diagnostic, string expected) => + HasDiagnostic(diagnostic?.Diagnostics, expected); + + /// Asserts that an analyzer result contains the expected number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this AnalyzerTestResult diagnostic, + string expected, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); + + /// Asserts that an analyzer result does not contain the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnostic( + this AnalyzerTestResult result, + DiagnosticDescriptor expected + ) => DoesNotHaveDiagnostic(result?.Diagnostics, expected); + + /// Asserts that an analyzer result does not contain the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnostic(this AnalyzerTestResult result, string expected) => + DoesNotHaveDiagnostic(result?.Diagnostics, expected); + + /// Asserts that an analyzer result does not contain a diagnostic with the specified prefix. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( + this AnalyzerTestResult result, + string startsWithValue + ) => DoesNotHaveDiagnosticThatStartsWith(result?.Diagnostics, startsWithValue); + + /// Asserts that an analyzer result does not contain error diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNoErrorDiagnostics(this AnalyzerTestResult result) => + HasNoErrorDiagnostics(result?.Diagnostics, "analyzer"); + + /// Asserts that an analyzer result does not contain diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNoDiagnostics(this AnalyzerTestResult result) => + HasNoDiagnostics(result?.Diagnostics, "analyzer"); + + /// Asserts that a code-fix result contains the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasDiagnostic( + this CodeFixTestResult diagnostic, + DiagnosticDescriptor expected + ) => HasDiagnostic(diagnostic?.Diagnostics, expected); + + /// Asserts that a code-fix result contains the expected total number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this CodeFixTestResult diagnostic, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, count); + + /// Asserts that a code-fix result contains the expected number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this CodeFixTestResult diagnostic, + DiagnosticDescriptor expected, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); + + /// Asserts that a code-fix result contains the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasDiagnostic(this CodeFixTestResult diagnostic, string expected) => + HasDiagnostic(diagnostic?.Diagnostics, expected); + + /// Asserts that a code-fix result contains the expected number of diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult> HasDiagnostics( + this CodeFixTestResult diagnostic, + string expected, + int count + ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); + + /// Asserts that a code-fix result does not contain the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnostic(this CodeFixTestResult result, DiagnosticDescriptor expected) => + DoesNotHaveDiagnostic(result?.Diagnostics, expected); + + /// Asserts that a code-fix result does not contain the expected diagnostic. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnostic(this CodeFixTestResult result, string expected) => + DoesNotHaveDiagnostic(result?.Diagnostics, expected); + + /// Asserts that a code-fix result does not contain a diagnostic with the specified prefix. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( + this CodeFixTestResult result, + string startsWithValue + ) => DoesNotHaveDiagnosticThatStartsWith(result?.Diagnostics, startsWithValue); + + /// Asserts that a code-fix result does not contain error diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNoErrorDiagnostics(this CodeFixTestResult result) => + HasNoErrorDiagnostics(result?.Diagnostics, "code fix"); + + /// Asserts that a code-fix result does not contain diagnostics. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasNoDiagnostics(this CodeFixTestResult result) => + HasNoDiagnostics(result?.Diagnostics, "code fix"); + + static AssertionResult HasDiagnostic( + IEnumerable? diagnostics, + DiagnosticDescriptor expected + ) + { + if (diagnostics is null) + return AssertionResult.Failed("expected analyzer result is null"); + if (expected is null) + return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null"); + + var matchingDiagnostic = diagnostics.FirstOrDefault(diagnostic => diagnostic.Id == expected.Id); + return matchingDiagnostic is null + ? (AssertionResult) + AssertionResult.Failed($"expected to contain diagnostic with Id {expected.Id}") + : AssertionResult.Passed(matchingDiagnostic); + } + + static AssertionResult HasDiagnostic(IEnumerable? diagnostics, string expected) + { + if (diagnostics is null) + return AssertionResult.Failed("expected analyzer result is null"); + if (string.IsNullOrWhiteSpace(expected)) + return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor.Id)} is null/ empty/ whitespace"); + + var matchingDiagnostic = diagnostics.FirstOrDefault(diagnostic => diagnostic.Id == expected); + return matchingDiagnostic is null + ? (AssertionResult)AssertionResult.Failed($"expected to contain diagnostic with Id {expected}") + : AssertionResult.Passed(matchingDiagnostic); + } + + static AssertionResult> HasDiagnostics( + IEnumerable? diagnostics, + DiagnosticDescriptor expected, + int count + ) => + expected is null + ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null") + : HasDiagnostics(diagnostics, expected.Id, count); + + static AssertionResult> HasDiagnostics(IEnumerable? diagnostics, int count) + { + if (diagnostics is null) + return AssertionResult.Failed("expected diagnostic result is null"); + if (count < 0) + return AssertionResult.Failed($"expected {nameof(count)} is less than 0"); + + var materializedDiagnostics = diagnostics.ToList(); + return materializedDiagnostics.Count != count + ? (AssertionResult>) + AssertionResult.Failed( + $"expected to contain {count} diagnostic(s), but found {materializedDiagnostics.Count}" + ) + : AssertionResult>.Passed(materializedDiagnostics); + } + + static AssertionResult> HasDiagnostics( + IEnumerable? diagnostics, + string expected, + int count + ) + { + if (diagnostics is null) + return AssertionResult.Failed("expected analyzer result is null"); + if (count < 1) + return AssertionResult.Failed($"expected {nameof(count)} is less than 1"); + if (string.IsNullOrWhiteSpace(expected)) + return AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor.Id)} is null/ empty/ whitespace"); + + var matchingDiagnostics = diagnostics.Where(diagnostic => diagnostic.Id == expected).ToList(); + return matchingDiagnostics.Count != count + ? (AssertionResult>) + AssertionResult.Failed( + $"expected to contain {count} diagnostic(s) with Id {expected}, but found {matchingDiagnostics.Count}" + ) + : AssertionResult>.Passed(matchingDiagnostics); + } + + static AssertionResult DoesNotHaveDiagnostic(IEnumerable? diagnostics, DiagnosticDescriptor expected) => + expected is null + ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor)} is null") + : DoesNotHaveDiagnostic(diagnostics, expected.Id); + + static AssertionResult DoesNotHaveDiagnostic(IEnumerable? diagnostics, string expected) + { + if (diagnostics is null) + return AssertionResult.Failed("expected analyzer result is null"); + + // Not null... process + return string.IsNullOrWhiteSpace(expected) + ? AssertionResult.Failed($"expected {nameof(DiagnosticDescriptor.Id)} is null/ empty/ whitespace") + : AssertionResult.FailIf( + diagnostics.Any(diagnostic => diagnostic.Id == expected), + $"expected not to contain diagnostic with Id {expected}" + ); + } + + static AssertionResult DoesNotHaveDiagnosticThatStartsWith( + IEnumerable? diagnostics, + string startsWithValue + ) + { + if (diagnostics is null) + return AssertionResult.Failed("expected analyzer result is null"); + + // Not null... process + return string.IsNullOrWhiteSpace(startsWithValue) + ? AssertionResult.Failed("expected diagnostic Id prefix is null/ empty/ whitespace") + : AssertionResult.FailIf( + diagnostics.Any(diagnostic => diagnostic.Id.StartsWith(startsWithValue, StringComparison.Ordinal)), + $"expected not to contain Diagnostic with Id starting with {startsWithValue}" + ); + } + + static AssertionResult HasNoErrorDiagnostics(IEnumerable? diagnostics, string source) + { + if (diagnostics is null) + return AssertionResult.Failed($"expected {source} result is null"); + + // Not null... process + return AssertionResult.FailIf( + diagnostics.Any(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + $"expected no error diagnostics to be reported by the {source}" + ); + } + + static AssertionResult HasNoDiagnostics(IEnumerable? diagnostics, string source) => + diagnostics is null + ? AssertionResult.Failed($"expected {source} result is null") + : AssertionResult.FailIf(diagnostics.Any(), $"expected no diagnostics to be reported by the {source}"); } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/TypeIdentityAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/TypeIdentityAssertions.cs new file mode 100644 index 0000000..e05fbef --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/TypeIdentityAssertions.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using TUnit.Assertions.Attributes; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static partial class TypeIdentityAssertions +{ + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion(ExpectationMessage = "compilation result should contain symbol for {identity}")] + public static bool HasSymbol(this DriverRunResult result, TypeIdentity identity) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var symbol = result.CompilationResult.Compilation.GetTypeByMetadataName(identity.MetadataFullName); + + return symbol is not null; + } + + [EditorBrowsable(EditorBrowsableState.Never)] + [GenerateAssertion( + ExpectationMessage = "compilation result should contain symbol for {fullyQualifiedMetadataName}" + )] + public static bool HasSymbol(this DriverRunResult result, string fullyQualifiedMetadataName) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + if (string.IsNullOrWhiteSpace(fullyQualifiedMetadataName)) + throw new ArgumentException( + $"'{nameof(fullyQualifiedMetadataName)}' cannot be null or whitespace.", + nameof(fullyQualifiedMetadataName) + ); + + var symbol = result.CompilationResult.Compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); + + return symbol is not null; + } +} diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj b/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj index da1dcbd..bc1e7da 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj @@ -1,5 +1,6 @@  + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared $(TestingTargetFrameworks) true $(RootNamespace) @@ -7,11 +8,38 @@ + - + + + + + + + + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly)" /> + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly->'%(RootDir)%(Directory)%(Filename).pdb')" /> + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly->'%(RootDir)%(Directory)%(Filename).xml')" /> + + + lib/$(TargetFramework)/ + + + diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs new file mode 100644 index 0000000..36f951b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit; + +/// TUnit-specific base class for analyzer and code fix tests. +public abstract class TUnitCodeFixTestBase + : TUnitCodeFixTestBase + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new(); + +/// TUnit-specific base class for analyzer and code fix tests. +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1005:Avoid excessive parameters on generic types", + Justification = "The analyzer, code fix, and options types independently define the test fixture." +)] +public abstract class TUnitCodeFixTestBase + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + where TOptions : CodeFixTestOptions, new() +{ + readonly CodeFixTestRunner _runner = new(); + + /// Runs the analyzer and applies the selected code fix. + protected Task ApplyCodeFixAsync(string source, CancellationToken cancellationToken = default) => + ApplyCodeFixAsync(source, null!, cancellationToken); + + /// Runs the analyzer and applies the selected code fix using the supplied options. + protected Task ApplyCodeFixAsync( + string source, + TOptions options, + CancellationToken cancellationToken = default + ) => _runner.RunAsync(source, options ?? new(), cancellationToken); +} diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs new file mode 100644 index 0000000..46f4393 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs @@ -0,0 +1,13 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit; + +/// TUnit-specific base class for diagnostic analyzer tests. +public abstract class TUnitDiagnosticAnalyzerTestBase + : TUnitDiagnosticAnalyzerTestBase + where TAnalyzer : DiagnosticAnalyzer, new(); + +/// TUnit-specific base class for diagnostic analyzer tests. +public abstract class TUnitDiagnosticAnalyzerTestBase : AnalyzerTestBase + where TAnalyzer : DiagnosticAnalyzer, new() + where TOptions : AnalyzerTestOptions, new(); diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs new file mode 100644 index 0000000..142405b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Framework-agnostic base class for diagnostic analyzer tests. +/// The type of diagnostic analyzer. +public abstract class AnalyzerTestBase : AnalyzerTestBase + where TAnalyzer : DiagnosticAnalyzer, new(); + +/// Framework-agnostic base class for diagnostic analyzer tests. +/// The type of diagnostic analyzer. +/// The type of test options. +public abstract class AnalyzerTestBase + where TAnalyzer : DiagnosticAnalyzer, new() + where TOptions : AnalyzerTestOptions, new() +{ + readonly DiagnosticAnalyzerTestRunner _runner = new(); + + /// Runs the analyzer against the supplied source. + protected Task AnalyzeAsync(string source, CancellationToken cancellationToken = default) => + AnalyzeAsync(source, null!, cancellationToken); + + /// Runs the analyzer against the supplied sources. + protected Task AnalyzeAsync( + IEnumerable sources, + CancellationToken cancellationToken = default + ) => AnalyzeAsync(sources, null!, cancellationToken); + + /// Runs the analyzer against the supplied source and options. + protected Task AnalyzeAsync( + string source, + TOptions options, + CancellationToken cancellationToken = default + ) => AnalyzeAsync([source], options, cancellationToken); + + /// Runs the analyzer against the supplied sources and options. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1849:Call async methods when in an async method" + )] + protected async Task AnalyzeAsync( + IEnumerable sources, + TOptions options, + CancellationToken cancellationToken = default + ) + { + if (sources is null) + throw new ArgumentNullException(nameof(sources)); + + options ??= new(); + options = OnBeforeRun(sources, options, cancellationToken); + options = await OnBeforeRunAsync(sources, options, cancellationToken); + + var result = await _runner.RunAsync(sources, options, cancellationToken); + + OnAfterRun(result, sources, options, cancellationToken); + await OnAfterRunAsync(result, sources, options, cancellationToken); + + return result; + } + + /// Called before the analyzer is run. + protected virtual TOptions OnBeforeRun( + IEnumerable sources, + TOptions options, + CancellationToken cancellationToken + ) => options; + + /// Called asynchronously before the analyzer is run. + protected virtual Task OnBeforeRunAsync( + IEnumerable sources, + TOptions options, + CancellationToken cancellationToken + ) => Task.FromResult(options); + + /// Called after the analyzer is run. + protected virtual void OnAfterRun( + AnalyzerTestResult result, + IEnumerable sources, + TOptions options, + CancellationToken cancellationToken + ) + { + // No-op by default. + } + + /// Called asynchronously after the analyzer is run. + protected virtual Task OnAfterRunAsync( + AnalyzerTestResult result, + IEnumerable sources, + TOptions options, + CancellationToken cancellationToken + ) => Task.CompletedTask; +} diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs new file mode 100644 index 0000000..fdfa9f8 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs @@ -0,0 +1,15 @@ +namespace Purview.SourceGeneratorFramework.Testing; + +/// Options that configure a diagnostic analyzer test run. +public record AnalyzerTestOptions : SourceGeneratorTestOptions; + +/// Options that configure a code fix test run. +public record CodeFixTestOptions : AnalyzerTestOptions +{ + /// Gets the index of the registered code action to apply. + public int CodeActionIndex { get; init; } + + /// Gets the equivalence key used to select a registered code action. + /// When specified, this takes precedence over . + public string? EquivalenceKey { get; init; } +} diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs new file mode 100644 index 0000000..2b861aa --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs @@ -0,0 +1,16 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// The result of a diagnostic analyzer test run. +public sealed record AnalyzerTestResult(ImmutableArray Diagnostics, Compilation Compilation); + +/// The result of a code fix test run. +public sealed record CodeFixTestResult( + ImmutableArray Diagnostics, + ImmutableArray CodeActions, + string FixedSource, + Compilation Compilation +); diff --git a/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs new file mode 100644 index 0000000..8ff8b09 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Executes an analyzer and applies one code fix to a test document. +public sealed class CodeFixTestRunner : RoslynTestRunner + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + /// Runs the analyzer and applies a registered code action. + public async Task RunAsync( + string source, + CodeFixTestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new(); + using var testProject = CreateProject([source], options, typeof(TAnalyzer).Assembly); + var compilation = + await testProject.Project.GetCompilationAsync(cancellationToken) + ?? throw new InvalidOperationException("Unable to create the test compilation."); + var diagnostics = await WithAnalyzers(compilation, [new TAnalyzer()], options) + .GetAnalyzerDiagnosticsAsync(cancellationToken); + var diagnostic = + diagnostics.FirstOrDefault(diagnostic => diagnostic.Location.IsInSource) + ?? throw new InvalidOperationException("The analyzer did not report a source diagnostic."); + var document = testProject.Project.Solution.GetDocument(testProject.DocumentIds[0])!; + List actions = []; + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + await new TCodeFix().RegisterCodeFixesAsync(context); + + var action = options.EquivalenceKey is null + ? actions.ElementAtOrDefault(options.CodeActionIndex) + : actions.FirstOrDefault(action => action.EquivalenceKey == options.EquivalenceKey); + action = action ?? throw new InvalidOperationException("The requested code action was not registered."); + + var operations = await action.GetOperationsAsync(cancellationToken); + var changedSolution = + operations.OfType().SingleOrDefault()?.ChangedSolution + ?? throw new InvalidOperationException("The code action did not produce an ApplyChangesOperation."); + var changedDocument = + changedSolution.GetDocument(document.Id) + ?? throw new InvalidOperationException("The code action removed the source document."); + var fixedSource = (await changedDocument.GetTextAsync(cancellationToken)).ToString(); + + return new(diagnostics, [.. actions], fixedSource, compilation); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeWriterFactory.cs b/src/src/SourceGeneratorFramework.Testing/CodeWriterFactory.cs index b75dab7..96f427d 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeWriterFactory.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeWriterFactory.cs @@ -15,12 +15,10 @@ public static class CodeWriterFactory public static CodeWriter ForTests( int initialCapacity = 4096, bool throwOnUnclosedScopes = true, - string generatorName = DefaultGeneratorName, - string generatorVersion = DefaultGeneratorVersion + GenerationSettings? settings = null ) => new( - generatorName: generatorName, - generatorVersion: generatorVersion, + settings ?? new(DefaultGeneratorName, DefaultGeneratorVersion), initialCapacity: initialCapacity, throwOnUnclosedScopes: throwOnUnclosedScopes ); diff --git a/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs new file mode 100644 index 0000000..80f075d --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Executes a diagnostic analyzer against a test compilation. +public sealed class DiagnosticAnalyzerTestRunner : RoslynTestRunner + where TAnalyzer : DiagnosticAnalyzer, new() +{ + /// Runs the analyzer against one source file. + public Task RunAsync( + string source, + AnalyzerTestOptions? options = null, + CancellationToken cancellationToken = default + ) => RunAsync([source], options, cancellationToken); + + /// Runs the analyzer against the supplied source files. + public async Task RunAsync( + IEnumerable sources, + AnalyzerTestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new(); + using var testProject = CreateProject(sources, options, typeof(TAnalyzer).Assembly); + var compilation = + await testProject.Project.GetCompilationAsync(cancellationToken) + ?? throw new InvalidOperationException("Unable to create the test compilation."); + var diagnostics = await WithAnalyzers(compilation, [new TAnalyzer()], options) + .GetAnalyzerDiagnosticsAsync(cancellationToken); + + return new(diagnostics, compilation); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs index 1b04e3b..82915f2 100644 --- a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; using Purview.SourceGeneratorFramework.Logging; using Purview.SourceGeneratorFramework.Testing.Models; @@ -10,9 +11,21 @@ namespace Purview.SourceGeneratorFramework.Testing; /// /// The result of a source generator test run. /// +/// All syntax trees generated during the run. +/// +/// The primary syntax trees generated during the run, excluding anything detailed by . +/// +/// This is useful for excluding generated source such as attribute markers. +/// +/// +/// The log entries generated during the run. +/// The result of the analyzer compilation run, if any. +/// The result of the compilation run. +/// The result of the generator driver run. public sealed record class DriverRunResult( GeneratorDriverRunResult DriverResult, CompilationRunResult CompilationResult, + AnalyzerCompilationRunResult? AnalyzerResult, ImmutableArray AllSyntaxTrees, ImmutableArray PrimarySyntaxTrees, ImmutableArray LogEntries @@ -124,6 +137,22 @@ public string GetSource() /// The matching syntax tree, or if none is found. public SyntaxTree? GetGeneratedTree(string filePathSuffix) => AllSyntaxTrees.FirstOrDefault(tree => tree.FilePath.EndsWith(filePathSuffix, StringComparison.Ordinal)); + + /// + /// Gets the symbol for a type by its metadata name. + /// + /// The identity of the type. + /// The symbol for the type, or if the type is not found. + public ISymbol? GetTypeByMetadataName(TypeIdentity identity) => + CompilationResult.Compilation.GetTypeByMetadataName(identity.MetadataFullName); + + /// + /// Gets the symbol for a type by its metadata name. + /// + /// The fully qualified metadata name of the type. + /// The symbol for the type, or if the type is not found. + public ISymbol? GetTypeByMetadataName(string fullyQualifiedMetadataName) => + CompilationResult.Compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); } /// @@ -138,6 +167,16 @@ public sealed record class CompilationRunResult( ImmutableArray Diagnostics ); +/// +/// The result of a compilation run with analyzers applied, including the compilation and any diagnostics produced during compilation. +/// +/// The compilation that was run, with analyzers applied. +/// The diagnostics produced just by the analyzers defined by . +public sealed record class AnalyzerCompilationRunResult( + CompilationWithAnalyzers Compilation, + ImmutableArray Diagnostics +); + /// /// Specifies how to match hint names when retrieving generated source code from a . /// diff --git a/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs index 436a815..e0d78f8 100644 --- a/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs +++ b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/CodeWriterExtensions.cs @@ -8,12 +8,11 @@ public static class CodeWriterExtensions extension(CodeWriter) { public static CodeWriter CreateTestWriter( - string? generatorName = null, - string? version = null, + GenerationSettings? settings = null, bool includeGeneratedAttributes = false ) { - return new CodeWriter(generatorName ?? "TestGenerator", version ?? "1") + return new(settings ?? new("TestGenerator", "1")) { DefaultIncludeGeneratedAttributes = includeGeneratedAttributes, }; diff --git a/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs new file mode 100644 index 0000000..2de32fc --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs @@ -0,0 +1,305 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Purview.SourceGeneratorFramework; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class SourceGeneratorTestOptionsExtensions +{ + extension(TOptions options) + where TOptions : SourceGeneratorTestOptions + { + /// + /// Creates a new options snapshot with the specified analyzer-config options added to the existing set. + /// + /// The analyzer-config options to add. + /// A new instance with the specified options added. + public TOptions WithAnalyzerConfigOptions(params (string, string)[] configOptions) + { + if (configOptions is null || configOptions.Length == 0) + return options; + + var analyzerConfigOptions = options.AnalyzerConfigOptions.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + foreach (var (key, value) in configOptions) + { + analyzerConfigOptions[key] = value; + } + + return options with + { + AnalyzerConfigOptions = analyzerConfigOptions.ToImmutableDictionary(), + }; + } + + /// + /// Creates a new options snapshot with the specified analyzer-config options added to the existing set. + /// + /// The analyzer-config options to add. + /// A new instance with the specified options added. + public TOptions WithAnalyzerConfigOptions(IEnumerable<(string, string)> configOptions) + { + if (configOptions is null) + return options; + + var analyzerConfigOptions = options.AnalyzerConfigOptions.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + foreach (var (key, value) in configOptions) + { + analyzerConfigOptions[key] = value; + } + + return options with + { + AnalyzerConfigOptions = analyzerConfigOptions.ToImmutableDictionary(), + }; + } + + /// + /// Creates a new options snapshot with the specified additional assembly types added to the existing set. + /// + /// The additional assembly types to add. + /// A new instance with the specified assembly types added. + public TOptions WithAdditionalAssemblyTypes(params Type[] additionalAssemblyTypes) + { + if (additionalAssemblyTypes is null || additionalAssemblyTypes.Length == 0) + return options; + + var assemblyTypes = options.AdditionalAssemblyTypes.ToList(); + assemblyTypes.AddRange(additionalAssemblyTypes); + + return options with + { + AdditionalAssemblyTypes = [.. assemblyTypes], + }; + } + + /// + /// Creates a new options snapshot with the specified additional assembly types added to the existing set. + /// + /// The additional assembly types to add. + /// A new instance with the specified assembly types added. + public TOptions WithAdditionalAssemblyTypes(IEnumerable additionalAssemblyTypes) + { + if (additionalAssemblyTypes is null) + return options; + + var assemblyTypes = options.AdditionalAssemblyTypes.ToList(); + assemblyTypes.AddRange(additionalAssemblyTypes); + + return options with + { + AdditionalAssemblyTypes = [.. assemblyTypes], + }; + } + + public TOptions WithExcludeGeneratedSourceHintNames(params string[] sourceHintNames) => + sourceHintNames is null || sourceHintNames.Length == 0 + ? options + : ( + options with + { + ExcludeGeneratedSourceHintNames = options.ExcludeGeneratedSourceHintNames.AddRange( + sourceHintNames + ), + } + ); + + /// Creates a new options snapshot with additional namespaces appended. + public TOptions WithExcludeGeneratedSourceHintNames(IEnumerable sourceHintNames) => + sourceHintNames is null + ? options + : ( + options with + { + ExcludeGeneratedSourceHintNames = options.ExcludeGeneratedSourceHintNames.AddRange( + sourceHintNames + ), + } + ); + + /// Creates a new options snapshot with additional namespaces appended. + public TOptions WithAdditionalNamespaces(params string[] additionalNamespaces) => + additionalNamespaces is null || additionalNamespaces.Length == 0 + ? options + : (options with { AdditionalNamespaces = options.AdditionalNamespaces.AddRange(additionalNamespaces) }); + + /// Creates a new options snapshot with additional namespaces appended. + public TOptions WithAdditionalNamespaces(IEnumerable additionalNamespaces) => + additionalNamespaces is null + ? options + : (options with { AdditionalNamespaces = options.AdditionalNamespaces.AddRange(additionalNamespaces) }); + + /// Creates a new options snapshot with additional namespaces appended. + public TOptions WithAdditionalNamespaces(params TypeIdentity[] identities) => + identities is null || identities.Length == 0 + ? options + : ( + options with + { + AdditionalNamespaces = options.AdditionalNamespaces.AddRange( + identities.Where(m => !m.IsGlobalNamespace).Select(x => x.Namespace!) + ), + } + ); + + /// Creates a new options snapshot with additional namespaces appended. + public TOptions WithAdditionalNamespaces(IEnumerable identities) => + identities is null + ? options + : ( + options with + { + AdditionalNamespaces = options.AdditionalNamespaces.AddRange( + identities.Where(m => !m.IsGlobalNamespace).Select(x => x.Namespace!) + ), + } + ); + + /// Creates a new options snapshot with additional metadata references appended. + public TOptions WithAdditionalReferences(params MetadataReference[] additionalReferences) => + additionalReferences is null || additionalReferences.Length == 0 + ? options + : (options with { AdditionalReferences = options.AdditionalReferences.AddRange(additionalReferences) }); + + /// Creates a new options snapshot with additional metadata references appended. + public TOptions WithAdditionalReferences(IEnumerable additionalReferences) => + additionalReferences is null || !additionalReferences.Any() + ? options + : (options with { AdditionalReferences = options.AdditionalReferences.AddRange(additionalReferences) }); + + /// Creates a new options snapshot with additional source files appended. + public TOptions WithAdditionalSources(params string[] additionalSources) => + additionalSources is null || additionalSources.Length == 0 + ? options + : (options with { AdditionalSources = options.AdditionalSources.AddRange(additionalSources) }); + + /// Creates a new options snapshot with additional source files appended. + public TOptions WithAdditionalSources(IEnumerable additionalSources) => + additionalSources is null || !additionalSources.Any() + ? options + : (options with { AdditionalSources = options.AdditionalSources.AddRange(additionalSources) }); + + /// Creates a new options snapshot with additional source files appended. + public TOptions WithAdditionalSources(params SourceText[] additionalSources) => + additionalSources is null || additionalSources.Length == 0 + ? options + : ( + options with + { + AdditionalSources = options.AdditionalSources.AddRange( + additionalSources.Select(x => x.ToString()) + ), + } + ); + + /// Creates a new options snapshot with additional source files appended. + public TOptions WithAdditionalSources(IEnumerable additionalSources) => + additionalSources is null + ? options + : ( + options with + { + AdditionalSources = options.AdditionalSources.AddRange( + additionalSources.Select(x => x.ToString()) + ), + } + ); + + /// Creates a new options snapshot with additional text files appended. + public TOptions WithAdditionalText(params AdditionalText[] additionalText) => + additionalText is null || additionalText.Length == 0 + ? options + : (options with { AdditionalText = options.AdditionalText.AddRange(additionalText) }); + + /// Creates a new options snapshot with additional text files appended. + public TOptions WithAdditionalText(IEnumerable additionalText) => + additionalText is null + ? options + : (options with { AdditionalText = options.AdditionalText.AddRange(additionalText) }); + + /// Creates a new options snapshot with analyzer types appended. + public TOptions WithAnalyzers(params Type[] analyzerTypes) + { + if (analyzerTypes is null || analyzerTypes.Length == 0) + return options; + + foreach (var analyzerType in analyzerTypes) + { + if (analyzerType is null || !typeof(DiagnosticAnalyzer).IsAssignableFrom(analyzerType)) + { + throw new ArgumentException( + $"All analyzer types must derive from {nameof(DiagnosticAnalyzer)}.", + nameof(analyzerTypes) + ); + } + } + + return options with + { + AnalyzerTypes = options.AnalyzerTypes.AddRange(analyzerTypes), + }; + } + + /// Creates a new options snapshot with analyzer types appended. + public TOptions WithAnalyzers(IEnumerable analyzerTypes) + { + if (analyzerTypes is null) + return options; + + foreach (var analyzerType in analyzerTypes) + { + if (analyzerType is null || !typeof(DiagnosticAnalyzer).IsAssignableFrom(analyzerType)) + { + throw new ArgumentException( + $"All analyzer types must derive from {nameof(DiagnosticAnalyzer)}.", + nameof(analyzerTypes) + ); + } + } + + return options with + { + AnalyzerTypes = options.AnalyzerTypes.AddRange(analyzerTypes), + }; + } + + /// Creates a new options snapshot using the specified analyzer options. + /// This clears . + public TOptions WithAnalyzerOptions(AnalyzerOptions? analyzerOptions) => + options with + { + AnalyzerOptions = analyzerOptions, + CompilationWithAnalyzersOptions = null, + }; + + /// Creates a new options snapshot using the specified compilation-with-analyzers options. + /// This clears . + public TOptions WithCompilationWithAnalyzersOptions( + CompilationWithAnalyzersOptions? compilationWithAnalyzersOptions + ) => options with { AnalyzerOptions = null, CompilationWithAnalyzersOptions = compilationWithAnalyzersOptions }; + } + + extension(TOptions options) + where TOptions : CodeFixTestOptions + { + /// Creates a new code-fix options snapshot selecting a code action by index. + /// This clears . + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA1512:Use ArgumentOutOfRangeException throw helper", + Justification = "The testing package supports targets where ThrowIfNegative is unavailable." + )] + public TOptions WithCodeActionIndex(int codeActionIndex) => + codeActionIndex < 0 + ? throw new ArgumentOutOfRangeException(nameof(codeActionIndex)) + : (options with { CodeActionIndex = codeActionIndex, EquivalenceKey = null }); + + /// Creates a new code-fix options snapshot selecting a code action by equivalence key. + public TOptions WithCodeActionEquivalenceKey(string equivalenceKey) => + string.IsNullOrWhiteSpace(equivalenceKey) + ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(equivalenceKey)) + : (options with { EquivalenceKey = equivalenceKey }); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/Extensions/SourceGeneratorTestOptionsExtensions.cs b/src/src/SourceGeneratorFramework.Testing/Extensions/SourceGeneratorTestOptionsExtensions.cs new file mode 100644 index 0000000..c69af23 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/Extensions/SourceGeneratorTestOptionsExtensions.cs @@ -0,0 +1,22 @@ +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// Fluent extension methods that preserve the concrete options type for downstream derived records. +/// +public static class SourceGeneratorTestOptionsExtensions +{ + extension(TOptions options) + where TOptions : SourceGeneratorTestOptions + { + /// + /// Creates a copy of these options with + /// set to . + /// + /// + /// The concrete options type is preserved, so derived records such as + /// and can opt into compiling + /// the output assembly without losing their derived properties. + /// + public TOptions Compile() => options with { CompileToAssembly = true }; + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs new file mode 100644 index 0000000..2082d5e --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Provides shared Roslyn project creation for analyzer and code fix test runners. +public abstract class RoslynTestRunner +{ + /// Creates a compilation-with-analyzers using the configured analyzer options. + protected static CompilationWithAnalyzers WithAnalyzers( + Compilation compilation, + ImmutableArray analyzers, + SourceGeneratorTestOptions options + ) + { + if (compilation is null) + throw new ArgumentNullException(nameof(compilation)); + if (options is null) + throw new ArgumentNullException(nameof(options)); + + if (options.AnalyzerOptions is not null && options.CompilationWithAnalyzersOptions is not null) + { + throw new ArgumentException( + $"{nameof(options.AnalyzerOptions)} and {nameof(options.CompilationWithAnalyzersOptions)} cannot be provided at the same time.", + nameof(options) + ); + } + + // If the caller provided CompilationWithAnalyzersOptions, use that; otherwise, use AnalyzerOptions. + return options.CompilationWithAnalyzersOptions is not null + ? compilation.WithAnalyzers(analyzers, options.CompilationWithAnalyzersOptions) + : compilation.WithAnalyzers(analyzers, options.AnalyzerOptions); + } + + /// Creates a project containing the supplied sources. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "Constructing a Roslyn workspace necessarily coordinates Roslyn project model types." + )] + protected static TestProject CreateProject( + IEnumerable sources, + SourceGeneratorTestOptions options, + Assembly componentAssembly + ) + { + if (sources is null) + throw new ArgumentNullException(nameof(sources)); + if (options is null) + throw new ArgumentNullException(nameof(options)); + if (componentAssembly is null) + throw new ArgumentNullException(nameof(componentAssembly)); + + if (!options.AdditionalSources.IsDefaultOrEmpty) + sources = sources.Concat(options.AdditionalSources); + + var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var solution = workspace + .CurrentSolution.AddProject( + projectId, + options.CompilationAssemblyName, + options.CompilationAssemblyName, + LanguageNames.CSharp + ) + .WithProjectParseOptions(projectId, new CSharpParseOptions(options.LanguageVersion)) + .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(options.OutputKind)) + .AddMetadataReferences(projectId, SourceGeneratorHelpers.ResolveReferences(options, componentAssembly)); + + var documentIds = ImmutableArray.CreateBuilder(); + var index = 0; + foreach (var source in sources) + { + var documentId = DocumentId.CreateNewId(projectId); + documentIds.Add(documentId); + solution = solution.AddDocument( + documentId, + $"Test{++index}.cs", + SourceText.From(PrepareSource(source, options), System.Text.Encoding.UTF8) + ); + } + + if (documentIds.Count == 0) + throw new ArgumentException("At least one source is required.", nameof(sources)); + + // Return a disposable wrapper that owns the workspace and project. + return new(workspace, solution.GetProject(projectId)!, documentIds.ToImmutable()); + } + + static string PrepareSource(string source, SourceGeneratorTestOptions options) => + SourceGeneratorHelpers.PrepareSource(source, options); + + /// Owns the workspace and project created for a test run. + protected sealed class TestProject( + AdhocWorkspace workspace, + Project project, + ImmutableArray documentIds + ) : IDisposable + { + /// Gets the test project. + public Project Project { get; } = project; + + /// Gets the source document identifiers in input order. + public ImmutableArray DocumentIds { get; } = documentIds; + + /// + public void Dispose() => workspace.Dispose(); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md index bad035e..9b1eeb5 100644 --- a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md @@ -104,12 +104,12 @@ var options = new SourceGeneratorTestOptions IncludeDefaultNamespaces = true, AdditionalNamespaces = ["MyNamespace"], AdditionalAssemblyTypes = [typeof(SomeExternalType)], - CompileToAssembly = true, EnableLogging = true, AnalyzerConfigOptions = { ["MyGenerator_Disable"] = "true" } }; -var result = await runner.RunAsync(source, options); +// Emitting the output to an assembly is opt-in because it is expensive. +var result = await runner.RunAsync(source, options.Compile()); ``` Analyzer options are preserved under their supplied keys. Keys without the Roslyn diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/build/Purview.SourceGeneratorFramework.Testing.props b/src/src/SourceGeneratorFramework.Testing/Sdk/build/Purview.SourceGeneratorFramework.Testing.props index 25a2533..98252dd 100644 --- a/src/src/SourceGeneratorFramework.Testing/Sdk/build/Purview.SourceGeneratorFramework.Testing.props +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/build/Purview.SourceGeneratorFramework.Testing.props @@ -1,5 +1,6 @@ + diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj index 0d61349..1519603 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj @@ -1,14 +1,44 @@  + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared true $(RootNamespace) + + + + + + + + + + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly)" /> + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly->'%(RootDir)%(Directory)%(Filename).pdb')" /> + <_SourceGeneratorSharedPackageFile Include="@(_SourceGeneratorSharedAssembly->'%(RootDir)%(Directory)%(Filename).xml')" /> + + + lib/$(TargetFramework)/ + + + diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs index e0518cb..01d924b 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Reflection; +using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -11,6 +12,15 @@ static class SourceGeneratorHelpers (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? "" ).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + public static ImmutableArray ResolveTrustedReferences { get; } = + CreateMetadataReferences(TrustedAssemblies); + + static readonly string? GeneratorAssemblyPath = typeof(TypeIdentity).Assembly.Location; + static readonly ImmutableArray CachedFilteredReferences = FilterGeneratorAssembly( + ResolveTrustedReferences, + GeneratorAssemblyPath + ); + public static CSharpCompilation CreateCompilation( IEnumerable syntaxTrees, ImmutableArray references, @@ -33,29 +43,73 @@ Assembly generatorAssembly if (generatorAssembly is null) throw new ArgumentNullException(nameof(generatorAssembly)); - var generatorAssemblyPath = generatorAssembly.GetType( - "Purview.SourceGeneratorFramework.Models.TypeValueObject", - throwOnError: false - ) - is null - ? null - : generatorAssembly.Location; - var builder = ImmutableArray.CreateBuilder(); - builder.AddRange( - TrustedAssemblies - .Where(path => - generatorAssemblyPath is null - || !string.Equals(path, generatorAssemblyPath, StringComparison.OrdinalIgnoreCase) - ) - .Select(static path => MetadataReference.CreateFromFile(path)) - ); - builder.AddRange( - options.AdditionalAssemblyTypes.Select(static a => MetadataReference.CreateFromFile(a.Assembly.Location)) + if (options.AdditionalAssemblyTypes.IsDefaultOrEmpty && options.AdditionalReferences.IsDefaultOrEmpty) + return CachedFilteredReferences; + + var builder = ImmutableArray.CreateBuilder( + CachedFilteredReferences.Length + + options.AdditionalAssemblyTypes.Length + + options.AdditionalReferences.Length ); + + builder.AddRange(CachedFilteredReferences); + + foreach (var type in options.AdditionalAssemblyTypes) + builder.Add(MetadataReference.CreateFromFile(type.Assembly.Location)); + builder.AddRange(options.AdditionalReferences); var references = builder.ToImmutable(); options.PreprocessReferences?.Invoke(references); return references; } + + public static string PrepareSource(string source, SourceGeneratorTestOptions options) + { + if (!options.IncludeDefaultNamespaces) + return source; + + var namespaces = options.DefaultNamespaces.AddRange(options.AdditionalNamespaces); + if (namespaces.IsDefaultOrEmpty) + return source; + + var builder = new StringBuilder(source.Length + (namespaces.Length * 20)); + foreach (var ns in namespaces) + { + builder.Append("using ").Append(ns).AppendLine(";"); + } + builder.AppendLine(); + + return builder.Append(source).ToString(); + } + + static ImmutableArray CreateMetadataReferences(string[] paths) + { + if (paths.Length == 0) + return []; + + var builder = ImmutableArray.CreateBuilder(paths.Length); + foreach (var path in paths) + builder.Add(MetadataReference.CreateFromFile(path)); + + return builder.ToImmutable(); + } + + static ImmutableArray FilterGeneratorAssembly( + ImmutableArray references, + string? generatorAssemblyPath + ) + { + if (generatorAssemblyPath is null) + return references; + + var builder = ImmutableArray.CreateBuilder(references.Length); + foreach (var reference in references) + { + if (!string.Equals(reference.Display, generatorAssemblyPath, StringComparison.OrdinalIgnoreCase)) + builder.Add(reference); + } + + return builder.ToImmutable(); + } } diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs index 4728442..d5b6857 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs @@ -83,8 +83,8 @@ protected async Task GenerateAsync( options ??= new(); options = options with { TestOutput = testOutput }; - OnBeforeRun(sources, options, cancellationToken); - await OnBeforeRunAsync(sources, options, cancellationToken); + options = OnBeforeRun(sources, options, cancellationToken); + options = await OnBeforeRunAsync(sources, options, cancellationToken); var result = await _runner.RunAsync(sources, options, cancellationToken); if (options.ThrowOnGenerationException) @@ -133,14 +133,11 @@ CancellationToken cancellationToken /// The source code files to generate. /// The options to use for the generation. /// A token to monitor for cancellation requests. - protected virtual void OnBeforeRun( + protected virtual TOptions OnBeforeRun( IEnumerable sources, TOptions options, CancellationToken cancellationToken - ) - { - // - } + ) => options; /// /// Called before the generator is run, allowing for customization of the sources and options. @@ -148,9 +145,9 @@ CancellationToken cancellationToken /// The source code files to generate. /// The options to use for the generation. /// A token to monitor for cancellation requests. - protected virtual Task OnBeforeRunAsync( + protected virtual Task OnBeforeRunAsync( IEnumerable sources, TOptions options, CancellationToken cancellationToken - ) => Task.CompletedTask; + ) => Task.FromResult(options); } diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs index 9b1b0d8..7df59c4 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs @@ -1,9 +1,17 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; namespace Purview.SourceGeneratorFramework.Testing; +/* + * IMPORTANT! + * + * If you add a new property to this record, please also update the copy constructor! + */ + /// /// Options that configure a source generator test run. /// @@ -33,40 +41,44 @@ public SourceGeneratorTestOptions() // Bootstraps Default using the property initializers without recursively reading Default. SourceGeneratorTestOptions(bool _) { } - /// Initializes an options snapshot by copying another instance. - /// The options to copy. - /// - /// This is also the record copy constructor. Mutable collections are copied so options snapshots - /// can be customized independently. - /// - protected SourceGeneratorTestOptions(SourceGeneratorTestOptions source) - { - if (source is null) - throw new ArgumentNullException(nameof(source)); - - IncludeDefaultNamespaces = source.IncludeDefaultNamespaces; - DefaultNamespaces = source.DefaultNamespaces; - AdditionalNamespaces = source.AdditionalNamespaces; - AdditionalAssemblyTypes = source.AdditionalAssemblyTypes; - AdditionalReferences = source.AdditionalReferences; - PreprocessReferences = source.PreprocessReferences; - ThrowOnGenerationException = source.ThrowOnGenerationException; - CompileToAssembly = source.CompileToAssembly; - ValidateCodeWriterScopes = source.ValidateCodeWriterScopes; - EnableLogging = source.EnableLogging; - ThrowOnLogError = source.ThrowOnLogError; - DisableSourceGeneratorPropertyName = source.DisableSourceGeneratorPropertyName; - DisableSourceGeneratorValue = source.DisableSourceGeneratorValue; - - AnalyzerConfigOptions = [with(source.AnalyzerConfigOptions)]; - - TestOutput = source.TestOutput; - CompilationAssemblyName = source.CompilationAssemblyName; - OutputKind = source.OutputKind; - LanguageVersion = source.LanguageVersion; - ExcludeGeneratedSourceHintNames = source.ExcludeGeneratedSourceHintNames; - AdditionalText = source.AdditionalText; - } + ///// Initializes an options snapshot by copying another instance. + ///// The options to copy. + ///// + ///// This is also the record copy constructor. Mutable collections are copied so options snapshots + ///// can be customized independently. + ///// + //protected SourceGeneratorTestOptions(SourceGeneratorTestOptions source) + //{ + // if (source is null) + // throw new ArgumentNullException(nameof(source)); + + // IncludeDefaultNamespaces = source.IncludeDefaultNamespaces; + // DefaultNamespaces = source.DefaultNamespaces; + // AdditionalNamespaces = source.AdditionalNamespaces; + // AdditionalAssemblyTypes = source.AdditionalAssemblyTypes; + // AdditionalReferences = source.AdditionalReferences; + // PreprocessReferences = source.PreprocessReferences; + // ThrowOnGenerationException = source.ThrowOnGenerationException; + // CompileToAssembly = source.CompileToAssembly; + // ValidateCodeWriterScopes = source.ValidateCodeWriterScopes; + // EnableLogging = source.EnableLogging; + // ThrowOnLogError = source.ThrowOnLogError; + // DisableSourceGeneratorPropertyName = source.DisableSourceGeneratorPropertyName; + // DisableSourceGeneratorValue = source.DisableSourceGeneratorValue; + + // AnalyzerConfigOptions = [with(source.AnalyzerConfigOptions)]; + + // TestOutput = source.TestOutput; + // CompilationAssemblyName = source.CompilationAssemblyName; + // OutputKind = source.OutputKind; + // LanguageVersion = source.LanguageVersion; + // ExcludeGeneratedSourceHintNames = source.ExcludeGeneratedSourceHintNames; + // AdditionalText = source.AdditionalText; + // AdditionalSources = source.AdditionalSources; + // AnalyzerTypes = source.AnalyzerTypes; + // AnalyzerOptions = source.AnalyzerOptions; + // CompilationWithAnalyzersOptions = source.CompilationWithAnalyzersOptions; + //} /// /// Gets a value indicating whether the default namespaces should be prepended to the source. @@ -107,7 +119,12 @@ protected SourceGeneratorTestOptions(SourceGeneratorTestOptions source) /// /// Gets a value indicating whether the output compilation should be emitted to an assembly. /// - public bool CompileToAssembly { get; init; } = true; + /// + /// The default is because emitting an assembly is expensive and most + /// generator tests only need to inspect generated source. Use the Compile extension method + /// to opt-in when the test needs to load or reflect over the compiled output. + /// + public bool CompileToAssembly { get; init; } /// /// Gets whether code writers created by the generation context should throw when generated @@ -139,9 +156,11 @@ protected SourceGeneratorTestOptions(SourceGeneratorTestOptions source) public bool? DisableSourceGeneratorValue { get; init; } /// - /// Gets additional analyzer-config options to pass to the generator driver. + /// Gets additional analyzer-config options to pass to the generator driver, such as build_property.* or build_metadata.* values. /// - public Dictionary AnalyzerConfigOptions { get; init; } = []; + [SuppressMessage("Style", "IDE0301:Use collection expression for empty")] + public ImmutableDictionary AnalyzerConfigOptions { get; init; } = + ImmutableDictionary.Empty; /// /// Gets the test output receiver used for generator logging. @@ -171,7 +190,33 @@ protected SourceGeneratorTestOptions(SourceGeneratorTestOptions source) public ImmutableArray ExcludeGeneratedSourceHintNames { get; init; } = []; /// - /// Gets additional text files to include in the test compilation. + /// Gets additional files to include in the test compilation, such as configuration files or other content that can be read by the generator. /// public ImmutableArray AdditionalText { get; init; } = []; + + /// + /// Gets additional source text to include in the test compilation, such as generated code or other content that can be read by the generator. + /// + /// This will be added to the compilation as additional source files, along with the source provided. + public ImmutableArray AdditionalSources { get; init; } = []; + + /// + /// Gets additional analyzer types to include in the test compilation, such as diagnostic + /// analyzers or other Roslyn analyzers that can be run alongside the generator. + /// + /// When this is populated, the compilation will also include the specified analyzers + /// and populate . + public ImmutableArray AnalyzerTypes { get; init; } = []; + + /// + /// Gets the options to use when running the compilation with analyzers. + /// + /// This is mutually exclusive with . + public AnalyzerOptions? AnalyzerOptions { get; init; } + + /// + /// Gets the options to use when running the compilation with analyzers. + /// + /// This is mutually exclusive with . + public CompilationWithAnalyzersOptions? CompilationWithAnalyzersOptions { get; init; } } diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs index 993462a..6c579ca 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs @@ -3,9 +3,8 @@ using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Helpers; +using Microsoft.CodeAnalysis.Diagnostics; using Purview.SourceGeneratorFramework.Logging; -using Purview.SourceGeneratorFramework.Models; using Purview.SourceGeneratorFramework.Testing.Models; namespace Purview.SourceGeneratorFramework.Testing; @@ -34,10 +33,20 @@ public async Task RunAsync( CancellationToken cancellationToken = default ) { - options ??= new SourceGeneratorTestOptions(); + options ??= new(); + if (options.AnalyzerOptions is not null && options.CompilationWithAnalyzersOptions is not null) + { + throw new ArgumentException( + $"{nameof(options.AnalyzerOptions)} and {nameof(options.CompilationWithAnalyzersOptions)} cannot be provided at the same time.", + nameof(options) + ); + } ConcurrentBag logEntries = []; + if (!options.AdditionalSources.IsDefaultOrEmpty) + sources = sources.Concat(options.AdditionalSources); + var syntaxTrees = sources .Select(source => CSharpSyntaxTree.ParseText( @@ -61,6 +70,8 @@ public async Task RunAsync( out _, cancellationToken ); + + var analyzerCompilationRun = await GetAnalyzerResultsAsync(options, outputCompilation, cancellationToken); var result = driver.GetRunResult(); Assembly? assembly = null; @@ -75,23 +86,58 @@ public async Task RunAsync( return new( result, new(outputCompilation, assembly, compilationDiagnostics), + analyzerCompilationRun, result.GeneratedTrees, excludedGeneratedSource, [.. logEntries] ); } - static string PrepareSource(string source, SourceGeneratorTestOptions options) + static async Task GetAnalyzerResultsAsync( + SourceGeneratorTestOptions options, + Compilation outputCompilation, + CancellationToken cancellationToken + ) { - if (!options.IncludeDefaultNamespaces) - return source; + if (options.AnalyzerTypes.IsDefaultOrEmpty) + return null; - var namespaces = options.DefaultNamespaces.AddRange(options.AdditionalNamespaces); - var usings = string.Join(Environment.NewLine, namespaces.Select(n => $"using {n};")); + var analyzers = options + .AnalyzerTypes.Select(static type => + { +#pragma warning disable CA2208 // Instantiate argument exceptions correctly + if (type.GetConstructors().All(c => c.GetParameters().Length > 0)) + { + throw new ArgumentException( + $"Analyzer type {type.FullName} must have a parameterless constructor.", + nameof(options.AnalyzerTypes) + ); + } - return usings + Environment.NewLine + Environment.NewLine + source; + if (!typeof(DiagnosticAnalyzer).IsAssignableFrom(type)) + { + throw new ArgumentException( + $"Analyzer type {type.FullName} must be a DiagnosticAnalyzer.", + nameof(options.AnalyzerTypes) + ); + } +#pragma warning restore CA2208 // Instantiate argument exceptions correctly + + return (Activator.CreateInstance(type) as DiagnosticAnalyzer)!; + }) + .ToImmutableArray(); + + var compilationWithAnalyzers = options.CompilationWithAnalyzersOptions is not null + ? outputCompilation.WithAnalyzers(analyzers, options.CompilationWithAnalyzersOptions) + : outputCompilation.WithAnalyzers(analyzers, options.AnalyzerOptions); + + var analyzerDiagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(cancellationToken); + return new(compilationWithAnalyzers, analyzerDiagnostics); } + static string PrepareSource(string source, SourceGeneratorTestOptions options) => + SourceGeneratorHelpers.PrepareSource(source, options); + static GeneratorDriver CreateDriver( TGenerator generator, SourceGeneratorTestOptions options, @@ -104,25 +150,31 @@ static GeneratorDriver CreateDriver( parseOptions: new(options.LanguageVersion) ); - var analyzerOptions = new Dictionary(options.AnalyzerConfigOptions) + Dictionary analyzerOptions = new(options.AnalyzerConfigOptions) { - [IncrementalPipeline.BuildProperty + GenerationContext.ValidateCodeWriterScopesBuildProperty] = - options.ValidateCodeWriterScopes ? "true" : "false", - [IncrementalPipeline.BuildProperty + GenerationContext.EnableLoggingBuildProperty] = options.EnableLogging - ? "true" - : "false", + [SourceGeneratorBuildProperties.ValidateCodeWriterScopes] = options.ValidateCodeWriterScopes.ToString(), + [SourceGeneratorBuildProperties.EnableLogging] = options.EnableLogging.ToString(), }; + foreach (var (key, value) in options.AnalyzerConfigOptions) { - if (!key.StartsWith(IncrementalPipeline.BuildProperty, StringComparison.Ordinal)) - analyzerOptions.TryAdd(IncrementalPipeline.BuildProperty + key, value); + if (!key.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + analyzerOptions.TryAdd(SourceGeneratorBuildProperties.BuildProperty + key, value); } + if (loggingSessionId is not null) - analyzerOptions[IncrementalPipeline.BuildProperty + GenerationContext.LoggingSessionIdBuildProperty] = - loggingSessionId; + { + analyzerOptions[SourceGeneratorBuildProperties.LoggingSessionId] = loggingSessionId; + } + if (options.DisableSourceGeneratorPropertyName is not null && options.DisableSourceGeneratorValue is not null) - analyzerOptions[IncrementalPipeline.BuildProperty + options.DisableSourceGeneratorPropertyName] = - options.DisableSourceGeneratorValue.Value.ToString(); + { + var disablePropertyName = options.DisableSourceGeneratorPropertyName; + if (!disablePropertyName.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + disablePropertyName = SourceGeneratorBuildProperties.BuildProperty + disablePropertyName; + + analyzerOptions[disablePropertyName] = options.DisableSourceGeneratorValue.Value.ToString(); + } if (analyzerOptions.Count > 0) driver = driver.WithUpdatedAnalyzerConfigOptions(new TestAnalyzerConfigOptionsProvider(analyzerOptions)); @@ -139,35 +191,56 @@ ConcurrentBag logEntries if (loggingSessionId is null) return null; - Action sink = (message, level) => + void Sink(string message, int level) { var type = (SourceGenLogLevel)level; options.TestOutput.WriteLine($"[{type}] {message}"); logEntries.Add(new(type, message)); - }; + } - List registrations = [SourceGenLogging.RegisterSinkCore(loggingSessionId, sink)]; + List registrations = [SourceGenLogging.RegisterSinkCore(loggingSessionId, Sink)]; // Self-contained generators may embed the framework logging types. Register the test sink // explicitly with that private copy rather than relying on process-wide shared state. - var embeddedLoggingType = typeof(TGenerator).Assembly.GetType( - "Purview.SourceGeneratorFramework.Logging.SourceGenLogging", - throwOnError: false - ); - if (embeddedLoggingType is not null && embeddedLoggingType != typeof(SourceGenLogging)) - { - var registerSink = embeddedLoggingType.GetMethod( - "RegisterSinkCore", - BindingFlags.Static | BindingFlags.NonPublic - ); - if (registerSink?.Invoke(null, [loggingSessionId, sink]) is IDisposable registration) - registrations.Add(registration); - } + var embeddedRegistration = GetEmbeddedSinkRegistration(typeof(TGenerator).Assembly); + if (embeddedRegistration is not null) + registrations.Add(embeddedRegistration(loggingSessionId, Sink)); return new(registrations); } + static readonly ConcurrentDictionary< + Assembly, + Func, IDisposable>? + > EmbeddedSinkRegistrationCache = new(); + + static Func, IDisposable>? GetEmbeddedSinkRegistration(Assembly generatorAssembly) + { + return EmbeddedSinkRegistrationCache.GetOrAdd( + generatorAssembly, + static assembly => + { + var embeddedLoggingType = assembly.GetType( + "Purview.SourceGeneratorFramework.Logging.SourceGenLogging", + throwOnError: false + ); + if (embeddedLoggingType is null || embeddedLoggingType == typeof(SourceGenLogging)) + return null; + + var registerSink = embeddedLoggingType.GetMethod( + "RegisterSinkCore", + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic + ); + + return registerSink is null + ? null + : (Func, IDisposable>) + Delegate.CreateDelegate(typeof(Func, IDisposable>), registerSink); + } + ); + } + sealed class LoggingRegistrations(List registrations) : IDisposable { List? _registrations = registrations; diff --git a/src/src/SourceGeneratorFramework/DeclarationSyntaxOptions.cs b/src/src/SourceGeneratorFramework/DeclarationSyntaxOptions.cs deleted file mode 100644 index 2be46a1..0000000 --- a/src/src/SourceGeneratorFramework/DeclarationSyntaxOptions.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Collections.Immutable; - -namespace Purview.SourceGeneratorFramework; - -/// Describes an attribute applied to a generated declaration. -public readonly record struct AttributeDeclarationOptions -{ - /// Creates an attribute declaration from a structured type reference. - public AttributeDeclarationOptions(TypeReferenceOptions type) => Type = type; - - /// Creates an attribute declaration from a structured type value. - /// - /// includes surrounding square brackets. This - /// constructor retains the rendered attribute name while removing those - /// delimiters because the code writer supplies them. - /// - public AttributeDeclarationOptions(TypeValueObject type) - : this(type.AsTypeReference()) { } - - /// Gets the structured attribute type. - public TypeReferenceOptions Type { get; } - - /// Gets an optional target such as return, field, or property. - public string? Target { get; init; } - - /// Gets structured attribute arguments. - public ImmutableArray Arguments { get; init; } -} - -/// Describes one positional or named attribute argument. -public readonly record struct AttributeArgumentOptions -{ - /// Creates a positional attribute argument. - public AttributeArgumentOptions(string value, string? name = null, bool isPropertyAssignment = false) => - (Value, Name, IsPropertyAssignment) = (value, name, isPropertyAssignment); - - /// Creates a positional Boolean attribute argument using a valid C# literal. - public AttributeArgumentOptions(bool value, string? name = null, bool isPropertyAssignment = false) => - (Value, Name, IsPropertyAssignment) = (value ? "true" : "false", name, isPropertyAssignment); - - /// Gets the argument expression. - public string Value { get; } - - /// Gets an optional constructor parameter or property name. - public string? Name { get; init; } - - /// Gets whether a named argument uses property assignment (=) instead of constructor naming (:). - public bool IsPropertyAssignment { get; init; } -} - -/// Identifies a generated parameter modifier. -public enum ParameterModifier -{ - /// No modifier. - None, - - /// The ref modifier. - Ref, - - /// The out modifier. - Out, - - /// The in modifier. - In, - - /// The ref readonly modifier. - RefReadOnly, -} - -/// Describes one argument supplied to a generated method call. -public readonly record struct MethodCallArgumentOptions -{ - /// Creates a method-call argument from its value expression. - /// The argument expression or variable name. - /// An optional named-argument label. - /// The argument passing modifier. - public MethodCallArgumentOptions( - string value, - string? name = null, - ParameterModifier modifier = ParameterModifier.None - ) - { - Value = string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Argument value cannot be null or whitespace.", nameof(value)) - : value; - Name = name; - Modifier = modifier; - } - - /// - /// Creates a method-call argument from its value expression, with a specified argument passing modifier. - /// - /// The argument expression or variable name. - /// The argument passing modifier. - public MethodCallArgumentOptions(string value, ParameterModifier modifier) - : this(value, null, modifier) { } - - /// Gets the argument expression or variable name. - public string Value { get; } - - /// Gets an optional named-argument label. - public string? Name { get; init; } - - /// Gets the argument passing modifier. - public ParameterModifier Modifier { get; init; } - - public static implicit operator MethodCallArgumentOptions(string value) => new(value); -} - -/// Describes a generated object-creation expression. -public readonly record struct ObjectCreationOptions -{ - /// Creates an object-creation expression. - /// The type to instantiate. - /// The constructor arguments; strings are implicitly supported. - public ObjectCreationOptions(TypeReferenceOptions type, params MethodCallArgumentOptions[] arguments) - { - if (type.IsEmpty) - throw new ArgumentException("Object-creation type cannot be empty.", nameof(type)); - - Type = type; - Arguments = arguments is null ? [] : [.. arguments]; - } - - /// Gets the type to instantiate. - public TypeReferenceOptions Type { get; } - - /// Gets the constructor arguments. - public ImmutableArray Arguments { get; } - - /// Gets whether constructor arguments are written one per line. - public bool WriteArgumentsOnSeparateLines { get; init; } -} - -/// Describes a generated method, constructor, delegate, or primary-constructor parameter. -public readonly record struct ParameterDeclarationOptions -{ - /// Creates a parameter declaration. - public ParameterDeclarationOptions( - string name, - TypeReferenceOptions type, - ParameterModifier modifier = ParameterModifier.None - ) - { - Name = name; - Type = type; - Modifier = modifier; - } - - /// Gets the parameter name. - public string Name { get; } - - /// Gets the parameter type. - public TypeReferenceOptions Type { get; } - - /// Gets the parameter passing modifier. - public ParameterModifier Modifier { get; init; } - - /// Gets whether this is emitted for an extension receiver. - public bool IsThis { get; init; } - - /// Gets whether params is emitted. - public bool IsParams { get; init; } - - /// Gets whether scoped is emitted. - public bool IsScoped { get; init; } - - /// Gets an optional default-value expression. - public string? DefaultValue { get; init; } - - /// Gets attributes applied to the parameter. - public ImmutableArray Attributes { get; init; } -} diff --git a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.Enum.cs b/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.Enum.cs deleted file mode 100644 index 9841d9a..0000000 --- a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.Enum.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Globalization; -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework.Extensions; - -partial class AttributeDataExtensions -{ - extension(AttributeData attribute) - { - /// - /// Gets the display name of an enum named argument, returning the default if the argument is not present or not an enum. - /// - public string? GetEnumNamedArgument(string name, string? defaultValue = null) - { - if ( - attribute.TryGetNamedArgument(name, out var value) - && !value.IsNull - && value.Kind == TypedConstantKind.Enum - ) - { - return value.ToEnumString(); - } - - // Return the default value if the named argument is not present or not an enum. - return defaultValue; - } - - /// - /// Gets the display name of an enum constructor argument by parameter name, returning the default if the argument is not present or not an enum. - /// - public string? GetEnumConstructorArgument(string name, string? defaultValue = null) - { - if ( - attribute.TryGetConstructorArgument(name, out var value) - && !value.IsNull - && value.Kind == TypedConstantKind.Enum - ) - { - return value.ToEnumString(); - } - - // Return the default value if the constructor argument is not present or not an enum. - return defaultValue; - } - - /// - /// Gets the display name of an enum constructor argument by index, returning the default if the argument is not present or not an enum. - /// - public string? GetEnumConstructorArgument(int index, string? defaultValue = null) - { - if ( - attribute.TryGetConstructorArgument(index, out var value) - && !value.IsNull - && value.Kind == TypedConstantKind.Enum - ) - { - return value.ToEnumString(); - } - - // Return the default value if the constructor argument is not present or not an enum. - return defaultValue; - } - } - - extension(TypedConstant constant) - { - /// - /// Converts the typed constant to a fully-qualified enum member display string (e.g. "Namespace.Type.Member"). - /// - public string? ToEnumString() - { - if (constant.IsNull || constant.Kind != TypedConstantKind.Enum) - return null; - - var type = constant.Type; - if (type is null) - return null; - - var typeName = TypeHelpers.ToFullyQualifiedDisplayString(type); - var value = constant.Value; - - foreach (var field in type.GetMembers().OfType()) - { - if (field.HasConstantValue && field.ConstantValue?.Equals(value) == true) - return $"{typeName}.{field.Name}"; - } - - return $"{typeName}.{Convert.ToString(value, CultureInfo.InvariantCulture)}"; - } - } -} diff --git a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.cs b/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.cs index 524085a..c41eb8f 100644 --- a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.cs +++ b/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.CodeAnalysis; -namespace Purview.SourceGeneratorFramework.Extensions; +namespace Purview.SourceGeneratorFramework; /// /// Provides extension methods for extracting values from . @@ -39,7 +39,7 @@ public bool TryGetNamedArgument(string name, out T? value) { if (string.Equals(namedArg.Key, name, StringComparison.Ordinal)) { - value = As(namedArg.Value); + value = namedArg.Value.As(); return true; } } @@ -115,7 +115,7 @@ public bool TryGetConstructorArgument(int index, out T? value) return false; } - value = As(attribute.ConstructorArguments[index]); + value = attribute.ConstructorArguments[index].As(); return true; } @@ -185,6 +185,60 @@ public bool TryGetGenericTypeArgument(string name, out T? value) return false; } + + /// + /// Gets the display name of an enum named argument, returning the default if the argument is not present or not an enum. + /// + public string? GetEnumNamedArgument(string name, string? defaultValue = null) + { + if ( + attribute.TryGetNamedArgument(name, out var value) + && !value.IsNull + && value.Kind == TypedConstantKind.Enum + ) + { + return value.ToEnumString(); + } + + // Return the default value if the named argument is not present or not an enum. + return defaultValue; + } + + /// + /// Gets the display name of an enum constructor argument by parameter name, returning the default if the argument is not present or not an enum. + /// + public string? GetEnumConstructorArgument(string name, string? defaultValue = null) + { + if ( + attribute.TryGetConstructorArgument(name, out var value) + && !value.IsNull + && value.Kind == TypedConstantKind.Enum + ) + { + return value.ToEnumString(); + } + + // Return the default value if the constructor argument is not present or not an enum. + return defaultValue; + } + + /// + /// Gets the display name of an enum constructor argument by index, returning the default if the argument is not present or not an enum. + /// + public string? GetEnumConstructorArgument(int index, string? defaultValue = null) + { + if ( + attribute.TryGetConstructorArgument(index, out var value) + && !value.IsNull + && value.Kind == TypedConstantKind.Enum + ) + { + return value.ToEnumString(); + } + + // Return the default value if the constructor argument is not present or not an enum. + return defaultValue; + } } static T? ConvertTypeSymbol(ITypeSymbol? typeSymbol) @@ -199,6 +253,13 @@ public bool TryGetGenericTypeArgument(string name, out T? value) return typeSymbol is T typedValue ? typedValue : default; } + if (targetType == typeof(TypeIdentity)) + { + return typeSymbol is not null && TypeIdentity.TryCreate(typeSymbol, out var identity) + ? (T?)(object?)identity + : default; + } + // If the target type is not a symbol type, we cannot convert it, so we return default. return default; } diff --git a/src/src/SourceGeneratorFramework/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtension.cs b/src/src/SourceGeneratorFramework/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtension.cs deleted file mode 100644 index a945f9d..0000000 --- a/src/src/SourceGeneratorFramework/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtension.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Microsoft.CodeAnalysis; - -public static class SourceProductionContextExtension -{ - extension(SourceProductionContext spc) - { - public void ReportDiagnostics(IEnumerable diagnostics) - { - if (diagnostics is null) - throw new ArgumentNullException(nameof(diagnostics)); - - foreach (var diagnostic in diagnostics) - { - spc.ReportDiagnostic(diagnostic.ToDiagnostic()); - } - } - } -} diff --git a/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs b/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs index a1ebdc9..9196d2d 100644 --- a/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs +++ b/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs @@ -8,10 +8,54 @@ public static class StringExtension extension(string? value) { /// - /// Surrounds the string with the specified string. Default is double quotes. + /// Surrounds the string with the specified string. Default is double quotes, + /// i.e. Hello, World! becomes "Hello, World!". /// /// The string to surround the value with. /// The surrounded string. public string Surround(string surroundWith = "\"") => $"{surroundWith}{value}{surroundWith}"; + + /// + /// Returns the string value or "null" if the value is null. If is true, then it will also return "null" if the value is whitespace. + /// + /// Whether to consider whitespace as null. + /// The string value or "null". + public string OrNullKeyword(bool useWhitespaceCheck = false) => + useWhitespaceCheck + ? string.IsNullOrWhiteSpace(value) + ? "null" + : value! + : value ?? "null"; + + /// + /// Trims the specified suffixes from the string value using . + /// If the value is null or no suffixes are provided, it returns null. + /// If a suffix is found, it returns the string without that suffix; otherwise, it returns the original string. + /// + /// The suffixes to trim. + /// The string without the specified suffixes, or null if the value is null or no suffixes are provided. + public string? TrimSuffix(params string[] suffixes) => TrimSuffix(value, StringComparison.Ordinal, suffixes); + + /// + /// Trims the specified suffixes from the string value using the specified comparison type. + /// If the value is null or no suffixes are provided, it returns null. + /// If a suffix is found, it returns the string without that suffix; otherwise, it returns the original string. + /// + /// The string comparison type to use. + /// The suffixes to trim. + /// The string without the specified suffixes, or null if the value is null or no suffixes are provided. + public string? TrimSuffix(StringComparison comparisonType, params string[] suffixes) + { + if (value is null || suffixes is null || suffixes.Length == 0) + return null; + + foreach (var suffix in suffixes) + { + if (value.EndsWith(suffix, comparisonType)) + return value.Substring(0, value.Length - suffix.Length); + } + + return value; + } } } diff --git a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.TypedConstants.cs b/src/src/SourceGeneratorFramework/Extensions/TypedConstantsExtensions.cs similarity index 59% rename from src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.TypedConstants.cs rename to src/src/SourceGeneratorFramework/Extensions/TypedConstantsExtensions.cs index 2a171d1..91e0312 100644 --- a/src/src/SourceGeneratorFramework/Extensions/AttributeDataExtensions.TypedConstants.cs +++ b/src/src/SourceGeneratorFramework/Extensions/TypedConstantsExtensions.cs @@ -1,10 +1,12 @@ using System.Collections.Immutable; +using System.ComponentModel; using System.Globalization; using Microsoft.CodeAnalysis; -namespace Purview.SourceGeneratorFramework.Extensions; +namespace Purview.SourceGeneratorFramework; -partial class AttributeDataExtensions +[EditorBrowsable(EditorBrowsableState.Never)] +public static partial class TypedConstantsExtensions { extension(TypedConstant constant) { @@ -29,6 +31,16 @@ partial class AttributeDataExtensions return constant.Kind == TypedConstantKind.Type && constant.Value is T typedValue ? typedValue : default; } + if (targetType == typeof(TypeIdentity)) + { + return + constant.Kind == TypedConstantKind.Type + && constant.Value is ITypeSymbol typeSymbol + && TypeIdentity.TryCreate(typeSymbol, out var identity) + ? (T?)(object?)identity + : default; + } + if (constant.Kind == TypedConstantKind.Array) { if (targetType == typeof(ImmutableArray)) @@ -71,5 +83,29 @@ partial class AttributeDataExtensions return default; } } + + /// + /// Converts the typed constant to a fully-qualified enum member display string (e.g. "Namespace.Type.Member"). + /// + public string? ToEnumString() + { + if (constant.IsNull || constant.Kind != TypedConstantKind.Enum) + return null; + + var type = constant.Type; + if (type is null) + return null; + + var typeName = TypeHelpers.ToFullyQualifiedDisplayString(type); + var value = constant.Value; + + foreach (var field in type.GetMembers().OfType()) + { + if (field.HasConstantValue && field.ConstantValue?.Equals(value) == true) + return $"{typeName}.{field.Name}"; + } + + return $"{typeName}.{Convert.ToString(value, CultureInfo.InvariantCulture)}"; + } } } diff --git a/src/src/SourceGeneratorFramework/GlobalUsings.cs b/src/src/SourceGeneratorFramework/GlobalUsings.cs index 3beae7a..df13bac 100644 --- a/src/src/SourceGeneratorFramework/GlobalUsings.cs +++ b/src/src/SourceGeneratorFramework/GlobalUsings.cs @@ -1,2 +1 @@ global using Purview.SourceGeneratorFramework.Helpers; -global using Purview.SourceGeneratorFramework.Models; diff --git a/src/src/SourceGeneratorFramework/Helpers/IncrementalGeneratorInitializationContextExtensions.cs b/src/src/SourceGeneratorFramework/Helpers/IncrementalGeneratorInitializationContextExtensions.cs deleted file mode 100644 index de48cd4..0000000 --- a/src/src/SourceGeneratorFramework/Helpers/IncrementalGeneratorInitializationContextExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace Purview.SourceGeneratorFramework.Helpers; - -/// -/// Provides extension methods for . -/// -public static class IncrementalGeneratorInitializationContextExtensions -{ - /// - /// Registers the Microsoft.CodeAnalysis.EmbeddedAttribute source using a . - /// - /// The initialization context. - /// The generator name to stamp into the generated source. - /// The optional generator version. - /// The source file name. - public static void RegisterEmbeddedAttribute( - this IncrementalGeneratorInitializationContext context, - string generatorName, - string? generatorVersion = null, - string fileName = "EmbeddedAttribute.g.cs" - ) - { - if (string.IsNullOrWhiteSpace(generatorName)) - throw new ArgumentException("Generator name cannot be null or whitespace.", nameof(generatorName)); - - context.RegisterPostInitializationOutput(spc => - { - var writer = new CodeWriter(generatorName, generatorVersion ?? "1.0.0.0"); - writer.WriteAutoGeneratedHeader(); - writer.WriteFileScopedNamespace( - PurviewTypeLibrary.Microsoft.CodeAnalysis.EmbeddedAttribute.AsTypeReference() - ); - - // The compiler treats this special type as implicitly compiler-generated and embedded, - // so no generated attributes are applied here. - writer.WriteClass( - new(PurviewTypeLibrary.Microsoft.CodeAnalysis.EmbeddedAttribute) - { - IsSealed = true, - IsPartial = true, - BaseType = PurviewTypeLibrary.System.Attribute, - IncludeGeneratedAttributes = false, - }, - bodyWriter => - bodyWriter.Comment( - "This is a special type that is treated as implicitly compiler-generated and embedded by the compiler." - ) - ); - - spc.AddSource(fileName, SourceText.From(writer.ToString(), Encoding.UTF8)); - }); - } -} diff --git a/src/src/SourceGeneratorFramework/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorFramework/Helpers/IncrementalPipeline.cs deleted file mode 100644 index 78845b0..0000000 --- a/src/src/SourceGeneratorFramework/Helpers/IncrementalPipeline.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Purview.SourceGeneratorFramework.Logging; - -namespace Purview.SourceGeneratorFramework.Helpers; - -/// -/// Helpers for building common incremental source generator pipelines. -/// -public static class IncrementalPipeline -{ - public const string BuildProperty = "build_property."; - - /// - /// Combines a state provider with another value and immediately projects the pair into a - /// named result, avoiding deeply nested tuple structures as a pipeline expands. - /// - public static IncrementalValueProvider CombineWith( - this IncrementalValueProvider stateProvider, - IncrementalValueProvider valueProvider, - Func selector, - string? trackingName = null - ) - { - if (selector is null) - throw new ArgumentNullException(nameof(selector)); - - var result = stateProvider - .Combine(valueProvider) - .Select((pair, cancellationToken) => selector(pair.Left, pair.Right, cancellationToken)); - - return string.IsNullOrWhiteSpace(trackingName) ? result : result.WithTrackingName(trackingName!); - } - - /// - /// Combines every item from a values provider with a single value and immediately projects - /// each pair, preserving independent per-item incrementality. - /// - public static IncrementalValuesProvider CombineWith( - this IncrementalValuesProvider stateProvider, - IncrementalValueProvider valueProvider, - Func selector, - string? trackingName = null - ) - { - if (selector is null) - throw new ArgumentNullException(nameof(selector)); - - var result = stateProvider - .Combine(valueProvider) - .Select((pair, cancellationToken) => selector(pair.Left, pair.Right, cancellationToken)); - - return string.IsNullOrWhiteSpace(trackingName) ? result : result.WithTrackingName(trackingName!); - } - - /// - /// Combines every item from a values provider with a single state value and immediately - /// projects each pair, preserving independent per-item incrementality. - /// - public static IncrementalValuesProvider CombineWith( - this IncrementalValueProvider stateProvider, - IncrementalValuesProvider valuesProvider, - Func selector, - string? trackingName = null - ) - { - return selector is null - ? throw new ArgumentNullException(nameof(selector)) - : valuesProvider.CombineWith( - stateProvider, - (value, state, cancellationToken) => selector(state, value, cancellationToken), - trackingName - ); - } - - /// - /// Collects a values provider and immediately projects its immutable array together with an - /// existing state, making additional pipeline inputs straightforward to add. - /// - public static IncrementalValueProvider CollectWith( - this IncrementalValueProvider stateProvider, - IncrementalValuesProvider valuesProvider, - Func, CancellationToken, TResult> selector, - string? trackingName = null - ) - { - return selector is null - ? throw new ArgumentNullException(nameof(selector)) - : stateProvider.CombineWith(valuesProvider.Collect(), selector, trackingName); - } - - /// - /// Collects all items from a values provider, combines the resulting immutable array with a - /// single value, and projects both into one aggregate result. - /// - public static IncrementalValueProvider CollectWith( - this IncrementalValuesProvider stateProvider, - IncrementalValueProvider valueProvider, - Func, TValue, CancellationToken, TResult> selector, - string? trackingName = null - ) - { - return selector is null - ? throw new ArgumentNullException(nameof(selector)) - : stateProvider.Collect().CombineWith(valueProvider, selector, trackingName); - } - - /// - /// Collects all items from two values providers and projects both immutable arrays into one - /// aggregate result. This is an aggregate operation rather than a Cartesian product. - /// - public static IncrementalValueProvider CollectWith( - this IncrementalValuesProvider leftProvider, - IncrementalValuesProvider rightProvider, - Func, ImmutableArray, CancellationToken, TResult> selector, - string? trackingName = null - ) - { - return selector is null - ? throw new ArgumentNullException(nameof(selector)) - : leftProvider.Collect().CombineWith(rightProvider.Collect(), selector, trackingName); - } - - /// - /// Creates a value provider that reads an MSBuild property to determine whether the generator is disabled. - /// - public static IncrementalValueProvider IsDisabledValueProvider( - IncrementalGeneratorInitializationContext context, - string propertyName - ) => PropertyValueProvider(context, propertyName, value => bool.TryParse(value, out var isDisabled) && isDisabled); - - /// - /// Creates a value provider that reads an MSBuild property. - /// - public static IncrementalValueProvider PropertyValueProvider( - IncrementalGeneratorInitializationContext context, - string propertyName, - Func converter - ) - { - if (string.IsNullOrWhiteSpace(propertyName)) - throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); - if (converter == null) - throw new ArgumentNullException(nameof(converter)); - - var msbuildPropertyValue = propertyName; - if (!propertyName.StartsWith(BuildProperty, StringComparison.Ordinal)) - msbuildPropertyValue = BuildProperty + propertyName; - - // All valid... - return context - .AnalyzerConfigOptionsProvider.Select( - (options, _) => - { - options.GlobalOptions.TryGetValue(msbuildPropertyValue, out var value); - - return converter(value); - } - ) - .WithTrackingName($"GetMSBuildPropertyValue_{propertyName}"); - } - - /// - /// Creates a value provider that builds a generation context from the compilation, framework - /// build properties, an optional generator-disable property, and any registered logging sink. - /// - public static IncrementalValueProvider GenerationContextValueProvider( - IncrementalGeneratorInitializationContext context, - string generatorName, - string generatorVersion, - Func factory, - string? disablePropertyName = null - ) - where TContext : notnull, GenerationContext - { - if (factory is null) - throw new ArgumentNullException(nameof(factory)); - - var baseSettings = new GenerationSettings(generatorName, generatorVersion); - - return context - .CompilationProvider.Combine(GenerationConfigurationValueProvider(context, disablePropertyName)) - .Select( - (input, cancellationToken) => - { - cancellationToken.ThrowIfCancellationRequested(); - - var configuration = input.Right; - var logger = configuration.IsLoggingEnabled - ? SourceGenLogging.CreateLogger(configuration.LoggingSessionId) - : null; - - logger?.Info( - $"Creating generation context ({typeof(TContext)}) for compilation '{input.Left.AssemblyName}'." - ); - - return factory( - input.Left, - baseSettings with - { - ValidateCodeWriterScopes = configuration.ValidateCodeWriterScopes, - IsSourceGeneratorDisabled = configuration.IsSourceGeneratorDisabled, - IsLoggingEnabled = logger is not null, - }, - logger, - cancellationToken - ); - } - ) - .WithTrackingName($"GetGenerationContext_{typeof(TContext).Name}"); - } - - /// - /// Creates a value provider that builds a default generation context from the compilation and - /// resolved framework configuration. - /// - public static IncrementalValueProvider DefaultGenerationContextValueProvider( - IncrementalGeneratorInitializationContext context, - string generatorName, - string generatorVersion, - string? disablePropertyName = null - ) - { - return GenerationContextValueProvider( - context, - generatorName, - generatorVersion, - static (compilation, settings, logger, _) => new GenerationContext(compilation, settings, logger), - disablePropertyName - ); - } - - static IncrementalValueProvider GenerationConfigurationValueProvider( - IncrementalGeneratorInitializationContext context, - string? disablePropertyName - ) => - context - .AnalyzerConfigOptionsProvider.Select( - (options, _) => - { - options.GlobalOptions.TryGetValue( - BuildProperty + GenerationContext.ValidateCodeWriterScopesBuildProperty, - out var scopeValidationValue - ); - options.GlobalOptions.TryGetValue( - BuildProperty + GenerationContext.EnableLoggingBuildProperty, - out var loggingEnabledValue - ); - options.GlobalOptions.TryGetValue( - BuildProperty + GenerationContext.LoggingSessionIdBuildProperty, - out var loggingSessionId - ); - - string? disabledValue = null; - if (!string.IsNullOrWhiteSpace(disablePropertyName)) - { - var propertyName = disablePropertyName!.StartsWith(BuildProperty, StringComparison.Ordinal) - ? disablePropertyName - : BuildProperty + disablePropertyName; - options.GlobalOptions.TryGetValue(propertyName, out disabledValue); - } - - return new GenerationConfiguration( - ValidateCodeWriterScopes: bool.TryParse(scopeValidationValue, out var validateScopes) - && validateScopes, - IsSourceGeneratorDisabled: bool.TryParse(disabledValue, out var isDisabled) && isDisabled, - IsLoggingEnabled: bool.TryParse(loggingEnabledValue, out var loggingEnabled) && loggingEnabled, - LoggingSessionId: loggingSessionId - ); - } - ) - .WithTrackingName("GetGenerationConfiguration"); - - readonly record struct GenerationConfiguration( - bool ValidateCodeWriterScopes, - bool IsSourceGeneratorDisabled, - bool IsLoggingEnabled, - string? LoggingSessionId - ); - - /// - /// Creates a values provider for syntax nodes annotated with a specific attribute. - /// - public static IncrementalValuesProvider ForAttributeWithMetadataName( - IncrementalGeneratorInitializationContext context, - TypeValueObject attributeType, - Func transform, - Func? predicate = null, - string? trackingName = null - ) - where TOutput : notnull - { - if (transform is null) - throw new ArgumentNullException(nameof(transform)); - - predicate ??= static (_, _) => true; - - return context - .SyntaxProvider.ForAttributeWithMetadataName(attributeType.MetadataFullName, predicate, transform) - .WithTrackingName(trackingName ?? $"ForAttribute_{attributeType.TypeName}"); - } - - /// - /// Combines a values provider with a single value provider, returning a values provider of tuples. - /// - public static IncrementalValuesProvider<(TOutput Output, TContext Context)> CombineWithContext( - this IncrementalValuesProvider valuesProvider, - IncrementalValueProvider contextProvider - ) - where TOutput : notnull - { - return valuesProvider.CombineWith( - contextProvider, - static (output, generationContext, _) => (output, generationContext) - ); - } - - /// - /// Registers a source output that combines each with a - /// generation context, reports any diagnostics, and invokes the generator callback only for - /// successful results. This keeps generator methods thin - /// and enforces the rule that diagnostics are reported in the source output stage. - /// - public static void RegisterSourceOutput( - this IncrementalGeneratorInitializationContext context, - IncrementalValuesProvider> outputs, - IncrementalValueProvider contextProvider, - Action generate, - string? trackingName = null - ) - where TOutput : notnull - where TContext : GenerationContext - { - if (generate is null) - throw new ArgumentNullException(nameof(generate)); - - var combined = outputs - .CombineWith(contextProvider, static (output, ctx, _) => (Output: output, Context: ctx)) - .WithTrackingName(trackingName ?? $"RegisterSourceOutput_{typeof(TOutput).Name}"); - - context.RegisterSourceOutput( - combined, - (spc, item) => - { - if (item.Output.HasDiagnostics) - spc.ReportDiagnostics(item.Output.Diagnostics); - - if (!item.Output.IsSuccess || item.Output.IsFatal) - return; - - generate(spc, item.Output.Value!, item.Context); - } - ); - } -} diff --git a/src/src/SourceGeneratorFramework/Helpers/SymbolResolver.cs b/src/src/SourceGeneratorFramework/Helpers/SymbolResolver.cs index a986611..3060151 100644 --- a/src/src/SourceGeneratorFramework/Helpers/SymbolResolver.cs +++ b/src/src/SourceGeneratorFramework/Helpers/SymbolResolver.cs @@ -16,17 +16,17 @@ compilation is null : compilation.GetTypeByMetadataName(fullyQualifiedName); /// - /// Resolves a type from a . + /// Resolves a type from a . /// - public static INamedTypeSymbol? Resolve(Compilation compilation, TypeValueObject type) => + public static INamedTypeSymbol? Resolve(Compilation compilation, TypeIdentity type) => compilation is null ? throw new ArgumentNullException(nameof(compilation)) : Resolve(compilation, type.MetadataFullName); /// - /// Resolves a type from a and returns a value indicating whether it was found. + /// Resolves a type from a and returns a value indicating whether it was found. /// - public static bool TryResolve(Compilation compilation, TypeValueObject type, out INamedTypeSymbol? symbol) + public static bool TryResolve(Compilation compilation, TypeIdentity type, out INamedTypeSymbol? symbol) { symbol = Resolve(compilation, type); return symbol is not null; diff --git a/src/src/SourceGeneratorFramework/Models/GenerationContext.cs b/src/src/SourceGeneratorFramework/Models/GenerationContext.cs deleted file mode 100644 index 3c68de6..0000000 --- a/src/src/SourceGeneratorFramework/Models/GenerationContext.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Microsoft.CodeAnalysis; -using Purview.SourceGeneratorFramework.Logging; - -namespace Purview.SourceGeneratorFramework.Models; - -/// Describes immutable settings shared by a source-generation operation. -public sealed record GenerationSettings -{ - /// Initializes source-generation settings. - public GenerationSettings(string generatorName, string generatorVersion, bool validateCodeWriterScopes = false) - { - if (string.IsNullOrWhiteSpace(generatorName)) - throw new ArgumentException("Generator name cannot be null or whitespace.", nameof(generatorName)); - if (string.IsNullOrWhiteSpace(generatorVersion)) - throw new ArgumentException("Generator version cannot be null or whitespace.", nameof(generatorVersion)); - - GeneratorName = generatorName; - GeneratorVersion = generatorVersion; - ValidateCodeWriterScopes = validateCodeWriterScopes; - } - - /// Gets the source generator name propagated to code writers. - public string GeneratorName { get; } - - /// Gets the source generator version propagated to code writers. - public string GeneratorVersion { get; } - - /// Gets whether created code writers validate undisposed scopes. - public bool ValidateCodeWriterScopes { get; init; } - - /// Gets whether the source generator is disabled by build configuration. - public bool IsSourceGeneratorDisabled { get; init; } - - /// Gets whether source-generator logging is active for this generation context. - public bool IsLoggingEnabled { get; init; } -} - -/// -/// Provides execution services for source generation, including the compilation, immutable -/// settings, optional logging, and symbol-resolution helpers. -/// -/// Initializes a generation context. -public class GenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger = null) - : ISourceGenLogger -{ - /// The MSBuild property that controls validation of undisposed code-writer scopes. - public const string ValidateCodeWriterScopesBuildProperty = - "PurviewSourceGeneratorFrameworkValidateCodeWriterScopes"; - - /// The MSBuild property that enables source-generator logging. - public const string EnableLoggingBuildProperty = "PurviewSourceGeneratorFrameworkEnableLogging"; - - /// The MSBuild property that identifies the registered logging sink for a generator run. - public const string LoggingSessionIdBuildProperty = "PurviewSourceGeneratorFrameworkLoggingSessionId"; - - /// Gets the assembly name of the compilation being processed. - public string AssemblyName { get; } = compilation.AssemblyName ?? string.Empty; - - /// Gets the language of the compilation being processed. - public string Language { get; } = compilation.Language; - - /// Gets the compilation being processed. - public Compilation Compilation { get; } = compilation ?? throw new ArgumentNullException(nameof(compilation)); - - /// Gets the immutable generation settings. - public GenerationSettings Settings { get; } = settings ?? throw new ArgumentNullException(nameof(settings)); - - /// Gets the optional logger, used during test execution. - public ISourceGenLogger? Logger { get; } = logger; - - /// Creates a new independently owned code writer. - public CodeWriter CreateCodeWriter() => - new( - Settings.GeneratorName, - Settings.GeneratorVersion, - throwOnUnclosedScopes: Settings.ValidateCodeWriterScopes - ); - - /// Resolves a type by its fully qualified metadata name. - public INamedTypeSymbol? GetTypeByMetadataName(string fullyQualifiedName) => - Compilation.GetTypeByMetadataName(fullyQualifiedName); - - /// Resolves a type from a structured type value. - public INamedTypeSymbol? GetTypeByMetadataName(TypeValueObject type) => - GetTypeByMetadataName(type.MetadataFullName); - - /// - public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => - Logger?.Log(level, indentation, message, args); -} diff --git a/src/src/SourceGeneratorFramework/Models/GeneratorResult.cs b/src/src/SourceGeneratorFramework/Models/GeneratorResult.cs deleted file mode 100644 index d45cac1..0000000 --- a/src/src/SourceGeneratorFramework/Models/GeneratorResult.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace Purview.SourceGeneratorFramework.Models; - -/// -/// Represents the result of an incremental source generator transform, carrying either a value, diagnostics, or both. -/// -/// The value type. -[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] -public readonly record struct GeneratorResult -{ - public T? Value { get; private init; } - - public EquatableArray Diagnostics { get; private init; } - - public bool IsSuccess => Value is not null && !EqualityComparer.Default.Equals(Value, default!); - - public bool HasDiagnostics => !Diagnostics.IsEmpty; - - public bool IsFatal => !IsSuccess && HasDiagnostics; - - public bool IsEmpty => !IsSuccess && !HasDiagnostics; - - public static GeneratorResult Ok(T value, params DiagnosticInfo[] diagnostics) - { - return new GeneratorResult - { - Value = value, - Diagnostics = - diagnostics is null || diagnostics.Length == 0 - ? EquatableArray.Empty - : EquatableArray.Create(diagnostics), - }; - } - - public static GeneratorResult Fail(params DiagnosticInfo[] diagnostics) - { - if (diagnostics is null || diagnostics.Length == 0) - { - throw new ArgumentException( - "At least one diagnostic must be provided for a failure result.", - nameof(diagnostics) - ); - } - - // All valid... - return new() { Diagnostics = EquatableArray.Create(diagnostics) }; - } - - public static GeneratorResult Empty { get; } -} diff --git a/src/src/SourceGeneratorFramework/Models/TargetSymbolDescriptor.cs b/src/src/SourceGeneratorFramework/Models/TargetSymbolDescriptor.cs deleted file mode 100644 index 447a2ee..0000000 --- a/src/src/SourceGeneratorFramework/Models/TargetSymbolDescriptor.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Purview.SourceGeneratorFramework.Models; - -/// -/// Describes a target symbol and its declaration syntax for source generation. -/// -public sealed record class TargetSymbolDescriptor(INamedTypeSymbol Symbol, TypeDeclarationSyntax? Declaration); diff --git a/src/src/SourceGeneratorFramework/Models/TypeValueObject.cs b/src/src/SourceGeneratorFramework/Models/TypeValueObject.cs deleted file mode 100644 index d014586..0000000 --- a/src/src/SourceGeneratorFramework/Models/TypeValueObject.cs +++ /dev/null @@ -1,404 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework.Models; - -/// -/// Represents a simple type/value descriptor used during source generation. -/// -public readonly record struct TypeValueObject : IEquatable -{ - /// - /// Initializes a new instance of the struct from a . - /// - /// The type to initialize the value object from. - /// Thrown when the provided type is null. - public TypeValueObject(Type type) - { - if (type == null) - throw new ArgumentNullException(nameof(type)); - - var knownType = KnownLangTypes.Get(type); - if (knownType != TypeMapping.Empty) - { - TypeName = knownType.Type.Name; - Namespace = knownType.Type.Namespace; - Keyword = knownType.Keyword; - SpecialType = knownType.SpecialType; - } - else - { - var metadataName = type.Name; - var aritySeparator = metadataName.IndexOf('`'); - - TypeName = aritySeparator < 0 ? metadataName : metadataName.Substring(0, aritySeparator); - Namespace = type.Namespace; - GenericArity = type.IsGenericType ? type.GetGenericArguments().Length : 0; - TypeArguments = - type.IsGenericType && !type.IsGenericTypeDefinition - ? [.. type.GetGenericArguments().Select(static argument => new TypeValueObject(argument))] - : []; - } - } - - /// - /// Initializes a new instance of the struct. - /// - /// Note: This constructor does not validate the provided type name or namespace. It is the caller's responsibility to ensure that the values are valid and represent a real type. - /// If this is a known C# keyword type, consider using the , , or constructors instead. - /// - /// - /// Also beware that this constructor does not handle generic types. If you need to represent a generic type, use the or constructors, - /// or the method after construction. - /// - /// - public TypeValueObject(string typeName, string? @namespace) - { - TypeName = typeName ?? throw new ArgumentNullException(nameof(typeName)); - Namespace = @namespace; - GenericArity = 0; - TypeArguments = []; - } - - /// - /// Initializes a new instance of the struct from a Roslyn type symbol. - /// - public TypeValueObject(ITypeSymbol typeSymbol) - { - if (typeSymbol == null) - throw new ArgumentNullException(nameof(typeSymbol)); - - var knownType = KnownLangTypes.Get(typeSymbol.SpecialType); - if (knownType != TypeMapping.Empty) - { - TypeName = knownType.Type.Name; - Namespace = knownType.Type.Namespace; - Keyword = knownType.Keyword; - SpecialType = knownType.SpecialType; - } - else - { - TypeName = typeSymbol.Name; - Namespace = typeSymbol.ContainingNamespace.IsGlobalNamespace - ? null - : typeSymbol.ContainingNamespace.ToDisplayString(); - - if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType) - { - GenericArity = namedType.Arity; - TypeArguments = IsGenericDefinition(namedType) - ? [] - : - [ - .. namedType.TypeArguments.Select(static argument => - { - return TypeHelpers.IsKeywordType(argument) - ? new(argument.SpecialType) - : new TypeValueObject(argument); - }), - ]; - } - else - { - GenericArity = 0; - TypeArguments = []; - } - } - } - - /// - /// Initializes a new instance of the struct from a recognized C# keyword special type. - /// - public TypeValueObject(SpecialType specialType) - { - var knownType = KnownLangTypes.Get(specialType); - if (knownType == TypeMapping.Empty) - { - throw new ArgumentException( - $"The provided special type '{specialType}' is not a recognized C# keyword type.", - nameof(specialType) - ); - } - - TypeName = knownType.Type.Name; - Namespace = knownType.Type.Namespace; - Keyword = knownType.Keyword; - SpecialType = knownType.SpecialType; - } - - /// - /// Gets the recognized C# keyword special type, or if the type is not a recognized keyword type. - /// - public SpecialType SpecialType { get; init; } = SpecialType.None; - - /// - /// Gets the C# keyword for the type, or if the type does not have a keyword representation. - /// - public string? Keyword { get; init; } - - /// - /// Gets the type name without its namespace. - /// - public string TypeName { get; init; } - - /// - /// Gets the namespace, or if the type is in the global namespace. - /// - public string? Namespace { get; init; } - - /// - /// Gets the number of generic parameters declared by the type. - /// - public int GenericArity { get; init; } - - /// - /// Gets the concrete generic type arguments for a constructed type. - /// - /// - /// This collection is empty for a non-generic type and for an open generic type definition. - /// Use to distinguish those cases. - /// - public ImmutableArray TypeArguments { get; init; } - - /// - /// Gets a value indicating whether this value represents an open generic type definition. - /// - public bool IsGenericTypeDefinition => GenericArity > 0 && TypeArguments.IsDefaultOrEmpty; - - /// - /// Gets the CLR metadata name, including the generic arity suffix when required. - /// - public string MetadataName => GenericArity == 0 ? TypeName : $"{TypeName}`{GenericArity}"; - - /// - /// Gets the namespace-qualified CLR metadata name used by Roslyn type lookup. - /// - public string MetadataFullName => IsGlobalNamespace ? MetadataName : $"{Namespace}.{MetadataName}"; - - /// - /// Gets the fully-qualified global type name for use in generated code. - /// - public string RenderFullName - { - get - { - if (SpecialType != SpecialType.None) - return Keyword!; - - // If the type is in the global namespace, we can render it without the "global::" prefix. - return IsGlobalNamespace ? RenderTypeName : $"global::{Namespace}.{RenderTypeName}"; - } - } - - /// - /// Gets the type name suitable for use in generated code. - /// - public string RenderTypeName - { - get - { - if (SpecialType != SpecialType.None) - return Keyword!; - - if (GenericArity == 0) - return TypeName; - - if (TypeArguments.IsDefaultOrEmpty) - return $"{TypeName}<{new string(',', GenericArity - 1)}>"; - - // If the type has concrete type arguments, render them in angle brackets. - return $"{TypeName}<{string.Join(", ", TypeArguments.Select(static argument => argument.RenderFullName))}>"; - } - } - - /// - /// Gets the fully-qualified name rendered as a C# attribute application, including brackets and - /// the optional omission of the Attribute suffix. - /// - public string RenderAttributeName => $"[{TypeHelpers.GetTypeName(RenderFullName)}]"; - - /// - /// Gets a value indicating whether the type is in the global namespace. - /// - public bool IsGlobalNamespace => Namespace is null; - - /// - /// Returns the rendered full name. - /// - public override string ToString() => RenderFullName; - - /// - /// Determines whether the specified is equal to the current . - /// - /// The type symbol to compare with the current type value object. - /// if the specified type symbol is equal to the current type value object; otherwise, . - public bool Equals(ITypeSymbol? other) - { - if (other is null) - return false; - - var otherNamespace = other.ContainingNamespace.IsGlobalNamespace - ? null - : other.ContainingNamespace.ToDisplayString(); - - if (TypeName != other.Name || Namespace != otherNamespace || SpecialType != other.SpecialType) - return false; - - if (other is not INamedTypeSymbol namedType) - return GenericArity == 0; - - if (GenericArity != namedType.Arity) - return false; - - // An open definition represents every constructed form of that definition. - if (TypeArguments.IsDefaultOrEmpty) - return true; - - // If the current value has concrete type arguments, ensure they match the other type's arguments. - return TypeArguments.Length == namedType.TypeArguments.Length - && TypeArguments - .Zip(namedType.TypeArguments, static (expected, actual) => expected.Equals(actual)) - .All(static equal => equal); - } - - /// Determines whether the specified runtime type represents the same semantic type. - public bool Equals(Type? other) => other is not null && Equals(new TypeValueObject(other)); - - /// Determines whether the specified structured type reference represents this semantic type. - public bool Equals(TypeReferenceOptions other) => other.Equals(this); - - /// - /// Determines whether the specified value represents the same type. - /// - public bool Equals(TypeValueObject other) - { - var typeArgumentCount = TypeArguments.IsDefaultOrEmpty ? 0 : TypeArguments.Length; - var otherTypeArgumentCount = other.TypeArguments.IsDefaultOrEmpty ? 0 : other.TypeArguments.Length; - - if ( - TypeName != other.TypeName - || Namespace != other.Namespace - || SpecialType != other.SpecialType - || Keyword != other.Keyword - || GenericArity != other.GenericArity - || typeArgumentCount != otherTypeArgumentCount - ) - return false; - - for (var index = 0; index < typeArgumentCount; index++) - { - if (!TypeArguments[index].Equals(other.TypeArguments[index])) - return false; - } - - return true; - } - - /// - /// Returns a structural hash code for this type and its generic arguments. - /// - public override int GetHashCode() - { - unchecked - { - var hashCode = TypeName?.GetHashCode() ?? 0; - hashCode = (hashCode * 397) ^ (Namespace?.GetHashCode() ?? 0); - hashCode = (hashCode * 397) ^ SpecialType.GetHashCode(); - hashCode = (hashCode * 397) ^ (Keyword?.GetHashCode() ?? 0); - hashCode = (hashCode * 397) ^ GenericArity; - - if (!TypeArguments.IsDefaultOrEmpty) - { - foreach (var argument in TypeArguments) - hashCode = (hashCode * 397) ^ argument.GetHashCode(); - } - - return hashCode; - } - } - - /// - /// Implicitly converts a to its rendered full name. - /// - public static implicit operator string(TypeValueObject typeValueObject) => typeValueObject.RenderFullName; - - /// - /// Creates the canonical source-generation type reference for this type. - /// - /// - /// Source-generation pipelines should pass values rather - /// than switching between semantic type values and rendered strings. The returned reference - /// retains this value for semantic equality while allowing nullable, array, pointer, and - /// generic syntax to be composed. - /// - public TypeReferenceOptions AsTypeReference() => new(this); - - /// Creates a nullable structured type reference. - public TypeReferenceOptions MakeNullable() => AsTypeReference().Nullable(); - - /// Creates an array structured type reference with the specified rank. - public TypeReferenceOptions MakeArray(int rank = 1) => AsTypeReference().MakeArray(rank); - - /// Creates a pointer structured type reference. - public TypeReferenceOptions MakePointer() => AsTypeReference().MakePointer(); - - /// - /// Creates a generic variant of this type using the standard angle-bracket syntax. - /// - public TypeValueObject MakeGeneric(params string[] typeArguments) - { - if (typeArguments == null) - throw new ArgumentNullException(nameof(typeArguments)); - - // If the type has no generic arity, we can treat the provided type arguments as concrete types. - return MakeGeneric(typeArguments.Select(static argument => new TypeValueObject(argument, null)).ToArray()); - } - - /// - /// Creates a constructed generic type using the specified type arguments. - /// - public TypeValueObject MakeGeneric(params TypeValueObject[] typeArguments) - { - if (typeArguments == null) - throw new ArgumentNullException(nameof(typeArguments)); - - if (typeArguments.Length == 0) - throw new ArgumentException("At least one type argument must be provided.", nameof(typeArguments)); - - if (GenericArity > 0 && typeArguments.Length != GenericArity) - { - throw new ArgumentException( - $"Type '{MetadataFullName}' requires {GenericArity} type arguments, but {typeArguments.Length} were supplied.", - nameof(typeArguments) - ); - } - - if (SpecialType != SpecialType.None) - { - throw new InvalidOperationException($"Cannot create a generic type from the special type '{SpecialType}'."); - } - - // If the type has no generic arity, we can treat the provided type arguments as concrete types. - return this with - { - GenericArity = GenericArity == 0 ? typeArguments.Length : GenericArity, - TypeArguments = [.. typeArguments], - }; - } - - /// - /// Gets an empty . - /// - public static readonly TypeValueObject Empty; - - /// - /// Creates a from a generic type parameter. - /// - /// The type parameter. - /// A representing the type parameter. - public static TypeValueObject Create() => new(typeof(T)); - - static bool IsGenericDefinition(INamedTypeSymbol typeSymbol) => - typeSymbol.IsUnboundGenericType - || SymbolEqualityComparer.Default.Equals(typeSymbol, typeSymbol.OriginalDefinition); -} diff --git a/src/src/SourceGeneratorFramework/Sdk/.agents/agents/source-generator-framework-writer.agent.md b/src/src/SourceGeneratorFramework/Sdk/.agents/agents/source-generator-framework-writer.agent.md index 80fec8c..7710ee3 100644 --- a/src/src/SourceGeneratorFramework/Sdk/.agents/agents/source-generator-framework-writer.agent.md +++ b/src/src/SourceGeneratorFramework/Sdk/.agents/agents/source-generator-framework-writer.agent.md @@ -25,13 +25,37 @@ You are a specialist for `Purview.SourceGeneratorFramework` emitter authoring. Produce clear, deterministic, maintainable source-generator emission code using `CodeWriter` and XML extension helpers from `XmlCommentWriter`. +## Background knowledge + +Before changing any source generator, analyser, or CodeWriter-related code, load and apply the `source-generator-codewriter-modernization` skill. It contains the full source-generator, analyser, and CodeWriter best-practices guidance for this framework, including incremental pipeline design, value equality, deterministic output, and Roslyn version compatibility. + +The most important rules are: + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` whenever possible.** +- **Remove `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxTree`, `SyntaxNode`, and `Location` from incremental pipeline models as early as possible.** +- **Pipeline models must be immutable and value-equatable; use `EquatableArray` for collections.** +- **Avoid `Collect()` until global knowledge is genuinely required.** +- **Never combine `CompilationProvider` into the pipeline merely because it is convenient.** +- **Generate deterministic output and stable hint names.** +- **Test incrementally, not just generated text.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Keep `CodeWriter` instances output-scoped; never cache them in incremental provider state or custom contexts.** + +## Available resources + +- `skills/source-generator-codewriter-modernization/SKILL.md` — source-generator, analyser, and CodeWriter best practices for this framework. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. + ## Must-follow rules -1. Prefer structured declaration APIs over handwritten declaration strings. -2. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output. -3. Keep `CodeWriter` instances output-scoped; never cache in incremental provider state. -4. Preserve semantic behavior while modernizing implementation style. -5. Keep edits minimal and localized to emitter concerns. +1. Load and apply the `source-generator-codewriter-modernization` skill. +2. Prefer structured declaration APIs over handwritten declaration strings. +3. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output. +4. Keep `CodeWriter` instances output-scoped; never cache in incremental provider state or custom contexts. +5. Preserve semantic behavior while modernizing implementation style. +6. Keep edits minimal and localized to emitter concerns. ## Refactoring posture diff --git a/src/src/SourceGeneratorFramework/Sdk/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md b/src/src/SourceGeneratorFramework/Sdk/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md index 9cf531b..d1bbb1f 100644 --- a/src/src/SourceGeneratorFramework/Sdk/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md +++ b/src/src/SourceGeneratorFramework/Sdk/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md @@ -3,7 +3,7 @@ agent: ask description: "Refactor a legacy source generator emitter from string/StringBuilder to CodeWriter + XmlCodeWriter-style XML extensions with behavior parity." --- -You are modernizing a source generator implementation in this repository. +You are modernizing a source generator implementation in this repository. Apply the guidance from the `source-generator-codewriter-modernization` skill for incremental pipelines, value equality, deterministic output, and CodeWriter scope safety. ## Inputs diff --git a/src/src/SourceGeneratorFramework/Sdk/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/src/src/SourceGeneratorFramework/Sdk/.agents/skills/source-generator-codewriter-modernization/SKILL.md index a378822..26ae901 100644 --- a/src/src/SourceGeneratorFramework/Sdk/.agents/skills/source-generator-codewriter-modernization/SKILL.md +++ b/src/src/SourceGeneratorFramework/Sdk/.agents/skills/source-generator-codewriter-modernization/SKILL.md @@ -1,33 +1,227 @@ --- name: source-generator-codewriter-modernization -description: "Use when implementing or refactoring C# source generators in Purview.SourceGeneratorFramework, especially migrating string/StringBuilder emitters to CodeWriter and XmlCodeWriter-style XML doc extensions for types, methods, properties, fields, and constructors." +description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices." --- # Source generator CodeWriter modernization -Use this skill when the task is about generating C# source, modernizing legacy emitter code, or producing maintainable generated output using `Purview.SourceGeneratorFramework`. - -## Outcomes this skill should enforce - -- Prefer structured declaration APIs on `CodeWriter` (`WriteClass`, `WriteMethod`, `WriteProperty`, `WriteField`, `WriteConstructor`, `WriteType` variants). -- Prefer XML documentation extension APIs from `XmlCommentWriter` (sometimes referred to as **XmlCodeWriter-style extensions**): `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlExample`, `XmlCode`, `XmlCodeBlock`, `XmlList`. -- Keep generated output deterministic (`WriteAutoGeneratedHeader`, stable ordering, no timestamps). -- Keep mutable writers out of incremental provider state: create writers only inside source-output callbacks. +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. ## Required implementation pattern When creating source output, favor this shape: -1. Build immutable pipeline values first. +1. Build immutable, value-equatable pipeline values first. 2. Register source output. -3. Inside callback: `var writer = generationContext.CreateCodeWriter();` +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` 4. Write header/usings/namespace. -5. Write structured type and members using options records. +5. Write structured types and members using declaration options records. 6. Add source once per output artifact. Never store `CodeWriter` in `GenerationContext` or custom contexts. -## Preferred API map +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter ### File and namespace @@ -43,44 +237,27 @@ Never store `CodeWriter` in `GenerationContext` or custom contexts. - Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` - Type syntax: `TypeReferenceOptions` (nullable/generic/array/pointer-safe composition) -### XML documentation (XmlCodeWriter-style extension usage) +### XML documentation -Use extension methods from `XmlCommentWriter` on `CodeWriter`: +Use `XmlCommentWriter` extension methods on `CodeWriter`: -- `XmlSummary(...)` -- `XmlParam(parameterName, ...)` -- `XmlTypeParam(...)` -- `XmlReturn(...)` -- `XmlRemarks(...)` -- `XmlExample(...)` +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` - `XmlCode(...)` / `XmlCodeBlock(...)` - `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` -For inline composition, use static helpers: - -- `CodeWriter.XmlInlineCode(...)` -- `CodeWriter.XmlSee(...)` -- `CodeWriter.XmlParamRef(...)` -- `CodeWriter.XmlText(...)` +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. ## Refactoring guide: string/StringBuilder -> CodeWriter Apply this checklist in order: -1. **Move emission boundaries** - - Replace one giant string assembly with explicit phases: header, namespace, type, member declarations. -2. **Replace manual braces/indentation** - - Convert brace writes and indentation counters to `using` scopes (`WriteClassScope`, `WriteMethodScope`, `OpenBlockScope`, `IndentedScope`). -3. **Replace handwritten signatures** - - Convert method/property/field/constructor signatures to declaration option records. -4. **Replace raw XML lines** - - Convert `///` concatenation to XML extension methods (`XmlSummary`, `XmlParam`, etc.). -5. **Normalize type strings** - - Replace fragile type text with `TypeReferenceOptions`. -6. **Preserve semantics and ordering** - - Ensure generated members and diagnostics remain equivalent after migration. -7. **Validate scope safety** - - Keep or enable CodeWriter scope validation (`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes`) for tests/dev. +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`WriteClassScope`, `WriteMethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReferenceOptions`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. ## Anti-patterns to remove during refactors @@ -89,11 +266,22 @@ Apply this checklist in order: - Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReferenceOptions` is available. - Raw XML tag string composition when XML extension methods can enforce consistency. - Sharing one `CodeWriter` across multiple generated outputs. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. ## Review checklist for pull requests -- Generated declarations use structured APIs for at least types + methods/properties. +- Generated declarations use structured APIs for types and members. - XML docs use XML extension methods rather than raw `///` fragments. - `CodeWriter` is created per output callback and not cached. - Header and generated attributes are deterministic and consistent. - Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index 68a0fd3..eac3951 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -1,5 +1,7 @@ # Purview.SourceGeneratorFramework +Core helpers, models, and MSBuild integration for writing incremental C# source generators with Roslyn. + ## Referencing a generator project Roslyn must receive both a source-generator assembly and its framework runtime dependency as @@ -62,8 +64,6 @@ test target. This framework supports Roslyn 4.13; prefer must also target .NET 8. Do not centrally pin `System.Collections.Immutable` to a newer runtime version merely to make the generator load. -Core helpers, models, and MSBuild integration for writing incremental C# source generators with Roslyn. - ## Installation ```bash @@ -79,7 +79,7 @@ dotnet add package Purview.SourceGeneratorFramework - **`TypeValueObject`**, **`TargetSymbolDescriptor`**, **`EquatableArray`**, **`DiagnosticInfo`** — reusable models for generator inputs and outputs. - **`SymbolResolver`**, **`TypeHelpers`**, **`EmbeddedResources`** — helper classes for common symbol and resource tasks. - **`AttributeDataModelGenerator`** — bundled source generator that emits `readonly record struct` attribute parser models from `[GenerateAttributeDataModel]` declarations, eliminating repetitive `FromAttributeData` boilerplate. Supports manual mapping, auto-discovery, nested models, and inheritance matching. -- **`SourceGeneratorFramework.Analyzers`** — Roslyn analyzers that enforce incremental source generator best practices (prefer `ForAttributeWithMetadataName`, use `IIncrementalGenerator`, avoid `RegisterImplementationSourceOutput`, etc.). +- **Bundled Roslyn analyzers** — `Purview.SourceGeneratorFramework.Analyzers` ships as an analyzer asset inside the `Purview.SourceGeneratorFramework` package and reports diagnostics such as `PSGFR11` (prefer `ForAttributeWithMetadataName`), `PSGFR12` (use `IIncrementalGenerator`), and `PSGFR14` (avoid `RegisterImplementationSourceOutput`). - **MSBuild `.props` / `.targets`** — automatically adds `global using` directives for the main namespaces and supports packaging source generators that reference this framework. ## Usage @@ -137,7 +137,7 @@ public sealed class MyGenerator : IIncrementalGenerator writer.WriteAutoGeneratedHeader(); writer.WriteFileScopedNamespace("MyNamespace"); using ( - writer.WriteClassScope( + writer.WriteClassScope( new TypeDeclarationOptions(name) { Accessibility = TypeDeclarationAccessibility.Public, @@ -268,43 +268,89 @@ The package includes `AttributeDataModelGenerator`, which generates `readonly re ```csharp using Microsoft.CodeAnalysis; -using Purview.SourceGeneratorFramework.Testing.Generators; +using Purview.SourceGeneratorFramework.Generators; using System.ComponentModel.DataAnnotations; namespace MySourceGenerator.Models; [GenerateAttributeDataModel(typeof(ValidationAttribute), MatchByInheritance = true)] public readonly partial record struct ValidationAttributeData( - [AttributeProperty] string? ErrorMessage, - [AttributeProperty] string? ErrorMessageResourceName, - [AttributeProperty] ITypeSymbol? ErrorMessageResourceType + [Property] string? ErrorMessage, + [Property] string? ErrorMessageResourceName, + [Property] ITypeSymbol? ErrorMessageResourceType ); [GenerateAttributeDataModel(typeof(RequiredAttribute))] public readonly partial record struct RequiredAttributeData( - [AttributeProperty] bool AllowEmptyStrings, - [AttributeProperty(Source = AttributePropertySource.NestedModel)] ValidationAttributeData ValidationAttribute + [Property] bool AllowEmptyStrings, + [NestedModel] ValidationAttributeData ValidationAttribute ); ``` -Supported mapping sources: -- `NamedArgument` — reads a named attribute property -- `ConstructorIndex` — reads a constructor argument by position -- `ConstructorName` — reads a constructor argument by parameter name -- `NestedModel` — populates a nested `[GenerateAttributeDataModel]` type +Supported mapping attributes: +- `[Property]` — reads a named attribute property (the property name is inferred from the parameter name unless overridden with `Name = ...`). +- `[Argument]` — reads a constructor argument by parameter name. +- `[Argument(int index)]` — reads a constructor argument by position. +- `[NestedModel]` — populates a nested `[GenerateAttributeDataModel]` type. +- `[GenericTypeArgument]` — reads a generic type argument of the attribute class. You can also target an attribute by fully-qualified name, which is useful when the attribute type is not available in the generator project (e.g., `LengthAttribute` in .NET 8+ or a self-generated attribute): ```csharp [GenerateAttributeDataModel("System.ComponentModel.DataAnnotations.RequiredAttribute")] public readonly partial record struct RequiredAttributeData( - [AttributeProperty] bool AllowEmptyStrings + [Property] bool AllowEmptyStrings +); +``` + +Enable auto-discovery with `[GenerateAttributeDataModel(typeof(MyAttribute), AutoDiscover = true)]` to generate properties for every constructor parameter and public named property. Auto-discovery requires the `Type` overload. Override defaults with `[Property(DefaultValue = ...)]` or `[Argument(DefaultValue = ...)]`, or rely on inferred defaults from optional constructor parameters. + +See [`SourceGeneratorFramework.Generators`](../SourceGeneratorFramework.Generators) for full documentation and additional examples. + +## Generic type identities and references + +`TypeIdentity` distinguishes an open generic definition from a constructed generic type: + +```csharp +var openDictionary = new TypeIdentity(typeof(Dictionary<,>)); + +var stringToIntDictionary = openDictionary.MakeGeneric( + new TypeIdentity(typeof(string)), + new TypeIdentity(typeof(int)) ); + +TypeReference openReference = openDictionary.AsTypeReference(); +TypeReference typedReference = stringToIntDictionary.AsTypeReference(); ``` -Enable auto-discovery with `[GenerateAttributeDataModel(typeof(MyAttribute), AutoDiscover = true)]` to generate properties for every constructor parameter and public named property. Auto-discovery requires the `Type` overload. Override defaults with `[AttributeProperty(DefaultValue = ...)]` or rely on inferred defaults from optional constructor parameters. +Use the open identity when any construction is acceptable. Its `Matches(ITypeSymbol)` method—and +`TypeHelpers.Is`/`IsDerivedFromExpectedBase`—matches symbols such as `Dictionary` and +`Dictionary` by generic definition and arity. Use the constructed identity when the +arguments matter. -See [`SourceGeneratorFramework.Testing.Generators`](../SourceGeneratorFramework.Testing.Generators) for full documentation and additional examples. +Structural equality remains exact: an open identity is not equal to a constructed identity, and +`Dictionary` is not equal to `Dictionary`. Symbol matching is +deliberately asymmetric: an open expected identity can match a constructed symbol. + +String arguments to `MakeGeneric` are literal type names, not wildcards: + +```csharp +// Describes ResourceKitBase, where the argument is literally named TResource. +var resourceKitBase = new TypeIdentity("ResourceKitBase", "Example"); +var parameterized = resourceKitBase.MakeGeneric("TResource"); + +// Describes the open List<> definition and matches any List construction. +var openList = new TypeIdentity(typeof(List<>)); +``` + +For contract-aware comparisons, a constructed expected argument may be an interface or base type. +`TypeHelpers.Is` and the symbol overload of `IsDerivedFromExpectedBase` accept an actual generic +argument that implements or inherits from that expected contract. The syntax-only overload cannot +inspect semantic relationships; it compares only the declared base type name. + +`TypeReference` adds use-site composition—nullable annotations, arrays, pointers, generic +parameters and nested constructions—around a `TypeIdentity`. It preserves the identity's generic +matching behavior, but its modifiers must also match. ## Structured member declarations @@ -637,7 +683,7 @@ are removed when the test run completes. ## Analyzers -The `SourceGeneratorFramework.Analyzers` package ships Roslyn diagnostics that help generator authors follow the incremental-source-generator rules that the framework itself observes: +The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGeneratorFramework.Analyzers` assembly as an analyzer asset. The diagnostics are enabled automatically when you reference `Purview.SourceGeneratorFramework` from a source generator project. | Rule | Summary | |------|---------| @@ -645,8 +691,6 @@ The `SourceGeneratorFramework.Analyzers` package ships Roslyn diagnostics that h | `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | | `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | -Add a reference to the analyzers assembly in your generator project to enable these diagnostics at build time. - ## License This project is licensed under the MIT license. diff --git a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props index 7f7c292..3ad2cb5 100644 --- a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props +++ b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props @@ -18,14 +18,23 @@ Identifies the registered sink for an isolated source-generator logging session. + + - + + + + + diff --git a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets index 6074005..ec6b5e8 100644 --- a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets +++ b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets @@ -2,6 +2,11 @@ <_PurviewSourceGeneratorFrameworkAssembly Condition="'$(_PurviewSourceGeneratorFrameworkAssembly)' == ''" >$(MSBuildThisFileDirectory)..\lib\netstandard2.0\Purview.SourceGeneratorFramework.dll + <_PurviewSourceGeneratorFrameworkSharedAssembly + Condition="'$(_PurviewSourceGeneratorFrameworkSharedAssembly)' == ''" + >$(MSBuildThisFileDirectory)..\lib\netstandard2.0\Purview.SourceGeneratorFramework.Shared.dll + <_PurviewSourceGeneratorFrameworkSharedPdb>$([System.IO.Path]::ChangeExtension('$(_PurviewSourceGeneratorFrameworkSharedAssembly)', '.pdb')) + <_PurviewSourceGeneratorFrameworkSharedXml>$([System.IO.Path]::ChangeExtension('$(_PurviewSourceGeneratorFrameworkSharedAssembly)', '.xml')) + + + + + + + + analyzers/dotnet/cs/ + + + diff --git a/src/src/SourceGeneratorFramework/TypeDeclarationOptions.cs b/src/src/SourceGeneratorFramework/TypeDeclarationOptions.cs deleted file mode 100644 index c038ef6..0000000 --- a/src/src/SourceGeneratorFramework/TypeDeclarationOptions.cs +++ /dev/null @@ -1,331 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework; - -/// -/// Identifies the C# declaration emitted for a generated type. -/// -public enum TypeDeclarationKind -{ - /// A class declaration. - Class, - - /// A struct declaration. - Struct, - - /// A record class declaration. - RecordClass, - - /// A record struct declaration. - RecordStruct, - - /// An interface declaration. - Interface, - - /// An enum declaration. - Enum, - - /// A delegate declaration. - Delegate, -} - -/// -/// Identifies an optional C# accessibility modifier for a generated type. -/// -public enum TypeDeclarationAccessibility -{ - /// The public accessibility modifier. - Public, - - /// The internal accessibility modifier. - Internal, - - /// The protected accessibility modifier. - Protected, - - /// The private accessibility modifier. - Private, - - /// The protected internal accessibility modifier. - ProtectedInternal, - - /// The private protected accessibility modifier. - PrivateProtected, - - /// The file accessibility modifier. - File, -} - -/// -/// Converts Roslyn symbol accessibility values to C# declaration accessibility values. -/// -public static class TypeDeclarationAccessibilityExtensions -{ - /// - /// Converts a Roslyn value to the corresponding - /// value. - /// - /// The Roslyn accessibility value. - /// - /// The corresponding declaration accessibility, or when Roslyn reports - /// or an unknown future value. - /// - /// This method never throws for an accessibility value. - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] - public static TypeDeclarationAccessibility? ToTypeDeclarationAccessibility(this Accessibility accessibility) => - accessibility switch - { - Accessibility.Private => TypeDeclarationAccessibility.Private, - Accessibility.ProtectedAndInternal => TypeDeclarationAccessibility.PrivateProtected, - Accessibility.Protected => TypeDeclarationAccessibility.Protected, - Accessibility.Internal => TypeDeclarationAccessibility.Internal, - Accessibility.ProtectedOrInternal => TypeDeclarationAccessibility.ProtectedInternal, - Accessibility.Public => TypeDeclarationAccessibility.Public, - _ => null, - }; - - /// - /// Converts a declaration accessibility value to the corresponding Roslyn - /// value. - /// - /// The declaration accessibility value. - /// - /// The corresponding Roslyn accessibility, or for - /// or an unknown future value. - /// - /// - /// Roslyn represents file-local accessibility separately from , so - /// has no direct mapping. This method never - /// throws for an accessibility value. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] - public static Accessibility ToRoslynAccessibility(this TypeDeclarationAccessibility accessibility) => - accessibility switch - { - TypeDeclarationAccessibility.Private => Accessibility.Private, - TypeDeclarationAccessibility.PrivateProtected => Accessibility.ProtectedAndInternal, - TypeDeclarationAccessibility.Protected => Accessibility.Protected, - TypeDeclarationAccessibility.Internal => Accessibility.Internal, - TypeDeclarationAccessibility.ProtectedInternal => Accessibility.ProtectedOrInternal, - TypeDeclarationAccessibility.Public => Accessibility.Public, - _ => Accessibility.NotApplicable, - }; -} - -/// -/// Describes one generic type parameter and its optional C# constraints. -/// -public sealed record GenericTypeParameterOptions -{ - /// - /// Initializes a generic type parameter description. - /// - /// The type parameter name. - public GenericTypeParameterOptions(string name) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Type parameter name cannot be null or whitespace.", nameof(name)); - - Name = name; - } - - /// - /// Gets the type parameter name. - /// - public string Name { get; } - - /// - /// Gets the ordered constraint expressions written after where T :. - /// - /// - /// Entries are emitted verbatim and may contain values such as class, notnull, - /// a base type, an interface, or new(). - /// - public ImmutableArray Constraints { get; init; } = []; - - public static implicit operator GenericTypeParameterOptions(string name) => new(name); -} - -/// Describes a field in a generated enum declaration. -public readonly record struct EnumFieldDeclarationOptions -{ - /// Initializes an enum field declaration. - /// The enum field name. - /// - /// The enum field value. Strings are emitted as C# expressions; other values are - /// formatted using the invariant culture. - /// - /// The lines written in the field's XML summary block. - public EnumFieldDeclarationOptions(string fieldName, object fieldValue, params string[] xmlSummary) - : this(fieldName, xmlSummary) - { - if (fieldValue is null) - throw new ArgumentNullException(nameof(fieldValue)); - - FieldValue = fieldValue; - } - - /// Initializes an enum field declaration. - /// The enum field name. - /// The lines written in the field's XML summary block. - public EnumFieldDeclarationOptions(string fieldName, params string[] xmlSummary) - { - if (string.IsNullOrWhiteSpace(fieldName)) - throw new ArgumentException("Enum field name cannot be null or whitespace.", nameof(fieldName)); - - FieldName = fieldName; - XmlSummary = [.. xmlSummary ?? []]; - } - - /// Gets the enum field name. - public string FieldName { get; } - - /// - /// Gets the optional enum field value. Strings are treated as C# expressions rather than - /// string literals. - /// - public object? FieldValue { get; } - - /// Gets the lines written in the field's XML summary block. - public ImmutableArray XmlSummary { get; init; } = []; - - /// Gets the attributes applied to the enum field. - public ImmutableArray Attributes { get; init; } = []; -} - -/// -/// Describes a generated class, struct, record, interface, enum, or delegate declaration. -/// -public sealed record TypeDeclarationOptions -{ - /// - /// Initializes a type declaration description. - /// - /// The generated type name without generic parameters. - /// The optional accessibility modifier, or to omit accessibility. - public TypeDeclarationOptions(string name, TypeDeclarationAccessibility? accessibility = null) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Type name cannot be null or whitespace.", nameof(name)); - - Name = name; - Accessibility = accessibility; - } - - /// - /// Initializes a type declaration description from a . - /// - /// The type value object. - /// The optional accessibility modifier, or to omit accessibility. - public TypeDeclarationOptions(TypeValueObject typeValue, TypeDeclarationAccessibility? accessibility = null) - { - Name = typeValue.TypeName; - Accessibility = accessibility; - } - - /// - /// Gets the generated type name without generic parameters. - /// - public string Name { get; } - - /// - /// Gets the declaration kind. The default is . - /// - public TypeDeclarationKind Kind { get; init; } = TypeDeclarationKind.Class; - - /// - /// Gets the accessibility modifier, or to omit accessibility. - /// - public TypeDeclarationAccessibility? Accessibility { get; init; } - - /// - /// Gets whether the partial modifier is emitted. The default is . - /// - public bool IsPartial { get; init; } = true; - - /// - /// Gets whether the sealed modifier is emitted for a class or record class. - /// The default is . This option is ignored for struct declarations. - /// - public bool IsSealed { get; init; } = true; - - /// - /// Gets whether the abstract modifier is emitted for a class or record class. - /// - /// - /// Abstract declarations take precedence over the default value. - /// - public bool IsAbstract { get; init; } - - /// - /// Gets whether the static modifier is emitted for a class declaration. - /// - /// - /// Static classes cannot declare a base type, implement interfaces, or declare - /// primary-constructor parameters. is ignored when this value is - /// . - /// - public bool IsStatic { get; init; } - - /// - /// Gets whether the readonly modifier is emitted for a struct or record struct. - /// - public bool IsReadOnly { get; init; } - - /// - /// Gets the optional base class or base record type. - /// - /// Struct and record struct declarations cannot specify a base type. - public TypeReferenceOptions? BaseType { get; init; } - - /// Gets the optional enum underlying integral type. - public TypeReferenceOptions? EnumUnderlyingType { get; init; } - - /// Gets the delegate return type. - public TypeReferenceOptions? DelegateReturnType { get; init; } - - /// Gets the complete delegate parameter declarations. - public ImmutableArray DelegateParameters { get; init; } = []; - - /// - /// Gets the interfaces implemented by the generated type, or inherited by an interface. - /// - public ImmutableArray Interfaces { get; init; } = []; - - /// - /// Gets the generic type parameters and their constraints. - /// - public ImmutableArray GenericTypes { get; init; } = []; - - /// - /// Gets the primary-constructor parameters written after the type name and generic parameters. - /// - /// Each entry is emitted verbatim as a complete parameter declaration. - public ImmutableArray PrimaryConstructorParameters { get; init; } = []; - - /// - /// If , the primary-constructor parameters are emitted on separate lines with one parameter per line. - /// - public bool ConstructorParametersOnSeparateLines { get; init; } - - /// - /// Gets the attributes applied to the generated type. - /// - public ImmutableArray Attributes { get; init; } = []; - - /// - /// Gets whether to emit on the type. - /// When , WriteAttributeClass enables it and other type-writing - /// APIs leave it disabled. Set this explicitly to to opt a generated - /// attribute out of embedding. - /// - public bool? IncludeEmbeddedAttribute { get; init; } - - /// - /// Gets whether to process any generated attributes, such as and . - /// is ignored if this is . - /// When , the value is inherited from . - /// - public bool? IncludeGeneratedAttributes { get; init; } -} diff --git a/src/src/SourceGeneratorFramework/TypeReferenceOptions.cs b/src/src/SourceGeneratorFramework/TypeReferenceOptions.cs deleted file mode 100644 index 0579ad0..0000000 --- a/src/src/SourceGeneratorFramework/TypeReferenceOptions.cs +++ /dev/null @@ -1,245 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework; - -/// -/// Describes C# type syntax without requiring callers to assemble nullable or generic text. -/// This is the canonical type representation for source-generation pipelines. -/// -public readonly record struct TypeReferenceOptions -{ - /// Creates a type reference from a required framework type value. - public TypeReferenceOptions(TypeValueObject type) - { - if (type == TypeValueObject.Empty) - { - this = Empty; - return; - } - - var name = RenderBaseTypeName(type); - Name = string.IsNullOrWhiteSpace(name) - ? throw new ArgumentException("The type value must provide a non-empty rendered name.", nameof(type)) - : name; - TypeValue = type; - GenericArguments = type.TypeArguments.IsDefaultOrEmpty - ? [] - : [.. type.TypeArguments.Select(static argument => new TypeReferenceOptions(argument))]; - GenericArity = GenericArguments.IsDefaultOrEmpty ? type.GenericArity : 0; - } - - /// Creates a type reference from a runtime type. - public TypeReferenceOptions(Type type) - { - if (type is null) - throw new ArgumentNullException(nameof(type)); - - this = CreateFromRuntimeType(type); - } - - /// Creates a type reference from a Roslyn symbol. - public TypeReferenceOptions(ITypeSymbol type) - { - if (type is null) - throw new ArgumentNullException(nameof(type)); - - this = CreateFromSymbol(type); - } - - /// Gets the named type, predefined keyword, tuple element list, or generic parameter. - public string Name { get; } - - /// - /// Gets the fully qualified type name without generic arguments, array ranks, or pointer/nullable suffixes. - /// - /// This is a direct call through to . - public string TypeName => TypeValue.TypeName; - - /// - /// Gets the required semantic type value represented by this reference. - /// - /// - /// is used only by . - /// - public TypeValueObject TypeValue { get; private init; } - - /// Gets whether this value represents the absence of a type reference. - public bool IsEmpty => this == Empty; - - /// Gets generic type arguments. - public ImmutableArray GenericArguments { get; init; } - - /// Gets the generic arity for an open generic definition. - public int GenericArity { get; init; } - - /// Gets array ranks, one entry per jagged-array layer; rank one represents []. - public ImmutableArray ArrayRanks { get; init; } - - /// Gets whether a nullable annotation is appended to the complete type. - public bool IsNullable { get; init; } - - /// Gets whether a pointer suffix is appended. - public bool IsPointer { get; init; } - - /// Returns this type with a nullable annotation. - public TypeReferenceOptions Nullable() => this with { IsNullable = true }; - - /// Returns this type without a nullable annotation. - public TypeReferenceOptions NonNullable() => this with { IsNullable = false }; - - /// Returns this type with concrete generic arguments. - public TypeReferenceOptions MakeGeneric(params TypeReferenceOptions[] arguments) => - this with - { - GenericArguments = [.. arguments], - GenericArity = 0, - }; - - /// Returns this type as an array of the specified rank. - public TypeReferenceOptions MakeArray(int rank = 1) - { - return rank < 1 - ? throw new ArgumentOutOfRangeException(nameof(rank)) - : (this with { ArrayRanks = ArrayRanks.IsDefault ? [rank] : [.. ArrayRanks, rank] }); - } - - /// Returns this type as a pointer type. - public TypeReferenceOptions MakePointer() => this with { IsPointer = true }; - - /// - /// Determines whether this unmodified reference represents the specified semantic type value. - /// - public bool Equals(TypeValueObject other) => - !IsEmpty - && !IsNullable - && !IsPointer - && ArrayRanks.IsDefaultOrEmpty - && GenericArguments.IsDefaultOrEmpty - && GenericArity == 0 - && TypeValue.Equals(other); - - /// Determines whether the specified runtime type has the same complete reference shape. - public bool Equals(Type? other) => - other is not null && RenderTypeName == new TypeReferenceOptions(other).RenderTypeName; - - /// Determines whether the specified Roslyn symbol has the same complete reference shape. - public bool Equals(ITypeSymbol? other) => - other is not null && RenderTypeName == new TypeReferenceOptions(other).RenderTypeName; - - /// Gets the type rendered as valid C# type syntax. - public string RenderTypeName - { - get - { - if (IsEmpty) - return string.Empty; - - StringBuilder builder = new(Name); - if (!GenericArguments.IsDefaultOrEmpty) - { - builder.Append('<'); - for (var index = 0; index < GenericArguments.Length; index++) - { - if (index > 0) - builder.Append(", "); - builder.Append(GenericArguments[index].RenderTypeName); - } - builder.Append('>'); - } - else if (GenericArity > 0) - { - builder.Append('<').Append(',', GenericArity - 1).Append('>'); - } - - for (var index = 0; !ArrayRanks.IsDefaultOrEmpty && index < ArrayRanks.Length; index++) - builder.Append('[').Append(',', ArrayRanks[index] - 1).Append(']'); - - if (IsPointer) - builder.Append('*'); - if (IsNullable) - builder.Append('?'); - - return builder.ToString(); - } - } - - /// Gets this type rendered as C# attribute syntax without the optional Attribute suffix. - public string RenderAttributeName - { - get - { - var rendered = RenderTypeName; - var genericStart = rendered.IndexOf('<'); - var baseName = genericStart < 0 ? rendered : rendered.Substring(0, genericStart); - var suffix = "Attribute"; - if (!baseName.EndsWith(suffix, StringComparison.Ordinal)) - return rendered; - - baseName = baseName.Substring(0, baseName.Length - suffix.Length); - return genericStart < 0 ? baseName : baseName + rendered.Substring(genericStart); - } - } - - /// Returns the type rendered as valid C# type syntax. - public override string ToString() => RenderTypeName; - - public static implicit operator TypeReferenceOptions(TypeValueObject type) => - type == TypeValueObject.Empty ? Empty : new(type); - - public static implicit operator TypeReferenceOptions?(Type? type) => type == null ? null : new(type); - - public static implicit operator TypeReferenceOptions(Type type) => new(type); - - /// Implicitly converts a structured type reference to rendered C# type syntax. - public static implicit operator string(TypeReferenceOptions type) => type.RenderTypeName; - - /// - /// Represents the absence of a type reference. Code renderers and emitters ignore this value. - /// - public static readonly TypeReferenceOptions Empty; - - static TypeReferenceOptions CreateFromRuntimeType(Type type) - { - if (type.IsByRef) - return CreateFromRuntimeType(type.GetElementType()); - - if (type.IsArray) - return CreateFromRuntimeType(type.GetElementType()).MakeArray(type.GetArrayRank()); - - if (type.IsPointer) - return CreateFromRuntimeType(type.GetElementType()).MakePointer(); - - // Handle generic type definitions and constructed generic types - return new(new TypeValueObject(type)); - } - - static TypeReferenceOptions CreateFromSymbol(ITypeSymbol type) - { - if (type is IArrayTypeSymbol array) - { - var arrayReference = CreateFromSymbol(array.ElementType).MakeArray(array.Rank); - return type.NullableAnnotation == NullableAnnotation.Annotated ? arrayReference.Nullable() : arrayReference; - } - - if (type is IPointerTypeSymbol pointer) - return CreateFromSymbol(pointer.PointedAtType).MakePointer(); - - var reference = new TypeReferenceOptions(new TypeValueObject(type)); - if (type is INamedTypeSymbol named && named.IsGenericType) - { - reference = reference with - { - GenericArguments = [.. named.TypeArguments.Select(CreateFromSymbol)], - GenericArity = named.IsUnboundGenericType ? named.Arity : 0, - }; - } - return type.NullableAnnotation == NullableAnnotation.Annotated ? reference.Nullable() : reference; - } - - static string RenderBaseTypeName(TypeValueObject type) => - type.SpecialType != SpecialType.None ? type.Keyword! - : type.IsGlobalNamespace ? type.TypeName - : $"global::{type.Namespace}.{type.TypeName}"; -} diff --git a/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs b/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs new file mode 100644 index 0000000..7093352 --- /dev/null +++ b/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs @@ -0,0 +1,22 @@ +namespace Purview.SourceGeneratorFramework; + +/// Describes one positional or named attribute argument. +public readonly record struct AttributeArgumentOptions +{ + /// Creates a positional attribute argument. + public AttributeArgumentOptions(string value, string? name = null, bool isPropertyAssignment = false) => + (Value, Name, IsPropertyAssignment) = (value, name, isPropertyAssignment); + + /// Creates a positional Boolean attribute argument using a valid C# literal. + public AttributeArgumentOptions(bool value, string? name = null, bool isPropertyAssignment = false) => + (Value, Name, IsPropertyAssignment) = (value ? "true" : "false", name, isPropertyAssignment); + + /// Gets the argument expression. + public string Value { get; } + + /// Gets an optional constructor parameter or property name. + public string? Name { get; init; } + + /// Gets whether a named argument uses property assignment (=) instead of constructor naming (:). + public bool IsPropertyAssignment { get; init; } +} diff --git a/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs b/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs new file mode 100644 index 0000000..e387c3f --- /dev/null +++ b/src/src/SourceGeneratorShared/AttributeDataModelDiagnosticRules.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Provides diagnostic descriptors shared between the source generator and the analyzer for the +/// attribute-data model feature. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "MicrosoftCodeAnalysisReleaseTracking", + "RS2008:Enable analyzer release tracking", + Justification = "The descriptor is shared between the source generator and the analyzer project; release tracking is maintained in the consuming analyzer project." +)] +public static class AttributeDataModelDiagnosticRules +{ + /// + /// Diagnostic raised when an attribute-data model property is declared with a non-cacheable + /// or type. + /// + public static readonly DiagnosticDescriptor SymbolPropertyNotCacheable = new( + "ADM0010", + "Attribute data model property type is not cacheable", + "Property '{0}' type '{1}' is not cacheable for attribute data extraction. Use Purview.SourceGeneratorFramework.TypeIdentity or a string/string? type to capture type identity in a cacheable form.", + "Property", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs b/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs new file mode 100644 index 0000000..db6cdf5 --- /dev/null +++ b/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs @@ -0,0 +1,23 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// Describes an attribute applied to a generated declaration. +public readonly record struct AttributeDeclarationOptions +{ + /// Creates an attribute declaration from a structured type reference. + public AttributeDeclarationOptions(TypeReference reference) => Reference = reference; + + /// Creates an attribute declaration from a structured type value. + public AttributeDeclarationOptions(TypeIdentity type) + : this(type.AsTypeReference()) { } + + /// Gets the structured attribute type. + public TypeReference Reference { get; } + + /// Gets an optional target such as return, field, or property. + public string? Target { get; init; } + + /// Gets structured attribute arguments. + public ImmutableArray Arguments { get; init; } +} diff --git a/src/src/SourceGeneratorShared/CodeWriter.Types.cs b/src/src/SourceGeneratorShared/CodeWriter.Types.cs new file mode 100644 index 0000000..209595e --- /dev/null +++ b/src/src/SourceGeneratorShared/CodeWriter.Types.cs @@ -0,0 +1,126 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +partial class CodeWriter +{ + enum WrittenItemKind + { + None, + Field, + Property, + Constructor, + Method, + Type, + Namespace, + } + + /// + /// Restores warning pragmas when disposed. + /// + [SuppressMessage( + "Performance", + "CA1815:Override equals and operator equals on value types", + Justification = "This type is a mutable lifetime token and has no meaningful value equality." + )] + public struct PragmaScope(CodeWriter writer, string[] pragmas) : IDisposable + { + CodeWriter? _writer = writer; + readonly string[] _pragmas = pragmas; + + /// + /// Restores the disabled pragmas once. + /// + public void Dispose() + { + var writer = _writer; + if (writer is null) + return; + + _writer = null; + writer.RestorePragmas(_pragmas); + } + } + + void RestorePragmas(string[] pragmas) + { + if (pragmas.Length == 0) + return; + + NewLine(); + foreach (var pragma in pragmas) + Write("#pragma warning restore ").WriteLine(pragma); + } + + /// + /// Restores a writer's indentation when disposed. + /// + [SuppressMessage( + "Performance", + "CA1815:Override equals and operator equals on value types", + Justification = "This type is a mutable lifetime token and has no meaningful value equality." + )] + public struct IndentScope(CodeWriter writer, int scopeId) : IDisposable + { + CodeWriter? _writer = writer; + + /// + /// Restores the indentation level once. + /// + public void Dispose() + { + var writer = _writer; + if (writer is null) + return; + + _writer = null; + writer.CloseIndentScope(scopeId); + } + } + + /// + /// Restores indentation and writes a block's closing token when disposed. + /// + [SuppressMessage( + "Performance", + "CA1815:Override equals and operator equals on value types", + Justification = "This type is a mutable lifetime token and has no meaningful value equality." + )] + public struct BlockScope : IDisposable + { + CodeWriter? _writer; + readonly string? _closingSeparator; + readonly int _scopeId; + readonly int _completedItem; + readonly int _itemIndent; + + internal BlockScope(CodeWriter writer, string? closingSeparator, int scopeId, int completedItem, int itemIndent) + { + _writer = writer; + _closingSeparator = closingSeparator; + _scopeId = scopeId; + _completedItem = completedItem; + _itemIndent = itemIndent; + } + + /// + /// Closes the block once. + /// + public void Dispose() + { + var writer = _writer; + if (writer is null) + return; + + _writer = null; + writer.CloseBlock(_closingSeparator, _scopeId, _completedItem, _itemIndent); + } + } + + sealed record class NoOpScope : IDisposable + { + public static NoOpScope Instance { get; } = new(); + + public void Dispose() { } + } +} diff --git a/src/src/SourceGeneratorFramework/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs similarity index 92% rename from src/src/SourceGeneratorFramework/CodeWriter.cs rename to src/src/SourceGeneratorShared/CodeWriter.cs index 7abf773..59dbeaa 100644 --- a/src/src/SourceGeneratorFramework/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -14,27 +14,14 @@ namespace Purview.SourceGeneratorFramework; /// Instances are not thread-safe and must not be shared between concurrent generator operations. /// [SuppressMessage("Design", "CA1034:Nested types should not be visible")] -public sealed class CodeWriter +public sealed partial class CodeWriter { - enum WrittenItemKind - { - None, - Field, - Property, - Constructor, - Method, - Type, - Namespace, - } - const char IndentCharacter = '\t'; const char NewLineCharacter = '\n'; const int DefaultCapacity = 4096; const int IndentDisplayWidth = 4; const int DefaultMaximumLineLength = 100; - readonly StringBuilder _builder; - readonly Dictionary? _openScopes; int _indentLevel; int _nextScopeId; int _lastWrittenItemIndent = -1; @@ -42,11 +29,13 @@ enum WrittenItemKind WrittenItemKind _lastWrittenItem; bool _atLineStart = true; + readonly StringBuilder _builder; + readonly Dictionary? _openScopes; + /// /// Initializes a new writer with required generator identity. /// - /// The source generator name used by headers and attributes. - /// The source generator version used by headers and attributes. + /// The generation settings containing the generator name and version used during code writing. /// /// The initial number of characters that the internal buffer can contain without growing. /// @@ -59,26 +48,25 @@ enum WrittenItemKind /// is less than zero. /// /// - /// or is empty or whitespace. + /// is null or contains invalid values. /// public CodeWriter( - string generatorName, - string generatorVersion, + GenerationSettings settings, int initialCapacity = DefaultCapacity, bool throwOnUnclosedScopes = true ) { + if (settings is null) + throw new ArgumentNullException(nameof(settings)); if (initialCapacity < 0) throw new ArgumentOutOfRangeException(nameof(initialCapacity)); - _builder = new StringBuilder(initialCapacity); - GeneratorName = - NormalizeOptionalIdentity(generatorName, nameof(generatorName)) - ?? throw new ArgumentException("Generator name cannot be null or whitespace.", nameof(generatorName)); - GeneratorVersion = - NormalizeOptionalIdentity(generatorVersion, nameof(generatorVersion)) - ?? throw new ArgumentException("Generator version cannot be null or whitespace.", nameof(generatorVersion)); + _builder = new(initialCapacity); + + GeneratorName = settings.GeneratorName; + GeneratorVersion = settings.GeneratorVersion; ThrowOnUnclosedScopes = throwOnUnclosedScopes; + if (throwOnUnclosedScopes) _openScopes = []; } @@ -814,7 +802,7 @@ public BlockScope WriteBlockNamespaceScope(string? namespaceName) /// The action that writes the namespace body. /// The current writer. /// writer.WriteBlockNamespace(new TypeValueObject("C", "Example").AsTypeReference(), body => body.WriteLine("class C { }")); - public CodeWriter WriteBlockNamespace(TypeReferenceOptions typeReference, Action bodyWriter) + public CodeWriter WriteBlockNamespace(TypeReference typeReference, Action bodyWriter) { if (bodyWriter is null) throw new ArgumentNullException(nameof(bodyWriter)); @@ -831,8 +819,8 @@ public CodeWriter WriteBlockNamespace(TypeReferenceOptions typeReference, Action /// The type reference whose namespace will be used, or a value with no namespace to return an empty scope. /// The namespace body scope, or an empty scope when no namespace is supplied. /// using (writer.WriteBlockNamespaceScope(new TypeValueObject("C", "Example").AsTypeReference())) writer.WriteLine("class C { }"); - public BlockScope WriteBlockNamespaceScope(TypeReferenceOptions typeReference) => - WriteBlockNamespaceScope(typeReference.TypeValue.Namespace); + public IDisposable WriteBlockNamespaceScope(TypeReference? typeReference) => + typeReference is null ? NoOpScope.Instance : WriteBlockNamespaceScope(typeReference.Identity.Namespace); /// Writes a block-scoped namespace and invokes a callback for its body. /// The namespace, or to omit the wrapper. @@ -867,8 +855,8 @@ public CodeWriter WriteFileScopedNamespace(string? namespaceName) /// The type reference whose namespace will be used, or a value with no namespace to write nothing. /// The current writer. /// writer.WriteFileScopedNamespace(new TypeValueObject("C", "Example").AsTypeReference()); - public CodeWriter WriteFileScopedNamespace(TypeReferenceOptions typeReference) => - WriteFileScopedNamespace(typeReference.TypeValue.Namespace); + public CodeWriter WriteFileScopedNamespace(TypeReference? typeReference) => + typeReference is null ? this : WriteFileScopedNamespace(typeReference.Identity.Namespace); /// /// Writes a class declaration from structured options and returns its body scope. @@ -930,7 +918,7 @@ public CodeWriter WriteAttributeClass( if (targets == 0 || (targets & ~AttributeTargets.All) != 0) throw new ArgumentOutOfRangeException(nameof(targets), targets, "Invalid attribute targets."); - AttributeDeclarationOptions attributeUsage = new(new TypeValueObject("AttributeUsageAttribute", "System")) + AttributeDeclarationOptions attributeUsage = new(new TypeIdentity("AttributeUsageAttribute", "System")) { Arguments = [ @@ -943,7 +931,7 @@ public CodeWriter WriteAttributeClass( return WriteClass( declaration with { - BaseType = declaration.BaseType ?? new TypeValueObject("Attribute", "System"), + BaseType = declaration.BaseType ?? new TypeIdentity("Attribute", "System"), Attributes = declaration.Attributes.Insert(0, attributeUsage), IncludeEmbeddedAttribute = declaration.IncludeEmbeddedAttribute ?? true, }, @@ -1218,7 +1206,7 @@ or TypeDeclarationKind.RecordClass Write("partial "); if (declaration.Kind == TypeDeclarationKind.Delegate) - Write("delegate ").WriteTypeReference(declaration.DelegateReturnType!.Value).Write(' '); + Write("delegate ").WriteTypeReference(declaration.DelegateReturnType!).Write(' '); Write( declaration.Kind switch @@ -1245,7 +1233,7 @@ or TypeDeclarationKind.RecordClass ); WriteBaseTypes(declaration); if (declaration.Kind == TypeDeclarationKind.Enum && declaration.EnumUnderlyingType is { IsEmpty: false }) - Write(" : ").WriteTypeReference(declaration.EnumUnderlyingType.Value); + Write(" : ").WriteTypeReference(declaration.EnumUnderlyingType!); if (declaration.Kind == TypeDeclarationKind.Delegate) { @@ -1272,8 +1260,10 @@ public CodeWriter WriteType(TypeDeclarationOptions declaration, Action arguments, string? receiver = null, - IEnumerable? genericArguments = null, + IEnumerable? genericArguments = null, bool writeArgumentsOnSeparateLines = false ) => WriteMethodCall( @@ -1542,7 +1532,7 @@ public CodeWriter WriteAwaitedMethodCall( string methodName, IEnumerable arguments, string? receiver = null, - IEnumerable? genericArguments = null, + IEnumerable? genericArguments = null, bool writeArgumentsOnSeparateLines = false ) => WriteMethodCallCore( @@ -1568,7 +1558,7 @@ public CodeWriter WriteMethodCall( string methodName, IEnumerable arguments, string? receiver = null, - IEnumerable? genericArguments = null, + IEnumerable? genericArguments = null, bool writeArgumentsOnSeparateLines = false ) => WriteMethodCallCore(methodName, arguments, receiver, genericArguments, writeArgumentsOnSeparateLines, false); @@ -1576,7 +1566,7 @@ CodeWriter WriteMethodCallCore( string methodName, IEnumerable arguments, string? receiver, - IEnumerable? genericArguments, + IEnumerable? genericArguments, bool writeArgumentsOnSeparateLines, bool isAwaited ) @@ -1728,7 +1718,7 @@ public CodeWriter WriteAssignment(string type, string name, ObjectCreationOption bool WriteObjectCreationExpression(ObjectCreationOptions value, bool forceNotNull) { - Write("new ").WriteTypeReference(value.Type).Write('('); + Write("new ").WriteTypeReference(value.Reference).Write('('); string[] arguments = value.Arguments.IsDefault ? [] : [.. value.Arguments.Select(RenderCallArgument)]; if (WriteMethodCallArguments(arguments, value.WriteArgumentsOnSeparateLines, forceNotNull ? ")!;" : ");")) return true; @@ -1772,12 +1762,12 @@ public CodeWriter WriteThrow(string expression) /// Writes a throw statement. /// writer.WriteThrow("new InvalidOperationException()"); - public CodeWriter WriteThrow(TypeReferenceOptions exceptionType, string? message = null) + public CodeWriter WriteThrow(TypeReference exceptionType, string? message = null) { - if (exceptionType.IsEmpty) - throw new ArgumentException("Exception type cannot be empty.", nameof(exceptionType)); + if (exceptionType.IsNullOrEmpty()) + throw new ArgumentException("Exception type cannot be null or empty.", nameof(exceptionType)); - Write("throw "); + Write("throw new "); WriteExpression( $"{exceptionType}{(message is null ? string.Empty : $"(\"{message}\")")}", expressionWriter: null @@ -1994,7 +1984,7 @@ void WriteExpression(string? expression, Action? expressionWriter) var callback = expressionWriter; if (callback is not null) { - var expressionWriterBuffer = new CodeWriter(GeneratorName, GeneratorVersion) + CodeWriter expressionWriterBuffer = new(new GenerationSettings(GeneratorName, GeneratorVersion)) { DefaultIncludeGeneratedAttributes = DefaultIncludeGeneratedAttributes, }; @@ -2107,7 +2097,7 @@ static int GetParameterLength(ParameterDeclarationOptions parameter) return length; } - static TypeReferenceOptions GetParameterType(ParameterDeclarationOptions parameter) => parameter.Type; + static TypeReference GetParameterType(ParameterDeclarationOptions parameter) => parameter.Reference; static string RenderCallArgument(MethodCallArgumentOptions argument) { @@ -2142,7 +2132,7 @@ CodeWriter WriteAttribute(AttributeDeclarationOptions attribute, string? default var target = attribute.Target ?? defaultTarget; if (target is not null) Write(target).Write(": "); - Write(attribute.Type.RenderAttributeName); + Write(attribute.Reference.RenderAttributeName); if (!attribute.Arguments.IsDefaultOrEmpty) { Write('('); @@ -2162,7 +2152,7 @@ CodeWriter WriteAttribute(AttributeDeclarationOptions attribute, string? default static int GetAttributeLength(AttributeDeclarationOptions attribute) { - var length = attribute.Type.RenderAttributeName.Length + 2; + var length = attribute.Reference.RenderAttributeName.Length + 2; if (attribute.Target is not null) length += attribute.Target.Length + 2; if (attribute.Arguments.IsDefaultOrEmpty) @@ -2413,7 +2403,7 @@ void WriteBaseTypes(TypeDeclarationOptions declaration) Write(" : "); if (hasBaseType) - WriteTypeReference(declaration.BaseType!.Value); + WriteTypeReference(declaration.BaseType!); if (!hasInterfaces) return; @@ -2431,7 +2421,7 @@ void WriteBaseTypes(TypeDeclarationOptions declaration) } } - static bool HasNonEmptyTypeReferences(ImmutableArray types) + static bool HasNonEmptyTypeReferences(ImmutableArray types) { for (var index = 0; !types.IsDefaultOrEmpty && index < types.Length; index++) { @@ -2585,14 +2575,14 @@ declaration.Kind is TypeDeclarationKind.Enum or TypeDeclarationKind.Delegate if ( declaration.Kind == TypeDeclarationKind.Enum && declaration.EnumUnderlyingType is not null - && string.IsNullOrWhiteSpace(declaration.EnumUnderlyingType.Value.Name) + && string.IsNullOrWhiteSpace(declaration.EnumUnderlyingType.Identity.Name) ) throw new ArgumentException("Enum underlying type cannot be whitespace.", nameof(declaration)); if (declaration.Kind == TypeDeclarationKind.Delegate) { if (declaration.DelegateReturnType is null) throw new ArgumentException("Delegate return type is required.", nameof(declaration)); - ValidateTypeReference(declaration.DelegateReturnType.Value, nameof(declaration)); + ValidateTypeReference(declaration.DelegateReturnType, nameof(declaration)); ValidateParameters( declaration.DelegateParameters, "Delegate parameters cannot contain null or whitespace values.", @@ -2755,17 +2745,6 @@ static void ValidateRequired(string? value, string description, string parameter throw new ArgumentException($"{description} cannot be null or whitespace.", parameterName); } - static string? NormalizeOptionalIdentity(string? value, string parameterName) - { - if (value is null) - return null; - if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("Generator identity values cannot be empty or whitespace.", parameterName); - - // Normalize the value to a consistent form for comparison and storage. - return value; - } - static void ValidateParameters( ImmutableArray parameters, string message, @@ -2779,79 +2758,70 @@ string parameterName { if ( string.IsNullOrWhiteSpace(parameters[index].Name) - || string.IsNullOrWhiteSpace(parameters[index].Type.Name) + || string.IsNullOrWhiteSpace(parameters[index].Reference.Identity.Name) ) throw new ArgumentException(message, parameterName); else { - ValidateTypeReference(parameters[index].Type, parameterName); + ValidateTypeReference(parameters[index].Reference, parameterName); ValidateAttributes(parameters[index].Attributes, parameterName); } } } - CodeWriter WriteTypeReference(TypeReferenceOptions type) + CodeWriter WriteTypeReference(TypeReference reference) { - if (type.IsEmpty) + if (reference.IsEmpty) return this; - ValidateTypeReference(type, nameof(type)); - Write(type.Name); - if (!type.GenericArguments.IsDefaultOrEmpty) - { - Write('<'); - for (var index = 0; index < type.GenericArguments.Length; index++) - { - if (index != 0) - Write(", "); - WriteTypeReference(type.GenericArguments[index]); - } - Write('>'); - } - else if (type.GenericArity > 0) - Write('<').Write(new string(',', type.GenericArity - 1)).Write('>'); - for (var index = 0; !type.ArrayRanks.IsDefaultOrEmpty && index < type.ArrayRanks.Length; index++) - Write('[').Write(new string(',', type.ArrayRanks[index] - 1)).Write(']'); - WriteIf(type.IsPointer, "*").WriteIf(type.IsNullable, "?"); + ValidateTypeReference(reference, nameof(reference)); + + Write(reference.RenderFullName); + + //Write(type.Name); + //if (!type.TypeArguments.IsDefaultOrEmpty) + //{ + // Write('<'); + // for (var index = 0; index < type.TypeArguments.Length; index++) + // { + // if (index != 0) + // Write(", "); + // WriteTypeReference(type.TypeArguments[index]); + // } + // Write('>'); + //} + //else if (type.GenericArity > 0) + // Write('<').Write(new string(',', type.GenericArity - 1)).Write('>'); + + //for (var index = 0; !reference.ArrayRanks.IsDefaultOrEmpty && index < reference.ArrayRanks.Length; index++) + // Write('[').Write(new string(',', reference.ArrayRanks[index] - 1)).Write(']'); + + //WriteIf(reference.IsPointer, "*").WriteIf(reference.IsNullable, "?"); return this; } - static int GetTypeReferenceLength(TypeReferenceOptions type) - { - if (type.IsEmpty) - return 0; - var length = type.Name.Length + (type.IsNullable ? 1 : 0) + (type.IsPointer ? 1 : 0); - if (!type.GenericArguments.IsDefaultOrEmpty) - { - length += 2; - for (var index = 0; index < type.GenericArguments.Length; index++) - length += GetTypeReferenceLength(type.GenericArguments[index]) + (index == 0 ? 0 : 2); - } - else if (type.GenericArity > 0) - length += type.GenericArity + 1; - for (var index = 0; !type.ArrayRanks.IsDefaultOrEmpty && index < type.ArrayRanks.Length; index++) - length += type.ArrayRanks[index] + 1; - return length; - } + static int GetTypeReferenceLength(TypeReference type) => type.IsEmpty ? 0 : type.RenderFullName.Length; - static void ValidateTypeReference(TypeReferenceOptions type, string parameterName) + static void ValidateTypeReference(TypeReference reference, string parameterName) { - if (type.IsEmpty) + if (reference.IsEmpty) return; + + var type = reference.Identity; if (string.IsNullOrWhiteSpace(type.Name)) throw new ArgumentException("Type name cannot be null or whitespace.", parameterName); if (type.GenericArity < 0) throw new ArgumentException("Generic arity cannot be negative.", parameterName); - if (type.GenericArity != 0 && !type.GenericArguments.IsDefaultOrEmpty) + if ( + !type.TypeArguments.IsDefaultOrEmpty + && (type.GenericArity == 0 || type.TypeArguments.Length != type.GenericArity) + ) throw new ArgumentException( - "A type cannot have both open generic arity and concrete generic arguments.", + "A constructed generic type must have one type argument for each declared generic parameter.", parameterName ); - for (var index = 0; !type.GenericArguments.IsDefaultOrEmpty && index < type.GenericArguments.Length; index++) - ValidateTypeReference(type.GenericArguments[index], parameterName); - for (var index = 0; !type.ArrayRanks.IsDefaultOrEmpty && index < type.ArrayRanks.Length; index++) - if (type.ArrayRanks[index] < 1) - throw new ArgumentException("Array ranks must be positive.", parameterName); + for (var index = 0; !type.TypeArguments.IsDefaultOrEmpty && index < type.TypeArguments.Length; index++) + ValidateTypeReference(type.TypeArguments[index], parameterName); } static void ValidateAttributes(ImmutableArray attributes, string parameterName) @@ -2859,7 +2829,7 @@ static void ValidateAttributes(ImmutableArray attri for (var index = 0; !attributes.IsDefaultOrEmpty && index < attributes.Length; index++) { var attribute = attributes[index]; - if (attribute.Type.IsEmpty) + if (attribute.Reference.IsEmpty) throw new ArgumentException("Attribute type names cannot be null or whitespace.", parameterName); if (attribute.Target is not null && string.IsNullOrWhiteSpace(attribute.Target)) throw new ArgumentException("Attribute targets cannot be whitespace.", parameterName); @@ -2891,106 +2861,4 @@ static bool HasGenericConstraints(ImmutableArray ge return true; return false; } - - /// - /// Restores warning pragmas when disposed. - /// - [SuppressMessage( - "Performance", - "CA1815:Override equals and operator equals on value types", - Justification = "This type is a mutable lifetime token and has no meaningful value equality." - )] - public struct PragmaScope(CodeWriter writer, string[] pragmas) : IDisposable - { - CodeWriter? _writer = writer; - readonly string[] _pragmas = pragmas; - - /// - /// Restores the disabled pragmas once. - /// - public void Dispose() - { - var writer = _writer; - if (writer is null) - return; - - _writer = null; - writer.RestorePragmas(_pragmas); - } - } - - void RestorePragmas(string[] pragmas) - { - if (pragmas.Length == 0) - return; - - NewLine(); - foreach (var pragma in pragmas) - Write("#pragma warning restore ").WriteLine(pragma); - } - - /// - /// Restores a writer's indentation when disposed. - /// - [SuppressMessage( - "Performance", - "CA1815:Override equals and operator equals on value types", - Justification = "This type is a mutable lifetime token and has no meaningful value equality." - )] - public struct IndentScope(CodeWriter writer, int scopeId) : IDisposable - { - CodeWriter? _writer = writer; - - /// - /// Restores the indentation level once. - /// - public void Dispose() - { - var writer = _writer; - if (writer is null) - return; - - _writer = null; - writer.CloseIndentScope(scopeId); - } - } - - /// - /// Restores indentation and writes a block's closing token when disposed. - /// - [SuppressMessage( - "Performance", - "CA1815:Override equals and operator equals on value types", - Justification = "This type is a mutable lifetime token and has no meaningful value equality." - )] - public struct BlockScope : IDisposable - { - CodeWriter? _writer; - readonly string? _closingSeparator; - readonly int _scopeId; - readonly int _completedItem; - readonly int _itemIndent; - - internal BlockScope(CodeWriter writer, string? closingSeparator, int scopeId, int completedItem, int itemIndent) - { - _writer = writer; - _closingSeparator = closingSeparator; - _scopeId = scopeId; - _completedItem = completedItem; - _itemIndent = itemIndent; - } - - /// - /// Closes the block once. - /// - public void Dispose() - { - var writer = _writer; - if (writer is null) - return; - - _writer = null; - writer.CloseBlock(_closingSeparator, _scopeId, _completedItem, _itemIndent); - } - } } diff --git a/src/src/SourceGeneratorShared/CodeWriterOpenScope.cs b/src/src/SourceGeneratorShared/CodeWriterOpenScope.cs new file mode 100644 index 0000000..622c85c --- /dev/null +++ b/src/src/SourceGeneratorShared/CodeWriterOpenScope.cs @@ -0,0 +1,10 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Describes a disposable scope that was still open when source was +/// materialized. +/// +/// The kind of scope. +/// The block header, when available. +/// The call stack captured when the scope was opened. +public sealed record CodeWriterOpenScope(string Kind, string? Header, string OpeningStackTrace); diff --git a/src/src/SourceGeneratorFramework/CodeWriterScopeValidationException.cs b/src/src/SourceGeneratorShared/CodeWriterScopeValidationException.cs similarity index 83% rename from src/src/SourceGeneratorFramework/CodeWriterScopeValidationException.cs rename to src/src/SourceGeneratorShared/CodeWriterScopeValidationException.cs index b2d389f..ae6b63a 100644 --- a/src/src/SourceGeneratorFramework/CodeWriterScopeValidationException.cs +++ b/src/src/SourceGeneratorShared/CodeWriterScopeValidationException.cs @@ -2,15 +2,6 @@ namespace Purview.SourceGeneratorFramework; -/// -/// Describes a disposable scope that was still open when source was -/// materialized. -/// -/// The kind of scope. -/// The block header, when available. -/// The call stack captured when the scope was opened. -public sealed record CodeWriterOpenScope(string Kind, string? Header, string OpeningStackTrace); - /// /// The exception thrown when generated source is requested while one or more /// scopes remain open. diff --git a/src/src/SourceGeneratorFramework/ConstructorDeclarationOptions.cs b/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs similarity index 76% rename from src/src/SourceGeneratorFramework/ConstructorDeclarationOptions.cs rename to src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs index fdc8555..ea25d82 100644 --- a/src/src/SourceGeneratorFramework/ConstructorDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs @@ -8,36 +8,37 @@ namespace Purview.SourceGeneratorFramework; public readonly record struct ConstructorDeclarationOptions { /// - /// Initializes a constructor declaration description. + /// Initializes a constructor declaration from its containing type name. /// - /// The name of the containing type without generic parameters. + /// The name of the containing type. /// The optional accessibility modifier, or to omit accessibility. - public ConstructorDeclarationOptions(string typeName, TypeDeclarationAccessibility? accessibility = null) + /// Thrown when is null or whitespace. + public ConstructorDeclarationOptions(string name, TypeDeclarationAccessibility? accessibility = null) { - if (string.IsNullOrWhiteSpace(typeName)) - throw new ArgumentException("Type name cannot be null or whitespace.", nameof(typeName)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Type name cannot be null or whitespace.", nameof(name)); - Type = new TypeValueObject(typeName, null).AsTypeReference(); + Reference = new TypeReference(new TypeIdentity(name, null)); Accessibility = accessibility; } /// Initializes a constructor declaration from its containing type. /// The containing type. Only its unqualified declaration name is used. /// The optional accessibility modifier, or to omit accessibility. - public ConstructorDeclarationOptions(TypeValueObject type, TypeDeclarationAccessibility? accessibility = null) + public ConstructorDeclarationOptions(TypeIdentity type, TypeDeclarationAccessibility? accessibility = null) : this(type.AsTypeReference(), accessibility) { } /// Initializes a constructor declaration from its containing type reference. - /// The containing type reference. + /// The containing type reference. /// The optional accessibility modifier, or to omit accessibility. - public ConstructorDeclarationOptions(TypeReferenceOptions type, TypeDeclarationAccessibility? accessibility = null) + public ConstructorDeclarationOptions(TypeReference reference, TypeDeclarationAccessibility? accessibility = null) { - Type = type; + Reference = reference; Accessibility = accessibility; } /// Gets the structured containing type reference. - public TypeReferenceOptions Type { get; } + public TypeReference Reference { get; } /// /// Gets the optional accessibility modifier, or to omit accessibility. diff --git a/src/src/SourceGeneratorShared/ContainingType.cs b/src/src/SourceGeneratorShared/ContainingType.cs new file mode 100644 index 0000000..d679ef2 --- /dev/null +++ b/src/src/SourceGeneratorShared/ContainingType.cs @@ -0,0 +1,24 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Represents a single link in a nested type's containing-type chain. +/// +/// +/// Deliberately minimal: only the name and the containing type's own generic arity are needed to identify +/// and render a link in the chain, or to match it against a declaration or symbol. Unlike +/// it carries no namespace, generic arguments or further nesting, so building +/// a chain never recurses into argument resolution. +/// +public readonly record struct ContainingType(string Name, int GenericArity) +{ + /// + /// Gets the CLR metadata name, including the generic arity suffix when required. + /// + public string MetadataName => GenericArity == 0 ? Name : $"{Name}`{GenericArity}"; + + /// + /// Gets the type name suitable for use in generated code, using open placeholders for any generic + /// parameters since a containing link does not track the constructed argument shapes. + /// + public string RenderTypeName => GenericArity == 0 ? Name : $"{Name}<{new string(',', GenericArity - 1)}>"; +} diff --git a/src/src/SourceGeneratorFramework/Models/DiagnosticInfo.cs b/src/src/SourceGeneratorShared/DiagnosticInfo.cs similarity index 78% rename from src/src/SourceGeneratorFramework/Models/DiagnosticInfo.cs rename to src/src/SourceGeneratorShared/DiagnosticInfo.cs index e682dcc..6947689 100644 --- a/src/src/SourceGeneratorFramework/Models/DiagnosticInfo.cs +++ b/src/src/SourceGeneratorShared/DiagnosticInfo.cs @@ -2,7 +2,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -namespace Purview.SourceGeneratorFramework.Models; +namespace Purview.SourceGeneratorFramework; /// /// A serializable representation of a that can be carried through incremental source generator pipelines. @@ -39,49 +39,60 @@ public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, params obje Create(descriptor, location: null, additionalLocations: null, messageArgs: messageArgs); /// - /// Creates a from a descriptor and a target symbol descriptor. + /// Creates a from a descriptor and optional location. /// /// The diagnostic descriptor. - /// The target symbol descriptor. + /// The location of the diagnostic. + /// The message arguments. + /// A instance. + public static DiagnosticInfo Create( + DiagnosticDescriptor descriptor, + Location? location, + params object[] messageArgs + ) => Create(descriptor, location, additionalLocations: null, messageArgs); + + /// + /// Creates a from a descriptor and optional location. + /// + /// The diagnostic descriptor. + /// The locations of the diagnostic. /// The message arguments. /// A instance. - /// public static DiagnosticInfo Create( DiagnosticDescriptor descriptor, - TargetSymbolDescriptor target, + IEnumerable locations, params object[] messageArgs ) { - if (target is null) - throw new ArgumentNullException(nameof(target)); + var location = locations?.FirstOrDefault(); + var additionalLocations = locations?.Skip(1).ToImmutableArray(); - var location = target.Declaration?.GetLocation(); - ImmutableArray additionalLocations = []; - if (location is null) - { - location = target.Symbol.Locations.FirstOrDefault(static loc => loc.IsInSource) is { } firstLocation - ? location = firstLocation - : location = null; - additionalLocations = [.. target.Symbol.Locations.Skip(1).Where(static loc => loc.IsInSource)]; - } - else - additionalLocations = [.. target.Symbol.Locations.Where(static loc => loc.IsInSource)]; - - return Create(descriptor, location, additionalLocations: additionalLocations, messageArgs); + return Create(descriptor, location, additionalLocations, messageArgs); } /// /// Creates a from a descriptor and optional location. /// /// The diagnostic descriptor. - /// The location of the diagnostic. + /// Used to retrieve the locations of the diagnostic. + /// The cancellation token. /// The message arguments. /// A instance. public static DiagnosticInfo Create( DiagnosticDescriptor descriptor, - Location? location, + IEnumerable syntaxReferences, + CancellationToken cancellationToken, params object[] messageArgs - ) => Create(descriptor, location, additionalLocations: null, messageArgs); + ) + { + var location = syntaxReferences?.FirstOrDefault()?.GetSyntax(cancellationToken).GetLocation(); + var additionalLocations = syntaxReferences + ?.Skip(1) + .Select(s => s.GetSyntax(cancellationToken).GetLocation()) + .ToImmutableArray(); + + return Create(descriptor, location, additionalLocations, messageArgs); + } /// /// Creates a from a descriptor and optional location. diff --git a/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs b/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs new file mode 100644 index 0000000..8da7000 --- /dev/null +++ b/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// Describes a field in a generated enum declaration. +public readonly record struct EnumFieldDeclarationOptions +{ + /// Initializes an enum field declaration. + /// The enum field name. + /// + /// The enum field value. Strings are emitted as C# expressions; other values are + /// formatted using the invariant culture. + /// + /// The lines written in the field's XML summary block. + public EnumFieldDeclarationOptions(string fieldName, object fieldValue, params string[] xmlSummary) + : this(fieldName, xmlSummary) + { + if (fieldValue is null) + throw new ArgumentNullException(nameof(fieldValue)); + + FieldValue = fieldValue; + } + + /// Initializes an enum field declaration. + /// The enum field name. + /// The lines written in the field's XML summary block. + public EnumFieldDeclarationOptions(string fieldName, params string[] xmlSummary) + { + if (string.IsNullOrWhiteSpace(fieldName)) + throw new ArgumentException("Enum field name cannot be null or whitespace.", nameof(fieldName)); + + FieldName = fieldName; + XmlSummary = [.. xmlSummary ?? []]; + } + + /// Gets the enum field name. + public string FieldName { get; } + + /// + /// Gets the optional enum field value. Strings are treated as C# expressions rather than + /// string literals. + /// + public object? FieldValue { get; } + + /// Gets the lines written in the field's XML summary block. + public ImmutableArray XmlSummary { get; init; } = []; + + /// Gets the attributes applied to the enum field. + public ImmutableArray Attributes { get; init; } = []; +} diff --git a/src/src/SourceGeneratorFramework/Models/EquatableArray.cs b/src/src/SourceGeneratorShared/EquatableArray.cs similarity index 88% rename from src/src/SourceGeneratorFramework/Models/EquatableArray.cs rename to src/src/SourceGeneratorShared/EquatableArray.cs index fd14aad..aa3f67d 100644 --- a/src/src/SourceGeneratorFramework/Models/EquatableArray.cs +++ b/src/src/SourceGeneratorShared/EquatableArray.cs @@ -1,7 +1,7 @@ using System.Collections; using System.Collections.Immutable; -namespace Purview.SourceGeneratorFramework.Models; +namespace Purview.SourceGeneratorFramework; /// /// An immutable, equatable wrapper around that is safe to use in incremental source generator pipelines. @@ -29,7 +29,7 @@ public static EquatableArray Create(params T[] items) return Empty; // All valid... - return new EquatableArray(ImmutableArray.Create(items)); + return new(ImmutableArray.Create(items)); } public bool Equals(EquatableArray other) => AsImmutableArray().SequenceEqual(other.AsImmutableArray()); @@ -43,6 +43,7 @@ public override int GetHashCode() var hash = 17; foreach (var item in AsImmutableArray()) hash = (hash * 31) + (item?.GetHashCode() ?? 0); + return hash; } } @@ -53,6 +54,9 @@ public override int GetHashCode() public static implicit operator EquatableArray(ImmutableArray array) => new(array); + public static implicit operator EquatableArray(ImmutableArray.Builder builder) => + builder is null || builder.Count == 0 ? Empty : new(builder.ToImmutable()); + public static implicit operator ImmutableArray(EquatableArray array) => array.AsImmutableArray(); public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); diff --git a/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalGeneratorInitializationContextExtensions.cs b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalGeneratorInitializationContextExtensions.cs new file mode 100644 index 0000000..a485b93 --- /dev/null +++ b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalGeneratorInitializationContextExtensions.cs @@ -0,0 +1,104 @@ +using System.ComponentModel; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Purview.SourceGeneratorFramework.Helpers; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class IncrementalGeneratorInitializationContextExtensions +{ + extension(IncrementalGeneratorInitializationContext context) + { + /// + /// Registers the Microsoft.CodeAnalysis.EmbeddedAttribute source using a . + /// + /// The source file name. + /// Generator type used to create the . + public IncrementalGeneratorInitializationContext RegisterEmbeddedAttribute( + string fileName = "EmbeddedAttribute.g.cs" + ) => RegisterEmbeddedAttribute(context, GenerationSettings.Create(), fileName); + + /// + /// Registers the Microsoft.CodeAnalysis.EmbeddedAttribute source using a . + /// + /// The generation settings. + /// The source file name. + public IncrementalGeneratorInitializationContext RegisterEmbeddedAttribute( + GenerationSettings settings, + string fileName = "EmbeddedAttribute.g.cs" + ) + { + if (settings is null) + throw new ArgumentNullException(nameof(settings)); + + context.RegisterPostInitializationOutput(spc => + { + CodeWriter writer = new(settings); + writer.WriteAutoGeneratedHeader(); + writer.WriteFileScopedNamespace( + PurviewTypeLibrary.Microsoft.CodeAnalysis.EmbeddedAttribute.AsTypeReference() + ); + + // The compiler treats this special type as implicitly compiler-generated and embedded, + // so no generated attributes are applied here. + writer.WriteClass( + new(PurviewTypeLibrary.Microsoft.CodeAnalysis.EmbeddedAttribute) + { + IsSealed = true, + IsPartial = true, + BaseType = PurviewTypeLibrary.System.Attribute, + IncludeGeneratedAttributes = false, + }, + bodyWriter => + bodyWriter.Comment( + "This is a special type that is treated as implicitly compiler-generated and embedded by the compiler." + ) + ); + + spc.AddSource(fileName, SourceText.From(writer.ToString(), Encoding.UTF8)); + }); + + return context; + } + + /// + /// Registers a source output that combines each with a + /// generation context, reports any diagnostics, and invokes the generator callback only for + /// successful results. This keeps generator methods thin + /// and enforces the rule that diagnostics are reported in the source output stage. + /// + public IncrementalGeneratorInitializationContext RegisterSourceOutput( + IncrementalValuesProvider> outputs, + IncrementalValueProvider> contextProvider, + Action> generate, + string? trackingName = null + ) + where TOutput : notnull + where TCapabilities : class, IGenerationCapabilities + { + if (generate is null) + throw new ArgumentNullException(nameof(generate)); + + var combined = outputs + .CombineWith(contextProvider, static (output, ctx, _) => (Output: output, Context: ctx)) + .WithTrackingName(trackingName ?? $"RegisterSourceOutput_{typeof(TOutput).Name}"); + + context.RegisterSourceOutput( + combined, + (spc, item) => + { + if (item.Output.HasDiagnostics) + spc.ReportDiagnostics(item.Output.Diagnostics); + + if (!item.Output.HasValue || item.Output.ShouldProcess) + return; + + generate(spc, item.Output.Value, item.Context); + } + ); + + return context; + } + } +} diff --git a/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalValueProviderExtensions.cs b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalValueProviderExtensions.cs new file mode 100644 index 0000000..eb5072e --- /dev/null +++ b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/IncrementalValueProviderExtensions.cs @@ -0,0 +1,133 @@ +using System.Collections.Immutable; +using System.ComponentModel; + +namespace Microsoft.CodeAnalysis; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class IncrementalValueProviderExtensions +{ + /// + /// Combines a state provider with another value and immediately projects the pair into a + /// named result, avoiding deeply nested tuple structures as a pipeline expands. + /// + public static IncrementalValueProvider CombineWith( + this IncrementalValueProvider stateProvider, + IncrementalValueProvider valueProvider, + Func selector, + string? trackingName = null + ) + { + if (selector is null) + throw new ArgumentNullException(nameof(selector)); + + var result = stateProvider + .Combine(valueProvider) + .Select((pair, cancellationToken) => selector(pair.Left, pair.Right, cancellationToken)); + + return string.IsNullOrWhiteSpace(trackingName) ? result : result.WithTrackingName(trackingName!); + } + + /// + /// Combines every item from a values provider with a single value and immediately projects + /// each pair, preserving independent per-item incrementality. + /// + public static IncrementalValuesProvider CombineWith( + this IncrementalValuesProvider stateProvider, + IncrementalValueProvider valueProvider, + Func selector, + string? trackingName = null + ) + { + if (selector is null) + throw new ArgumentNullException(nameof(selector)); + + var result = stateProvider + .Combine(valueProvider) + .Select((pair, cancellationToken) => selector(pair.Left, pair.Right, cancellationToken)); + + return string.IsNullOrWhiteSpace(trackingName) ? result : result.WithTrackingName(trackingName!); + } + + /// + /// Combines every item from a values provider with a single state value and immediately + /// projects each pair, preserving independent per-item incrementality. + /// + public static IncrementalValuesProvider CombineWith( + this IncrementalValueProvider stateProvider, + IncrementalValuesProvider valuesProvider, + Func selector, + string? trackingName = null + ) + { + return selector is null + ? throw new ArgumentNullException(nameof(selector)) + : valuesProvider.CombineWith( + stateProvider, + (value, state, cancellationToken) => selector(state, value, cancellationToken), + trackingName + ); + } + + /// + /// Collects a values provider and immediately projects its immutable array together with an + /// existing state, making additional pipeline inputs straightforward to add. + /// + public static IncrementalValueProvider CollectWith( + this IncrementalValueProvider stateProvider, + IncrementalValuesProvider valuesProvider, + Func, CancellationToken, TResult> selector, + string? trackingName = null + ) + { + return selector is null + ? throw new ArgumentNullException(nameof(selector)) + : stateProvider.CombineWith(valuesProvider.Collect(), selector, trackingName); + } + + /// + /// Collects all items from a values provider, combines the resulting immutable array with a + /// single value, and projects both into one aggregate result. + /// + public static IncrementalValueProvider CollectWith( + this IncrementalValuesProvider stateProvider, + IncrementalValueProvider valueProvider, + Func, TValue, CancellationToken, TResult> selector, + string? trackingName = null + ) + { + return selector is null + ? throw new ArgumentNullException(nameof(selector)) + : stateProvider.Collect().CombineWith(valueProvider, selector, trackingName); + } + + /// + /// Collects all items from two values providers and projects both immutable arrays into one + /// aggregate result. This is an aggregate operation rather than a Cartesian product. + /// + public static IncrementalValueProvider CollectWith( + this IncrementalValuesProvider leftProvider, + IncrementalValuesProvider rightProvider, + Func, ImmutableArray, CancellationToken, TResult> selector, + string? trackingName = null + ) + { + return selector is null + ? throw new ArgumentNullException(nameof(selector)) + : leftProvider.Collect().CombineWith(rightProvider.Collect(), selector, trackingName); + } + + /// + /// Combines a values provider with a single value provider, returning a values provider of tuples. + /// + public static IncrementalValuesProvider<(TOutput Output, TContext Context)> CombineWithContext( + this IncrementalValuesProvider valuesProvider, + IncrementalValueProvider contextProvider + ) + where TOutput : notnull + { + return valuesProvider.CombineWith( + contextProvider, + static (output, generationContext, _) => (output, generationContext) + ); + } +} diff --git a/src/src/SourceGeneratorFramework/Extensions/SourceProductionContextExtensions.cs b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensions.cs similarity index 65% rename from src/src/SourceGeneratorFramework/Extensions/SourceProductionContextExtensions.cs rename to src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensions.cs index dc10282..f1fb0d5 100644 --- a/src/src/SourceGeneratorFramework/Extensions/SourceProductionContextExtensions.cs +++ b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensions.cs @@ -1,11 +1,7 @@ using System.ComponentModel; -using Microsoft.CodeAnalysis; -namespace Purview.SourceGeneratorFramework.Extensions; +namespace Microsoft.CodeAnalysis; -/// -/// Extension methods for . -/// [EditorBrowsable(EditorBrowsableState.Never)] public static class SourceProductionContextExtensions { @@ -33,14 +29,5 @@ public void ReportDiagnostics(IEnumerable diagnostics) foreach (var diagnostic in diagnostics) context.ReportDiagnostic(diagnostic); } - - /// - /// Reports a sequence of diagnostics to the source production context. - /// - public void ReportDiagnostics(EquatableArray diagnostics) - { - foreach (var diagnostic in diagnostics) - context.ReportDiagnostic(diagnostic); - } } } diff --git a/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SyntaxNodeExtensions.cs b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SyntaxNodeExtensions.cs new file mode 100644 index 0000000..4eef326 --- /dev/null +++ b/src/src/SourceGeneratorShared/Extensions/Microsoft/CodeAnalysis/SyntaxNodeExtensions.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.CodeAnalysis; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class SyntaxNodeExtensions +{ + /// + /// Gets the declared accessibility of a method declaration syntax node. + /// + /// The method declaration syntax node. + /// The declared accessibility, or null if no explicit accessibility modifier is present. + /// Thrown if the method parameter is null. + public static Accessibility? GetDeclaredAccessibility(this MethodDeclarationSyntax method) + { + if (method == null) + throw new ArgumentNullException(nameof(method)); + + if (method.Modifiers.Any(SyntaxKind.PublicKeyword)) + return Accessibility.Public; + + if (method.Modifiers.Any(SyntaxKind.PrivateKeyword)) + return Accessibility.Private; + + if (method.Modifiers.Any(SyntaxKind.ProtectedKeyword)) + { + return method.Modifiers.Any(SyntaxKind.InternalKeyword) + ? Accessibility.ProtectedOrInternal + : Accessibility.Protected; + } + + if (method.Modifiers.Any(SyntaxKind.InternalKeyword)) + return Accessibility.Internal; + + return null; // No explicit accessibility modifier. + } +} diff --git a/src/src/SourceGeneratorShared/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs b/src/src/SourceGeneratorShared/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs new file mode 100644 index 0000000..3a6dd60 --- /dev/null +++ b/src/src/SourceGeneratorShared/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs @@ -0,0 +1,15 @@ +#if NETSTANDARD2_0 || NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 + +using System.ComponentModel; + +// Compilation error of CS0518 IsExternalInit is not defined when using .NET Standard. +// re: https://mking.net/blog/error-cs0518-isexternalinit-not-defined +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace System.Runtime.CompilerServices; + +#pragma warning restore IDE0130 // Namespace does not match folder structure + +[EditorBrowsable(EditorBrowsableState.Never)] +static class IsExternalInit; + +#endif diff --git a/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs b/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs new file mode 100644 index 0000000..a1ebdc9 --- /dev/null +++ b/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace System; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class StringExtension +{ + extension(string? value) + { + /// + /// Surrounds the string with the specified string. Default is double quotes. + /// + /// The string to surround the value with. + /// The surrounded string. + public string Surround(string surroundWith = "\"") => $"{surroundWith}{value}{surroundWith}"; + } +} diff --git a/src/src/SourceGeneratorShared/GenerationContext.cs b/src/src/SourceGeneratorShared/GenerationContext.cs new file mode 100644 index 0000000..75ecadd --- /dev/null +++ b/src/src/SourceGeneratorShared/GenerationContext.cs @@ -0,0 +1,73 @@ +using Purview.SourceGeneratorFramework.Logging; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Provides execution services for source generation, including the compilation, immutable +/// settings, optional logging, and symbol-resolution helpers. +/// +/// Initializes a generation context. +public sealed class GenerationContext( + TCapabilities Capabilities, + GenerationSettings Settings, + ISourceGenLogger? Logger = null +) : ISourceGenLogger, IEqualityComparer> + where TCapabilities : class, IGenerationCapabilities +{ + /// + /// Gets the capabilities of the source generator, which are used to determine what features are available during generation. + /// + public TCapabilities Capabilities { get; } = Capabilities ?? throw new ArgumentNullException(nameof(Capabilities)); + + /// Gets the immutable generation settings. + public GenerationSettings Settings { get; } = Settings ?? throw new ArgumentNullException(nameof(Settings)); + + /// + /// Gets the optional logger. This is primarily used during source generator test execution, such as + /// unit or integration tests, to capture and assert on log messages generated by the source generator. + /// + public ISourceGenLogger? Logger { get; } = Logger; + + /// Creates a new independently owned code writer. + public CodeWriter CreateCodeWriter() => new(Settings, throwOnUnclosedScopes: Settings.ValidateCodeWriterScopes); + + /// + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Logger?.Log(level, indentation, message, args); + + /// + public bool Equals(GenerationContext? x, GenerationContext? y) => + ReferenceEquals(x, y) + || ( + x is not null + && y is not null + && EqualityComparer.Default.Equals(x.Capabilities, y.Capabilities) + && EqualityComparer.Default.Equals(x.Settings, y.Settings) + ); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + EqualityComparer.Default.GetHashCode(Capabilities); + hash = (hash * 31) + EqualityComparer.Default.GetHashCode(Settings); + return hash; + } + } + + /// + public int GetHashCode(GenerationContext obj) => obj?.GetHashCode() ?? 0; +} + +/// +/// Represents an empty capabilities type that can be used when no specific capabilities are required for a source generator. +/// +public sealed record EmptyCapabilities : IGenerationCapabilities +{ + /// + /// Gets the singleton instance of . This instance can be used when no specific capabilities are required for a source generator. + /// + public static readonly EmptyCapabilities Instance = new(); +} diff --git a/src/src/SourceGeneratorShared/GenerationSettings.cs b/src/src/SourceGeneratorShared/GenerationSettings.cs new file mode 100644 index 0000000..13287d0 --- /dev/null +++ b/src/src/SourceGeneratorShared/GenerationSettings.cs @@ -0,0 +1,58 @@ +namespace Purview.SourceGeneratorFramework; + +/// Describes immutable settings shared by a source-generation operation. +public sealed record GenerationSettings +{ + /// Initializes source-generation settings. + /// The name of the generator. + /// The version of the generator. If null, defaults to "1.0.0.0". + /// An optional MSBuild property name that disables the generator when set to true. + public GenerationSettings( + string generatorName, + string? generatorVersion = null, + string? disabledSourceGenMSBuildProperty = null + ) + { + if (string.IsNullOrWhiteSpace(generatorName)) + throw new ArgumentException("Generator name cannot be null or whitespace.", nameof(generatorName)); + + GeneratorName = generatorName; + GeneratorVersion = generatorVersion ?? "1.0.0.0"; + DisabledSourceGenMSBuildProperty = disabledSourceGenMSBuildProperty; + } + + /// Gets the source generator name propagated to code writers. + public string GeneratorName { get; } + + /// Gets the source generator version propagated to code writers. + public string GeneratorVersion { get; } + + /// Gets the optional MSBuild property name that disables the generator when set to true. + public string? DisabledSourceGenMSBuildProperty { get; } + + /// Gets whether created code writers validate undisposed scopes. + public bool ValidateCodeWriterScopes { get; init; } + + /// Gets whether the source generator is disabled by build configuration. + public bool IsSourceGeneratorDisabled { get; init; } + + /// Gets whether source-generator logging is active for this generation context. + public bool IsLoggingEnabled { get; init; } + + /// + /// Creates a new generation settings instance for the specified generator type, using the type name and assembly version. + /// + /// The type of the generator. + /// An optional MSBuild property name that disables the generator when set to true. + /// A new instance. + public static GenerationSettings Create(string? disabledSourceGenMSBuildProperty = null) + { + var generatorType = typeof(TGenerator); + + return new( + generatorType.Name, + generatorType.Assembly.GetName().Version?.ToString(), + disabledSourceGenMSBuildProperty + ); + } +} diff --git a/src/src/SourceGeneratorShared/GeneratorResult.cs b/src/src/SourceGeneratorShared/GeneratorResult.cs new file mode 100644 index 0000000..e697b5c --- /dev/null +++ b/src/src/SourceGeneratorShared/GeneratorResult.cs @@ -0,0 +1,131 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Represents the result of an incremental source generator transform, carrying either a value, diagnostics, or both. +/// +/// The value type. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1000:Do not declare static members on generic types")] +public readonly record struct GeneratorResult +{ + /// + /// Gets the value of the generator result. If the result is a failure, this will be null or default(T). + /// + /// This value can be null or default(T) if the result is a failure. + public T Value { get; private init; } + + /// + /// Gets the diagnostics associated with the generator result. If the result is successful, this may be empty or contain warnings. + /// + public EquatableArray Diagnostics { get; private init; } + + /// + /// Indicates whether the generator result is successful (has a value) and does not contain any fatal diagnostics. + /// + public bool HasValue { get; private init; } + + /// + /// Indicates whether the generator result contains any diagnostics, regardless of success or failure. + /// + public bool HasDiagnostics { get; private init; } + + /// + /// Indicates whether the generator result should be processed, meaning it has a value and does not contain any error severity diagnostics. + /// + public bool ShouldProcess { get; private init; } + + /// + /// Indicates whether the generator result contains any diagnostics with severity of Error. + /// + /// We use the DefaultSeverity of the diagnostic descriptor, rather than the effective one + /// because regardless of if the consumer has changed its level, the source generator is effectively saying + /// it's serious and cannot continue. + public bool HasErrorDiagnostics { get; private init; } + + /// + /// Indicates whether the generator result is empty, meaning it has no value and no diagnostics. + /// + public bool IsEmpty => this == Empty; + + /// + /// Creates a successful generator result with the specified value and diagnostics. + /// + /// The value of the generator result. + /// The diagnostics associated with the result. + /// A successful generator result. + public static GeneratorResult Create(T value, ImmutableArray diagnostics) + { + var hasValue = value is not null && !EqualityComparer.Default.Equals(value, default!); + var hasDiagnostics = !diagnostics.IsDefaultOrEmpty; + var hasErrorDiagnostics = diagnostics.Any(d => + d.Descriptor.DefaultSeverity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error + ); + + return new() + { + Value = value!, + Diagnostics = diagnostics, + HasValue = hasValue, + HasDiagnostics = hasDiagnostics, + ShouldProcess = hasValue && !hasErrorDiagnostics, + HasErrorDiagnostics = hasErrorDiagnostics, + }; + } + + /// + /// Creates a successful generator result with the specified value and optional diagnostics. + /// + /// The value of the generator result. + /// Optional diagnostics associated with the result. + /// A successful generator result. + public static GeneratorResult Create(T value, params DiagnosticInfo[] diagnostics) => + Create( + value, + diagnostics is null || diagnostics.Length == 0 + ? EquatableArray.Empty + : EquatableArray.Create(diagnostics) + ); + + /// + /// Creates a failed generator result with the specified diagnostics. At least one diagnostic must be provided. + /// + /// The diagnostics associated with the failure result. + /// A failed generator result. + /// Thrown when no diagnostics are provided. + public static GeneratorResult Create(params DiagnosticInfo[] diagnostics) + { + if (diagnostics is null || diagnostics.Length == 0) + { + throw new ArgumentException( + "At least one diagnostic must be provided for a failure result.", + nameof(diagnostics) + ); + } + + var hasErrorDiagnostics = diagnostics.Any(d => + d.Descriptor.DefaultSeverity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error + ); + + return new() + { + Value = default!, + Diagnostics = EquatableArray.Create(diagnostics), + HasValue = false, + HasDiagnostics = true, + ShouldProcess = false, + HasErrorDiagnostics = hasErrorDiagnostics, + }; + } + + /// + /// Implicitly converts a value of type T to a successful GeneratorResult{T} with that value and no diagnostics. + /// + /// The value to convert to a successful GeneratorResult{T}. + public static implicit operator GeneratorResult(T value) => Create(value); + + /// + /// Represents an empty generator result with no value and no diagnostics. + /// + public static readonly GeneratorResult Empty; +} diff --git a/src/src/SourceGeneratorShared/GenericTypeParameterOptions.cs b/src/src/SourceGeneratorShared/GenericTypeParameterOptions.cs new file mode 100644 index 0000000..ac31cdf --- /dev/null +++ b/src/src/SourceGeneratorShared/GenericTypeParameterOptions.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Describes one generic type parameter and its optional C# constraints. +/// +public sealed record GenericTypeParameterOptions +{ + /// + /// Initializes a generic type parameter description. + /// + /// The type parameter name. + public GenericTypeParameterOptions(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Type parameter name cannot be null or whitespace.", nameof(name)); + + Name = name; + } + + /// + /// Gets the type parameter name. + /// + public string Name { get; } + + /// + /// Gets the ordered constraint expressions written after where T :. + /// + /// + /// Entries are emitted verbatim and may contain values such as class, notnull, + /// a base type, an interface, or new(). + /// + public ImmutableArray Constraints { get; init; } = []; + + public static implicit operator GenericTypeParameterOptions(string name) => new(name); +} diff --git a/src/src/SourceGeneratorShared/GlobalUsings.cs b/src/src/SourceGeneratorShared/GlobalUsings.cs new file mode 100644 index 0000000..df13bac --- /dev/null +++ b/src/src/SourceGeneratorShared/GlobalUsings.cs @@ -0,0 +1 @@ +global using Purview.SourceGeneratorFramework.Helpers; diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs new file mode 100644 index 0000000..67f1d31 --- /dev/null +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs @@ -0,0 +1,178 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Logging; + +namespace Purview.SourceGeneratorFramework.Helpers; + +/// +/// Helpers for building common incremental source generator pipelines. +/// +public static class IncrementalPipeline +{ + /// + /// Creates a value provider that reads an MSBuild property to determine whether the generator is disabled. + /// + public static IncrementalValueProvider IsDisabledValueProvider( + IncrementalGeneratorInitializationContext context, + string propertyName + ) => PropertyValueProvider(context, propertyName, value => bool.TryParse(value, out var isDisabled) && isDisabled); + + /// + /// Creates a value provider that reads an MSBuild property. + /// + public static IncrementalValueProvider PropertyValueProvider( + IncrementalGeneratorInitializationContext context, + string propertyName, + Func converter + ) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName)); + if (converter == null) + throw new ArgumentNullException(nameof(converter)); + + var msbuildPropertyValue = propertyName; + if (!propertyName.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + msbuildPropertyValue = SourceGeneratorBuildProperties.BuildProperty + propertyName; + + // All valid... + return context + .AnalyzerConfigOptionsProvider.Select( + (options, _) => + { + options.GlobalOptions.TryGetValue(msbuildPropertyValue, out var value); + + return converter(value); + } + ) + .WithTrackingName($"GetMSBuildPropertyValue_{propertyName}"); + } + + /// + /// Creates a value provider that builds a generation context from the compilation, framework + /// build properties, an optional generator-disable property, and any registered logging sink. + /// + /// The generator initialization context. + /// The generation settings. + /// A factory that creates a generation context from the compilation, settings, and optional logger. + public static IncrementalValueProvider< + GenerationContext + > GenerationContextValueProvider( + IncrementalGeneratorInitializationContext context, + GenerationSettings settings, + Func factory + ) + where TCapabilities : class, IGenerationCapabilities + { + if (settings is null) + throw new ArgumentNullException(nameof(settings)); + if (factory is null) + throw new ArgumentNullException(nameof(factory)); + + // Combine the compilation and generation configuration into a single value provider, then transform it into a generation context. + return context + .CompilationProvider.Combine( + GenerationConfigurationValueProvider(context, settings.DisabledSourceGenMSBuildProperty) + ) + .Select( + (input, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + + var compilation = input.Left; + var configuration = input.Right; + var logger = configuration.IsLoggingEnabled + ? SourceGenLogging.CreateLogger(configuration.LoggingSessionId) + : null; + + logger?.Info( + $"Creating generation context ({typeof(TCapabilities)}) for compilation '{input.Left.AssemblyName}'." + ); + + settings = settings with + { + ValidateCodeWriterScopes = configuration.ValidateCodeWriterScopes, + IsSourceGeneratorDisabled = configuration.IsSourceGeneratorDisabled, + IsLoggingEnabled = logger is not null, + }; + + var capabilities = factory(compilation, settings, logger, cancellationToken); + + return new GenerationContext(capabilities, settings, logger); + } + ) + .WithTrackingName($"GetGenerationContext_{typeof(TCapabilities).Name}"); + } + + static IncrementalValueProvider GenerationConfigurationValueProvider( + IncrementalGeneratorInitializationContext context, + string? disablePropertyName + ) => + context + .AnalyzerConfigOptionsProvider.Select( + (options, _) => + { + options.GlobalOptions.TryGetValue( + SourceGeneratorBuildProperties.ValidateCodeWriterScopes, + out var scopeValidationValue + ); + options.GlobalOptions.TryGetValue( + SourceGeneratorBuildProperties.EnableLogging, + out var loggingEnabledValue + ); + options.GlobalOptions.TryGetValue( + SourceGeneratorBuildProperties.LoggingSessionId, + out var loggingSessionId + ); + + string? disabledValue = null; + if (!string.IsNullOrWhiteSpace(disablePropertyName)) + { + var propertyName = disablePropertyName!.StartsWith( + SourceGeneratorBuildProperties.BuildProperty, + StringComparison.Ordinal + ) + ? disablePropertyName + : SourceGeneratorBuildProperties.BuildProperty + disablePropertyName; + options.GlobalOptions.TryGetValue(propertyName, out disabledValue); + } + + return new GenerationConfiguration( + ValidateCodeWriterScopes: bool.TryParse(scopeValidationValue, out var validateScopes) + && validateScopes, + IsSourceGeneratorDisabled: bool.TryParse(disabledValue, out var isDisabled) && isDisabled, + IsLoggingEnabled: bool.TryParse(loggingEnabledValue, out var loggingEnabled) && loggingEnabled, + LoggingSessionId: loggingSessionId + ); + } + ) + .WithTrackingName("GetGenerationConfiguration"); + + /// + /// Creates a values provider for syntax nodes annotated with a specific attribute. + /// + public static IncrementalValuesProvider ForAttributeWithMetadataName( + IncrementalGeneratorInitializationContext context, + TypeIdentity attributeType, + Func transform, + Func? predicate = null, + string? trackingName = null + ) + where TOutput : notnull + { + if (transform is null) + throw new ArgumentNullException(nameof(transform)); + + predicate ??= static (_, _) => true; + + return context + .SyntaxProvider.ForAttributeWithMetadataName(attributeType.MetadataFullName, predicate, transform) + .WithTrackingName(trackingName ?? $"ForAttribute_{attributeType.Name}"); + } + + readonly record struct GenerationConfiguration( + bool ValidateCodeWriterScopes, + bool IsSourceGeneratorDisabled, + bool IsLoggingEnabled, + string? LoggingSessionId + ); +} diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipelineExtensions.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipelineExtensions.cs new file mode 100644 index 0000000..dfe8ff7 --- /dev/null +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipelineExtensions.cs @@ -0,0 +1,89 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Logging; + +namespace Purview.SourceGeneratorFramework.Helpers; + +[EditorBrowsable(EditorBrowsableState.Never)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] +public static class IncrementalPipelineExtensions +{ + extension(IncrementalPipeline) + { + /// + /// Creates a value provider that builds a generation context from the compilation, framework + /// build properties, an optional generator-disable property, and any registered logging sink. + /// + /// The generator initialization context. + /// A factory that creates a generation context from the compilation, settings, and optional logger. + /// An optional MSBuild property name that disables the generator when set to true. + public static IncrementalValueProvider> GenerationContextValueProvider< + TCapabilities, + TGenerator + >( + IncrementalGeneratorInitializationContext context, + Func factory, + string? disablePropertyName = null + ) + where TCapabilities : class, IGenerationCapabilities => + IncrementalPipeline.GenerationContextValueProvider( + context, + GenerationSettings.Create(disablePropertyName), + factory + ); + + /// + /// Creates a value provider that builds a default generation context from the compilation and + /// resolved framework configuration. + /// + /// The generator initialization context. + /// The generation settings. + public static IncrementalValueProvider< + GenerationContext + > DefaultGenerationContextValueProvider( + IncrementalGeneratorInitializationContext context, + GenerationSettings settings + ) => + IncrementalPipeline.GenerationContextValueProvider( + context, + settings, + static (_, _, _, _) => EmptyCapabilities.Instance + ); + + /// + /// Creates a value provider that builds a default generation context from the compilation and + /// resolved framework configuration. + /// + /// The generator initialization context. + /// The MSBuild property that can be used to disable the source generator. + /// The type of the source generator. + public static IncrementalValueProvider< + GenerationContext + > DefaultGenerationContextValueProvider( + IncrementalGeneratorInitializationContext context, + string? disableSourceGenMSBuildProperty = null + ) => + IncrementalPipeline.GenerationContextValueProvider( + context, + GenerationSettings.Create(disableSourceGenMSBuildProperty), + static (_, _, _, _) => EmptyCapabilities.Instance + ); + + /// + /// Registers a source output action that receives a generation context and the generator result, + /// and produces source files and diagnostics from the successful results. This keeps generator + /// methods thin and enforces the rule that diagnostics are reported in the source output stage. + /// + /// The type of the generator result. + /// The incremental values provider for the generator results. + /// The incremental value provider for the generation context. + /// The action to generate source files and report diagnostics. + /// An optional tracking name for the source output. + public static void RegisterSourceOutput( + IncrementalValuesProvider> outputs, + IncrementalValueProvider> contextProvider, + Action> generate, + string? trackingName = null + ) => IncrementalPipeline.RegisterSourceOutput(outputs, contextProvider, generate, trackingName); + } +} diff --git a/src/src/SourceGeneratorFramework/Helpers/KnownLangTypes.cs b/src/src/SourceGeneratorShared/Helpers/KnownLangTypes.cs similarity index 74% rename from src/src/SourceGeneratorFramework/Helpers/KnownLangTypes.cs rename to src/src/SourceGeneratorShared/Helpers/KnownLangTypes.cs index 1937b87..207e19f 100644 --- a/src/src/SourceGeneratorFramework/Helpers/KnownLangTypes.cs +++ b/src/src/SourceGeneratorShared/Helpers/KnownLangTypes.cs @@ -90,48 +90,3 @@ public static TypeMapping Get(string keyword) => /// true if the special type is known; otherwise, false. public static bool IsKnownSpecialType(SpecialType specialType) => !Get(specialType).IsEmpty; } - -/// -/// Represents a mapping between a .NET type, its corresponding SpecialType, and its C# keyword representation. -/// -public readonly record struct TypeMapping -{ - internal TypeMapping(Type type, SpecialType specialType, string keyword) - { - Type = type; - SpecialType = specialType; - Keyword = keyword; - } - - /// - /// Gets the .NET type associated with this mapping. - /// - public Type Type { get; } - - /// - /// Gets the SpecialType associated with this mapping. - /// - public SpecialType SpecialType { get; } - - /// - /// Gets the C# keyword representation of the type associated with this mapping. - /// - public string Keyword { get; } - - /// - /// Determines whether this instance is empty (i.e., has no associated type, special type, or keyword). - /// - public bool IsEmpty => this == Empty; - - /// - /// Defines an implicit conversion from TypeMapping to TypeValueObject. If the SpecialType is None, it returns an empty TypeValueObject; otherwise, it creates a new TypeValueObject with the specified SpecialType. - /// - /// The TypeMapping instance to convert. - public static implicit operator TypeValueObject(TypeMapping mapping) => - mapping.SpecialType == SpecialType.None ? TypeValueObject.Empty : new(mapping.SpecialType); - - /// - /// Represents an empty TypeMapping instance with no associated type, special type, or keyword. - /// - public static readonly TypeMapping Empty; -} diff --git a/src/src/SourceGeneratorFramework/Helpers/TypeHelpers.cs b/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs similarity index 74% rename from src/src/SourceGeneratorFramework/Helpers/TypeHelpers.cs rename to src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs index d650a35..7a45b17 100644 --- a/src/src/SourceGeneratorFramework/Helpers/TypeHelpers.cs +++ b/src/src/SourceGeneratorShared/Helpers/TypeHelpers.cs @@ -15,6 +15,18 @@ public static class TypeHelpers /// public const string AttributeSuffix = nameof(Attribute); + /// + /// Determines whether the specified compilation contains a type with the given identity. + /// + /// The compilation to check. + /// The type identity to look for. + /// true if the compilation contains the type; otherwise, false. + /// Thrown if the compilation or typeIdentity is null. + public static bool HasType(Compilation compilation, TypeIdentity typeIdentity) => + compilation == null ? throw new ArgumentNullException(nameof(compilation)) + : typeIdentity == null ? throw new ArgumentNullException(nameof(typeIdentity)) + : compilation.GetTypeByMetadataName(typeIdentity.MetadataFullName) is not null; + /// /// Determines whether the specified type is a C# keyword type. /// @@ -36,11 +48,47 @@ public static bool IsAttribute(string typeName) if (typeName == null) throw new ArgumentNullException(nameof(typeName)); - var idx = typeName.IndexOf('`'); - if (idx >= 0) - typeName = typeName.Substring(0, idx); + var typeNameEnd = GetGenericTypeNameEnd(typeName); + return typeNameEnd > AttributeSuffix.Length + && string.Compare( + typeName, + typeNameEnd - AttributeSuffix.Length, + AttributeSuffix, + 0, + AttributeSuffix.Length, + StringComparison.Ordinal + ) == 0; + } + + /// + /// Determines whether the specified type symbol is compatible with any of the expected base types or interfaces. + /// + /// The type symbol to check. + /// The expected base types or interfaces to check against. + /// true if the type symbol is compatible with any of the expected base types or interfaces; otherwise, false. + /// Thrown if the identity or expectedBases parameters are null. + /// + /// An expected open generic definition matches any construction with the same name, namespace and arity. An + /// expected constructed generic validates its arguments; an actual argument may also satisfy an expected + /// argument by implementing or inheriting from that expected contract. + /// + public static bool Is(INamedTypeSymbol identity, params TypeIdentity[] expectedBases) + { + if (identity == null) + throw new ArgumentNullException(nameof(identity)); + if (expectedBases == null) + throw new ArgumentNullException(nameof(expectedBases)); + + foreach (var expectedBase in expectedBases) + { + if (IsCompatibleExpectedBase(identity, expectedBase)) + return true; - return typeName.Length > AttributeSuffix.Length && typeName.EndsWith(AttributeSuffix, StringComparison.Ordinal); + if (InheritsFrom(identity, expectedBase) || Implements(identity, expectedBase)) + return true; + } + + return false; } /// @@ -51,63 +99,102 @@ public static string GetTypeName(string typeName) if (typeName == null) throw new ArgumentNullException(nameof(typeName)); - var idx = typeName.IndexOf('`'); - if (idx >= 0) - typeName = typeName.Substring(0, idx); + var typeNameEnd = GetGenericTypeNameEnd(typeName); + if (!IsAttribute(typeName)) + return typeName; - if (IsAttribute(typeName)) - typeName = typeName.Substring(0, typeName.Length - AttributeSuffix.Length); + // Remove the 'Attribute' suffix while preserving any generic arity or type arguments + return typeName.Substring(0, typeNameEnd - AttributeSuffix.Length) + typeName.Substring(typeNameEnd); + } - return typeName; + /// + /// Gets the exclusive end index of a type's non-generic name. + /// + /// + /// Supports both CLR metadata names such as MarkerAttribute`1 and C# rendered names + /// such as global::Example.MarkerAttribute<string>. + /// + static int GetGenericTypeNameEnd(string typeName) + { + var metadataGenericStart = typeName.IndexOf('`'); + var renderedGenericStart = typeName.IndexOf('<'); + if (metadataGenericStart < 0) + return renderedGenericStart < 0 ? typeName.Length : renderedGenericStart; + if (renderedGenericStart < 0) + return metadataGenericStart; + + // If both generic indicators are present, return the earliest one to get the non-generic name end. + return Math.Min(metadataGenericStart, renderedGenericStart); } /// - /// Determines whether the target symbol has an explicit base type declaration. + /// Determines whether the target syntax has an explicit base type declaration. /// - public static bool HasExplicitBaseType(TargetSymbolDescriptor descriptor) + public static bool HasExplicitBaseType(BaseTypeDeclarationSyntax syntaxNode) { - if (descriptor == null) - throw new ArgumentNullException(nameof(descriptor)); - if (descriptor.Declaration == null) - return false; + if (syntaxNode == null) + throw new ArgumentNullException(nameof(syntaxNode)); // Check if the declaration has a base list with at least one type specified - return descriptor.Declaration.BaseList is { Types.Count: > 0 }; + return syntaxNode.BaseList is { Types.Count: > 0 }; } /// - /// Determines whether the target symbol is derived from the expected base type. + /// Determines whether the has a base type. Not that + /// if the base type is , it is considered to not have an explicit base type. /// - public static bool IsDerivedFromExpectedBase(TargetSymbolDescriptor descriptor, TypeValueObject expectedBase) + public static bool HasExplicitBaseType(ITypeSymbol symbol) { - if (descriptor == null) - throw new ArgumentNullException(nameof(descriptor)); - if (descriptor.Symbol.BaseType is not null) - { - if (IsCompatibleExpectedBase(descriptor.Symbol.BaseType, expectedBase)) - return true; - } + if (symbol == null) + throw new ArgumentNullException(nameof(symbol)); + + // Check if the declaration has a base list with at least one type specified + return symbol.BaseType is null or { SpecialType: not SpecialType.System_Object }; + } + + /// + /// Determines whether the target syntax is derived from the expected base type. + /// + public static bool IsDerivedFromExpectedBase(BaseTypeDeclarationSyntax syntax, TypeIdentity expectedBase) + { + if (syntax == null) + throw new ArgumentNullException(nameof(syntax)); - var declaredBaseTypes = descriptor.Declaration?.BaseList?.Types; + var declaredBaseTypes = syntax.BaseList?.Types; if (declaredBaseTypes is null) return false; foreach (var baseType in declaredBaseTypes) { - if ( - string.Equals( - GetUnqualifiedTypeName(baseType.Type), - expectedBase.MetadataFullName, - StringComparison.Ordinal - ) - ) + if (string.Equals(GetUnqualifiedTypeName(baseType.Type), expectedBase.Name, StringComparison.Ordinal)) + return true; + } + + return false; + } + + /// + /// Determines whether the target symbol is derived from the expected base type. + /// + /// + /// Open expected generic definitions match constructed base types. Constructed expected identities validate + /// generic arguments semantically, including interface implementation and inheritance compatibility. + /// + public static bool IsDerivedFromExpectedBase(ITypeSymbol symbol, TypeIdentity expectedBase) + { + if (symbol == null) + throw new ArgumentNullException(nameof(symbol)); + + if (symbol.BaseType is not null) + { + if (IsCompatibleExpectedBase(symbol.BaseType, expectedBase)) return true; } return false; } - static bool IsCompatibleExpectedBase(INamedTypeSymbol actualBase, TypeValueObject expectedBase) + static bool IsCompatibleExpectedBase(INamedTypeSymbol actualBase, TypeIdentity expectedBase) { if (expectedBase.Equals(actualBase)) return true; @@ -116,14 +203,14 @@ static bool IsCompatibleExpectedBase(INamedTypeSymbol actualBase, TypeValueObjec var actualNamespace = actualDefinition.ContainingNamespace.IsGlobalNamespace ? null : actualDefinition.ContainingNamespace.ToDisplayString(); - if (actualDefinition.Name != expectedBase.TypeName || actualNamespace != expectedBase.Namespace) + if (actualDefinition.Name != expectedBase.Name || actualNamespace != expectedBase.Namespace) return false; - // A name-only TypeValueObject has no generic shape information. Treat it as the generic - // definition identified by that name, allowing callers such as TypeLibrary.ResourceKitBase - // to validate any constructed ResourceKitBase. - if (expectedBase.GenericArity == 0 && expectedBase.TypeArguments.IsDefaultOrEmpty) - return true; + // An identity without constructed type arguments represents its type definition. Match + // any construction with the same name and arity. A name-only identity has arity zero and + // intentionally retains the historical name-only wildcard behavior. + if (expectedBase.TypeArguments.IsDefaultOrEmpty) + return expectedBase.GenericArity == 0 || actualDefinition.Arity == expectedBase.GenericArity; if ( actualDefinition.Arity != expectedBase.GenericArity @@ -141,7 +228,6 @@ static bool IsCompatibleExpectedBase(INamedTypeSymbol actualBase, TypeValueObjec var actualArgument = actualBase.TypeArguments[index]; if ( !expectedArgument.Equals(actualArgument) - && !MatchesFullyQualifiedName(actualArgument, expectedArgument.MetadataFullName) && !Implements(actualArgument, expectedArgument) && !InheritsFrom(actualArgument, expectedArgument) ) @@ -184,9 +270,9 @@ public static bool IsValidIdentifier(string? name) } /// - /// Creates a for the embedded compiler attribute used by source generators. + /// Creates a for the embedded compiler attribute used by source generators. /// - public static readonly TypeValueObject EmbeddedAttribute = new(nameof(EmbeddedAttribute), "Microsoft.CodeAnalysis"); + public static readonly TypeIdentity EmbeddedAttribute = new(nameof(EmbeddedAttribute), "Microsoft.CodeAnalysis"); /// /// Determines whether the type declaration is marked . @@ -256,7 +342,7 @@ static bool MatchesFullyQualifiedName(ISymbol symbol, string fullyQualifiedName) /// /// Determines whether the symbol has an attribute with the specified metadata name. /// - public static ImmutableArray GetAttributes(ISymbol symbol, TypeValueObject attributeType) => + public static ImmutableArray GetAttributes(ISymbol symbol, TypeIdentity attributeType) => GetAttributes(symbol, attributeType.MetadataFullName); /// @@ -289,7 +375,7 @@ attr.AttributeClass is not null /// /// Determines whether the symbol has an attribute with the specified metadata name. /// - public static AttributeData? GetAttribute(ISymbol symbol, TypeValueObject attributeType) => + public static AttributeData? GetAttribute(ISymbol symbol, TypeIdentity attributeType) => GetAttribute(symbol, attributeType.MetadataFullName); /// @@ -318,7 +404,7 @@ attr.AttributeClass is not null /// /// Determines whether the symbol has the specified attribute. /// - public static bool HasAttribute(ISymbol symbol, TypeValueObject attributeType) => + public static bool HasAttribute(ISymbol symbol, TypeIdentity attributeType) => HasAttribute(symbol, attributeType.MetadataFullName); /// @@ -367,7 +453,7 @@ public static bool InheritsFrom(ITypeSymbol typeSymbol, string fullyQualifiedNam /// /// Determines whether the type inherits from the specified base type. /// - public static bool InheritsFrom(ITypeSymbol typeSymbol, TypeValueObject baseType) => + public static bool InheritsFrom(ITypeSymbol typeSymbol, TypeIdentity baseType) => InheritsFrom(typeSymbol, baseType.MetadataFullName); /// @@ -381,7 +467,7 @@ public static bool Implements(ITypeSymbol typeSymbol, string fullyQualifiedName) /// /// Determines whether the type implements the specified interface. /// - public static bool Implements(ITypeSymbol typeSymbol, TypeValueObject interfaceType) => + public static bool Implements(ITypeSymbol typeSymbol, TypeIdentity interfaceType) => Implements(typeSymbol, interfaceType.MetadataFullName); /// diff --git a/src/src/SourceGeneratorShared/Helpers/TypeMapping.cs b/src/src/SourceGeneratorShared/Helpers/TypeMapping.cs new file mode 100644 index 0000000..8afcbe6 --- /dev/null +++ b/src/src/SourceGeneratorShared/Helpers/TypeMapping.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Helpers; + +/// +/// Represents a mapping between a .NET type, its corresponding SpecialType, and its C# keyword representation. +/// +public readonly record struct TypeMapping +{ + internal TypeMapping(Type type, SpecialType specialType, string keyword) + { + Type = type; + SpecialType = specialType; + Keyword = keyword; + } + + /// + /// Gets the .NET type associated with this mapping. + /// + public Type Type { get; } + + /// + /// Gets the SpecialType associated with this mapping. + /// + public SpecialType SpecialType { get; } + + /// + /// Gets the C# keyword representation of the type associated with this mapping. + /// + public string Keyword { get; } + + /// + /// Determines whether this instance is empty (i.e., has no associated type, special type, or keyword). + /// + public bool IsEmpty => this == Empty; + + /// + /// Defines an implicit conversion from TypeMapping to TypeValueObject. If the SpecialType is None, it returns an empty TypeValueObject; otherwise, it creates a new TypeValueObject with the specified SpecialType. + /// + /// The TypeMapping instance to convert. + public static implicit operator TypeIdentity(TypeMapping mapping) => + mapping.SpecialType == SpecialType.None ? TypeIdentity.Empty : new(mapping.SpecialType); + + /// + /// Represents an empty TypeMapping instance with no associated type, special type, or keyword. + /// + public static readonly TypeMapping Empty; +} diff --git a/src/src/SourceGeneratorShared/IGenerationCapabilities.cs b/src/src/SourceGeneratorShared/IGenerationCapabilities.cs new file mode 100644 index 0000000..9f67ff9 --- /dev/null +++ b/src/src/SourceGeneratorShared/IGenerationCapabilities.cs @@ -0,0 +1,11 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Defines the capabilities of a source generator, which can be used to determine what features are available during generation. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1040:Avoid empty interfaces", + Justification = "Used as a marker interface for generation capabilities so an analyzer can identify them and provide appropriate warnings or suggestions." +)] +public interface IGenerationCapabilities; diff --git a/src/src/SourceGeneratorFramework/Logging/ISourceGenLogger.cs b/src/src/SourceGeneratorShared/Logging/ISourceGenLogger.cs similarity index 100% rename from src/src/SourceGeneratorFramework/Logging/ISourceGenLogger.cs rename to src/src/SourceGeneratorShared/Logging/ISourceGenLogger.cs diff --git a/src/src/SourceGeneratorFramework/Logging/ISourceGenLoggerExtensions.cs b/src/src/SourceGeneratorShared/Logging/ISourceGenLoggerExtensions.cs similarity index 100% rename from src/src/SourceGeneratorFramework/Logging/ISourceGenLoggerExtensions.cs rename to src/src/SourceGeneratorShared/Logging/ISourceGenLoggerExtensions.cs diff --git a/src/src/SourceGeneratorFramework/Logging/SourceGenLogLevel.cs b/src/src/SourceGeneratorShared/Logging/SourceGenLogLevel.cs similarity index 100% rename from src/src/SourceGeneratorFramework/Logging/SourceGenLogLevel.cs rename to src/src/SourceGeneratorShared/Logging/SourceGenLogLevel.cs diff --git a/src/src/SourceGeneratorFramework/Logging/SourceGenLogger.cs b/src/src/SourceGeneratorShared/Logging/SourceGenLogger.cs similarity index 100% rename from src/src/SourceGeneratorFramework/Logging/SourceGenLogger.cs rename to src/src/SourceGeneratorShared/Logging/SourceGenLogger.cs diff --git a/src/src/SourceGeneratorFramework/Logging/SourceGenLogging.cs b/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs similarity index 93% rename from src/src/SourceGeneratorFramework/Logging/SourceGenLogging.cs rename to src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs index c3b6d51..ff2a213 100644 --- a/src/src/SourceGeneratorFramework/Logging/SourceGenLogging.cs +++ b/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs @@ -22,7 +22,7 @@ public static IDisposable RegisterSink(string sessionId, Action sink) + public static IDisposable RegisterSinkCore(string sessionId, Action sink) { if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Logging session ID cannot be null or whitespace.", nameof(sessionId)); @@ -35,7 +35,7 @@ internal static IDisposable RegisterSinkCore(string sessionId, Action + public static ISourceGenLogger? CreateLogger(string? sessionId) => string.IsNullOrWhiteSpace(sessionId) || !Sinks.ContainsKey(sessionId!) ? null : new SourceGenLogger(sessionId!); internal static void Write( diff --git a/src/src/SourceGeneratorFramework/MemberDeclarationOptions.cs b/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs similarity index 95% rename from src/src/SourceGeneratorFramework/MemberDeclarationOptions.cs rename to src/src/SourceGeneratorShared/MemberDeclarationOptions.cs index 6e4fc8b..d0be63f 100644 --- a/src/src/SourceGeneratorFramework/MemberDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs @@ -11,7 +11,7 @@ public readonly record struct MethodDeclarationOptions /// The optional accessibility. public MethodDeclarationOptions( string name, - TypeReferenceOptions returnType, + TypeReference returnType, TypeDeclarationAccessibility? accessibility = null ) { @@ -33,7 +33,7 @@ public MethodDeclarationOptions(string name, TypeDeclarationAccessibility? acces public string Name { get; } /// Gets the return type. - public TypeReferenceOptions ReturnType { get; } + public TypeReference ReturnType { get; } /// Gets the optional accessibility. public TypeDeclarationAccessibility? Accessibility { get; init; } @@ -91,7 +91,7 @@ public readonly record struct PropertyDeclarationOptions /// Creates a property declaration. public PropertyDeclarationOptions( string name, - TypeReferenceOptions type, + TypeReference type, TypeDeclarationAccessibility? accessibility = null ) { @@ -104,7 +104,7 @@ public PropertyDeclarationOptions( public string Name { get; } /// Gets the property type. - public TypeReferenceOptions Type { get; } + public TypeReference Type { get; } /// Gets the optional accessibility. public TypeDeclarationAccessibility? Accessibility { get; init; } @@ -163,11 +163,7 @@ public PropertyDeclarationOptions( public readonly record struct FieldDeclarationOptions { /// Creates a field declaration. - public FieldDeclarationOptions( - string name, - TypeReferenceOptions type, - TypeDeclarationAccessibility? accessibility = null - ) + public FieldDeclarationOptions(string name, TypeReference type, TypeDeclarationAccessibility? accessibility = null) { Name = name; Type = type; @@ -178,7 +174,7 @@ public FieldDeclarationOptions( public string Name { get; } /// Gets the field type. - public TypeReferenceOptions Type { get; } + public TypeReference Type { get; } /// Gets the optional accessibility. public TypeDeclarationAccessibility? Accessibility { get; init; } diff --git a/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs b/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs new file mode 100644 index 0000000..baf3e62 --- /dev/null +++ b/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs @@ -0,0 +1,41 @@ +namespace Purview.SourceGeneratorFramework; + +/// Describes one argument supplied to a generated method call. +public readonly record struct MethodCallArgumentOptions +{ + /// Creates a method-call argument from its value expression. + /// The argument expression or variable name. + /// An optional named-argument label. + /// The argument passing modifier. + public MethodCallArgumentOptions( + string value, + string? name = null, + ParameterModifier modifier = ParameterModifier.None + ) + { + Value = string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Argument value cannot be null or whitespace.", nameof(value)) + : value; + Name = name; + Modifier = modifier; + } + + /// + /// Creates a method-call argument from its value expression, with a specified argument passing modifier. + /// + /// The argument expression or variable name. + /// The argument passing modifier. + public MethodCallArgumentOptions(string value, ParameterModifier modifier) + : this(value, null, modifier) { } + + /// Gets the argument expression or variable name. + public string Value { get; } + + /// Gets an optional named-argument label. + public string? Name { get; init; } + + /// Gets the argument passing modifier. + public ParameterModifier Modifier { get; init; } + + public static implicit operator MethodCallArgumentOptions(string value) => new(value); +} diff --git a/src/src/SourceGeneratorShared/ObjectCreationOptions.cs b/src/src/SourceGeneratorShared/ObjectCreationOptions.cs new file mode 100644 index 0000000..ac78ae1 --- /dev/null +++ b/src/src/SourceGeneratorShared/ObjectCreationOptions.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// Describes a generated object-creation expression. +public readonly record struct ObjectCreationOptions +{ + /// Creates an object-creation expression. + /// The type to instantiate. + /// The constructor arguments; strings are implicitly supported. + public ObjectCreationOptions(TypeReference reference, params MethodCallArgumentOptions[] arguments) + { + if (reference.IsNullOrEmpty()) + throw new ArgumentException("Object-creation type cannot be empty.", nameof(reference)); + + Reference = reference; + Arguments = arguments is null ? [] : [.. arguments]; + } + + /// Gets the type to instantiate. + public TypeReference Reference { get; } + + /// Gets the constructor arguments. + public ImmutableArray Arguments { get; } + + /// Gets whether constructor arguments are written one per line. + public bool WriteArgumentsOnSeparateLines { get; init; } +} diff --git a/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs b/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs new file mode 100644 index 0000000..9db0200 --- /dev/null +++ b/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// Describes a generated method, constructor, delegate, or primary-constructor parameter. +public readonly record struct ParameterDeclarationOptions +{ + /// Creates a parameter declaration. + public ParameterDeclarationOptions( + string name, + TypeReference reference, + ParameterModifier modifier = ParameterModifier.None + ) + { + Name = name; + Reference = reference; + Modifier = modifier; + } + + /// Gets the parameter name. + public string Name { get; } + + /// Gets the parameter type. + public TypeReference Reference { get; } + + /// Gets the parameter passing modifier. + public ParameterModifier Modifier { get; init; } + + /// Gets whether this is emitted for an extension receiver. + public bool IsThis { get; init; } + + /// Gets whether params is emitted. + public bool IsParams { get; init; } + + /// Gets whether scoped is emitted. + public bool IsScoped { get; init; } + + /// Gets an optional default-value expression. + public string? DefaultValue { get; init; } + + /// Gets attributes applied to the parameter. + public ImmutableArray Attributes { get; init; } +} diff --git a/src/src/SourceGeneratorShared/ParameterModifier.cs b/src/src/SourceGeneratorShared/ParameterModifier.cs new file mode 100644 index 0000000..0ad6731 --- /dev/null +++ b/src/src/SourceGeneratorShared/ParameterModifier.cs @@ -0,0 +1,20 @@ +namespace Purview.SourceGeneratorFramework; + +/// Identifies a generated parameter modifier. +public enum ParameterModifier +{ + /// No modifier. + None, + + /// The ref modifier. + Ref, + + /// The out modifier. + Out, + + /// The in modifier. + In, + + /// The ref readonly modifier. + RefReadOnly, +} diff --git a/src/src/SourceGeneratorShared/Properties/AssemblyInfo.cs b/src/src/SourceGeneratorShared/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..4cea6f9 --- /dev/null +++ b/src/src/SourceGeneratorShared/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Purview.SourceGeneratorFramework")] +[assembly: InternalsVisibleTo("Purview.SourceGeneratorFramework.Generators")] diff --git a/src/src/SourceGeneratorFramework/PurviewTypeLibrary.cs b/src/src/SourceGeneratorShared/PurviewTypeLibrary.cs similarity index 55% rename from src/src/SourceGeneratorFramework/PurviewTypeLibrary.cs rename to src/src/SourceGeneratorShared/PurviewTypeLibrary.cs index 7602d5d..6f6e774 100644 --- a/src/src/SourceGeneratorFramework/PurviewTypeLibrary.cs +++ b/src/src/SourceGeneratorShared/PurviewTypeLibrary.cs @@ -3,7 +3,7 @@ namespace Purview.SourceGeneratorFramework; /// -/// Provides common instances for use by source generators. +/// Provides common instances for use by source generators. /// public static class PurviewTypeLibrary { @@ -18,112 +18,112 @@ public static class System /// /// . /// - public static readonly TypeValueObject Attribute = TypeValueObject.Create(); + public static readonly TypeIdentity Attribute = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Type = TypeValueObject.Create(); + public static readonly TypeIdentity Type = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Boolean = TypeValueObject.Create(); + public static readonly TypeIdentity Boolean = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Byte = TypeValueObject.Create(); + public static readonly TypeIdentity Byte = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject SByte = TypeValueObject.Create(); + public static readonly TypeIdentity SByte = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Char = TypeValueObject.Create(); + public static readonly TypeIdentity Char = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Decimal = TypeValueObject.Create(); + public static readonly TypeIdentity Decimal = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Double = TypeValueObject.Create(); + public static readonly TypeIdentity Double = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Float = TypeValueObject.Create(); + public static readonly TypeIdentity Float = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Int32 = TypeValueObject.Create(); + public static readonly TypeIdentity Int32 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject UInt32 = TypeValueObject.Create(); + public static readonly TypeIdentity UInt32 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Int64 = TypeValueObject.Create(); + public static readonly TypeIdentity Int64 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject UInt64 = TypeValueObject.Create(); + public static readonly TypeIdentity UInt64 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Int16 = TypeValueObject.Create(); + public static readonly TypeIdentity Int16 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject UInt16 = TypeValueObject.Create(); + public static readonly TypeIdentity UInt16 = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject String = TypeValueObject.Create(); + public static readonly TypeIdentity String = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Object = TypeValueObject.Create(); + public static readonly TypeIdentity Object = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Void = new("void", null); + public static readonly TypeIdentity Void = new("void", null); /// /// . /// - public static readonly TypeValueObject IntPtr = TypeValueObject.Create(); + public static readonly TypeIdentity IntPtr = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject UIntPtr = TypeValueObject.Create(); + public static readonly TypeIdentity UIntPtr = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Action = TypeValueObject.Create(); + public static readonly TypeIdentity Action = TypeIdentity.Create(); /// /// . /// - public static readonly TypeValueObject Func = new(nameof(Func), "System"); + public static readonly TypeIdentity Func = new(nameof(Func), "System"); } /// @@ -143,8 +143,10 @@ public static class CodeAnalysis /// /// . /// - public static readonly TypeValueObject EmbeddedAttribute = - TypeValueObject.Create(); + public static readonly TypeIdentity EmbeddedAttribute = new( + nameof(EmbeddedAttribute), + "Microsoft.CodeAnalysis" + ); } } } diff --git a/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs b/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs new file mode 100644 index 0000000..3163667 --- /dev/null +++ b/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Represents information about a resolved type, including its reference and declared accessibility. +/// +/// The reference to the resolved type. +/// The declared accessibility of the resolved type. +public readonly record struct ResolvedTypeInformation(TypeReference Reference, Accessibility DeclaredAccessibility) +{ + /// + /// Gets the type value object associated with the resolved type reference. + /// + /// Retrieved from + public TypeIdentity Type => Reference.Identity; +} diff --git a/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs b/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs new file mode 100644 index 0000000..4a9e56e --- /dev/null +++ b/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs @@ -0,0 +1,25 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Contains the MSBuild properties used by the source-generator framework. +/// +public static class SourceGeneratorBuildProperties +{ + public const string BuildProperty = "build_property."; + + /// + /// The MSBuild property that controls validation of undisposed code-writer scopes. + /// + public const string ValidateCodeWriterScopes = + BuildProperty + "PurviewSourceGeneratorFrameworkValidateCodeWriterScopes"; + + /// + /// The MSBuild property that enables source-generator logging. + /// + public const string EnableLogging = BuildProperty + "PurviewSourceGeneratorFrameworkEnableLogging"; + + /// + /// The MSBuild property that identifies the registered logging sink for a generator run. + /// + public const string LoggingSessionId = BuildProperty + "PurviewSourceGeneratorFrameworkLoggingSessionId"; +} diff --git a/src/src/SourceGeneratorShared/SourceGeneratorShared.csproj b/src/src/SourceGeneratorShared/SourceGeneratorShared.csproj new file mode 100644 index 0000000..a1722f9 --- /dev/null +++ b/src/src/SourceGeneratorShared/SourceGeneratorShared.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + true + $(NamespacePrefix).Shared + $(NamespacePrefix) + false + $(NamespacePrefix).Shared + + + + + + + diff --git a/src/src/SourceGeneratorShared/SymbolTypeResolver.cs b/src/src/SourceGeneratorShared/SymbolTypeResolver.cs new file mode 100644 index 0000000..dc0cb4a --- /dev/null +++ b/src/src/SourceGeneratorShared/SymbolTypeResolver.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Resolves the type carried by any symbol, so that matching can be applied uniformly to members. +/// +public static class SymbolTypeResolver +{ + /// + /// Returns the type of the given symbol: the declared type for fields, properties, events, parameters and + /// locals, the return type for methods, the target for aliases, and the symbol itself for type symbols. + /// + public static ITypeSymbol? Resolve(ISymbol? symbol) => + symbol switch + { + ITypeSymbol type => type, + IFieldSymbol field => field.Type, + IPropertySymbol property => property.Type, + IMethodSymbol method => method.ReturnType, + IEventSymbol @event => @event.Type, + IParameterSymbol parameter => parameter.Type, + ILocalSymbol local => local.Type, + IDiscardSymbol discard => discard.Type, + IAliasSymbol alias => alias.Target as ITypeSymbol, + _ => null, + }; +} diff --git a/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs b/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs new file mode 100644 index 0000000..8b1e623 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs @@ -0,0 +1,28 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies an optional C# accessibility modifier for a generated type. +/// +public enum TypeDeclarationAccessibility +{ + /// The public accessibility modifier. + Public, + + /// The internal accessibility modifier. + Internal, + + /// The protected accessibility modifier. + Protected, + + /// The private accessibility modifier. + Private, + + /// The protected internal accessibility modifier. + ProtectedInternal, + + /// The private protected accessibility modifier. + PrivateProtected, + + /// The file accessibility modifier. + File, +} diff --git a/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs b/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs new file mode 100644 index 0000000..7949b37 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs @@ -0,0 +1,58 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class TypeDeclarationAccessibilityExtensions +{ + /// + /// Converts a Roslyn value to the corresponding + /// value. + /// + /// The Roslyn accessibility value. + /// + /// The corresponding declaration accessibility, or when Roslyn reports + /// or an unknown future value. + /// + /// This method never throws for an accessibility value. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public static TypeDeclarationAccessibility? ToTypeDeclarationAccessibility(this Accessibility accessibility) => + accessibility switch + { + Accessibility.Private => TypeDeclarationAccessibility.Private, + Accessibility.ProtectedAndInternal => TypeDeclarationAccessibility.PrivateProtected, + Accessibility.Protected => TypeDeclarationAccessibility.Protected, + Accessibility.Internal => TypeDeclarationAccessibility.Internal, + Accessibility.ProtectedOrInternal => TypeDeclarationAccessibility.ProtectedInternal, + Accessibility.Public => TypeDeclarationAccessibility.Public, + _ => null, + }; + + /// + /// Converts a declaration accessibility value to the corresponding Roslyn + /// value. + /// + /// The declaration accessibility value. + /// + /// The corresponding Roslyn accessibility, or for + /// or an unknown future value. + /// + /// + /// Roslyn represents file-local accessibility separately from , so + /// has no direct mapping. This method never + /// throws for an accessibility value. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public static Accessibility ToRoslynAccessibility(this TypeDeclarationAccessibility accessibility) => + accessibility switch + { + TypeDeclarationAccessibility.Private => Accessibility.Private, + TypeDeclarationAccessibility.PrivateProtected => Accessibility.ProtectedAndInternal, + TypeDeclarationAccessibility.Protected => Accessibility.Protected, + TypeDeclarationAccessibility.Internal => Accessibility.Internal, + TypeDeclarationAccessibility.ProtectedInternal => Accessibility.ProtectedOrInternal, + TypeDeclarationAccessibility.Public => Accessibility.Public, + _ => Accessibility.NotApplicable, + }; +} diff --git a/src/src/SourceGeneratorShared/TypeDeclarationKind.cs b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs new file mode 100644 index 0000000..c4e3406 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs @@ -0,0 +1,28 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies the C# declaration emitted for a generated type. +/// +public enum TypeDeclarationKind +{ + /// A class declaration. + Class, + + /// A struct declaration. + Struct, + + /// A record class declaration. + RecordClass, + + /// A record struct declaration. + RecordStruct, + + /// An interface declaration. + Interface, + + /// An enum declaration. + Enum, + + /// A delegate declaration. + Delegate, +} diff --git a/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs new file mode 100644 index 0000000..94e3c24 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs @@ -0,0 +1,140 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Describes a generated class, struct, record, interface, enum, or delegate declaration. +/// +public sealed record TypeDeclarationOptions +{ + /// + /// Initializes a type declaration description. + /// + /// The generated type name without generic parameters. + /// The optional accessibility modifier, or to omit accessibility. + public TypeDeclarationOptions(string name, TypeDeclarationAccessibility? accessibility = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Type name cannot be null or whitespace.", nameof(name)); + + Name = name; + Accessibility = accessibility; + } + + /// + /// Initializes a type declaration description from a . + /// + /// The type value object. + /// The optional accessibility modifier, or to omit accessibility. + public TypeDeclarationOptions(TypeIdentity type, TypeDeclarationAccessibility? accessibility = null) + { + Name = type.Name; + Accessibility = accessibility; + } + + /// + /// Gets the generated type name without generic parameters. + /// + public string Name { get; } + + /// + /// Gets the declaration kind. The default is . + /// + public TypeDeclarationKind Kind { get; init; } = TypeDeclarationKind.Class; + + /// + /// Gets the accessibility modifier, or to omit accessibility. + /// + public TypeDeclarationAccessibility? Accessibility { get; init; } + + /// + /// Gets whether the partial modifier is emitted. The default is . + /// + public bool IsPartial { get; init; } = true; + + /// + /// Gets whether the sealed modifier is emitted for a class or record class. + /// The default is . This option is ignored for struct declarations. + /// + public bool IsSealed { get; init; } = true; + + /// + /// Gets whether the abstract modifier is emitted for a class or record class. + /// + /// + /// Abstract declarations take precedence over the default value. + /// + public bool IsAbstract { get; init; } + + /// + /// Gets whether the static modifier is emitted for a class declaration. + /// + /// + /// Static classes cannot declare a base type, implement interfaces, or declare + /// primary-constructor parameters. is ignored when this value is + /// . + /// + public bool IsStatic { get; init; } + + /// + /// Gets whether the readonly modifier is emitted for a struct or record struct. + /// + public bool IsReadOnly { get; init; } + + /// + /// Gets the optional base class or base record type. + /// + /// Struct and record struct declarations cannot specify a base type. + public TypeReference? BaseType { get; init; } + + /// Gets the optional enum underlying integral type. + public TypeReference? EnumUnderlyingType { get; init; } + + /// Gets the delegate return type. + public TypeReference? DelegateReturnType { get; init; } + + /// Gets the complete delegate parameter declarations. + public ImmutableArray DelegateParameters { get; init; } = []; + + /// + /// Gets the interfaces implemented by the generated type, or inherited by an interface. + /// + public ImmutableArray Interfaces { get; init; } = []; + + /// + /// Gets the generic type parameters and their constraints. + /// + public ImmutableArray GenericTypes { get; init; } = []; + + /// + /// Gets the primary-constructor parameters written after the type name and generic parameters. + /// + /// Each entry is emitted verbatim as a complete parameter declaration. + public ImmutableArray PrimaryConstructorParameters { get; init; } = []; + + /// + /// If , the primary-constructor parameters are emitted on separate lines with one parameter per line. + /// + public bool ConstructorParametersOnSeparateLines { get; init; } + + /// + /// Gets the attributes applied to the generated type. + /// + public ImmutableArray Attributes { get; init; } = []; + + /// + /// Gets whether to emit on the type. + /// When , WriteAttributeClass enables it and other type-writing + /// APIs leave it disabled. Set this explicitly to to opt a generated + /// attribute out of embedding. + /// + public bool? IncludeEmbeddedAttribute { get; init; } + + /// + /// Gets whether to process any generated attributes, such as and . + /// is ignored if this is . + /// When , the value is inherited from . + /// + public bool? IncludeGeneratedAttributes { get; init; } +} diff --git a/src/src/SourceGeneratorShared/TypeIdentity.cs b/src/src/SourceGeneratorShared/TypeIdentity.cs new file mode 100644 index 0000000..5b6fc8f --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeIdentity.cs @@ -0,0 +1,992 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Represents a semantic reference to a named type, independent of any single compilation. +/// +/// +/// +/// This value object models named type identity. Arrays, pointers, function pointers, +/// , generic parameters and error types are not identities and are modelled by +/// , which is also the element type of . +/// +/// +/// Matching against an is structural — name, namespace, containing-type +/// chain and generic shape — rather than symbolic, so a value created against one compilation can be matched +/// against another. +/// +/// +/// Generic definitions and constructed generic types are intentionally distinct values. For example, +/// new TypeIdentity(typeof(Dictionary<,>)) represents the open definition, while calling +/// supplies concrete arguments. Structural +/// requires the same generic construction; symbol +/// is asymmetric and allows an open definition to match any constructed +/// symbol having the same definition. +/// +/// +/// Construction is on the hot path of every generator pipeline, so it deliberately avoids +/// ToDisplayString, GetGenericArguments and builder growth. The common cases — a non-nested, +/// non-generic type — allocate nothing beyond the namespace string. +/// +/// +public readonly record struct TypeIdentity +{ + /// + /// Initializes a new instance of the struct from a . + /// + /// Thrown when the provided type is null. + /// + /// Thrown when the type is not a named type. Use when the + /// input may not be representable, or + /// to capture array, pointer and nullable composition. + /// + public TypeIdentity(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + + if (!IsRepresentable(type)) + { + throw new ArgumentException( + $"The type '{type}' is not a named type and cannot be represented by {nameof(TypeIdentity)}. Use {nameof(TypeReference)} for composed references.", + nameof(type) + ); + } + + var knownType = KnownLangTypes.Get(type); + if (knownType != TypeMapping.Empty) + { + Name = knownType.Type.Name; + Namespace = knownType.Type.Namespace; + Keyword = knownType.Keyword; + SpecialType = knownType.SpecialType; + ContainingTypes = []; + TypeArguments = []; + + return; + } + + Name = StripArity(type.Name); + Namespace = string.IsNullOrEmpty(type.Namespace) ? null : type.Namespace; + ContainingTypes = BuildContainingTypes(type, out var consumed); + + // The metadata name's backtick suffix already encodes the type's *own* arity, excluding any + // inherited from containing types, so there is no need to materialise GetGenericArguments() here. + GenericArity = ParseArity(type.Name); + + TypeArguments = + GenericArity == 0 || type.IsGenericTypeDefinition + ? [] + : BuildArguments(type.GetGenericArguments(), consumed, GenericArity); + } + + /// + /// Initializes a new instance of the struct from a type name and namespace. + /// + /// Note: This constructor does not validate the provided type name or namespace. It is the caller's + /// responsibility to ensure the values represent a real type. For keyword types prefer + /// , or + /// . + /// + /// + /// This produces a top-level, non-generic type. Use for nested types and + /// for constructed generics. + /// + /// + public TypeIdentity(string typeName, string? @namespace) + { + if (string.IsNullOrWhiteSpace(typeName)) + throw new ArgumentException("Type name cannot be null, empty or whitespace.", nameof(typeName)); + + Name = typeName; + Namespace = string.IsNullOrWhiteSpace(@namespace) ? null : @namespace; + GenericArity = 0; + ContainingTypes = []; + TypeArguments = []; + } + + /// + /// Initializes a new instance of the struct from a Roslyn type symbol. + /// + /// Thrown when the provided symbol is null. + /// + /// Thrown when the symbol is not a resolvable named type. Use + /// inside generator pipelines, where unresolved and + /// composed symbols are routine. + /// + public TypeIdentity(ITypeSymbol typeSymbol) + { + if (typeSymbol == null) + throw new ArgumentNullException(nameof(typeSymbol)); + + if (typeSymbol is not INamedTypeSymbol namedType || !IsRepresentable(namedType)) + { + throw new ArgumentException( + $"The symbol '{typeSymbol.ToDisplayString()}' is not a resolvable named type and cannot be represented by {nameof(TypeIdentity)}. Use {nameof(TryCreate)} for a non-throwing conversion.", + nameof(typeSymbol) + ); + } + + var assembly = namedType.ContainingAssembly; + if (assembly is not null) + { + var cache = AssemblySymbolCache.GetValue(assembly, AssemblyCacheFactory); + this = cache.GetOrAdd(typeSymbol, SymbolCacheFactory).Value; + } + else + { + this = CreateFromSymbol(typeSymbol).Value; + } + } + + static readonly ConditionalWeakTable< + IAssemblySymbol, + ConcurrentDictionary + > AssemblySymbolCache = new(); + static readonly ConditionalWeakTable< + IAssemblySymbol, + ConcurrentDictionary + >.CreateValueCallback AssemblyCacheFactory = static assembly => new ConcurrentDictionary< + ITypeSymbol, + TypeIdentityCacheEntry + >(TypeSymbolEqualityComparer.Instance); + static readonly Func SymbolCacheFactory = CreateFromSymbol; + + static TypeIdentityCacheEntry CreateFromSymbol(ITypeSymbol typeSymbol) + { + var namedType = (INamedTypeSymbol)typeSymbol; + var knownType = KnownLangTypes.Get(namedType.SpecialType); + + return knownType == TypeMapping.Empty + ? new TypeIdentityCacheEntry( + new TypeIdentity + { + Name = namedType.Name, + Namespace = BuildNamespace(namedType.ContainingNamespace), + ContainingTypes = BuildContainingTypes(namedType), + GenericArity = namedType.Arity, + TypeArguments = BuildArguments(namedType), + } + ) + : new TypeIdentityCacheEntry( + new TypeIdentity + { + Name = knownType.Type.Name, + Namespace = knownType.Type.Namespace, + Keyword = knownType.Keyword, + SpecialType = knownType.SpecialType, + ContainingTypes = [], + TypeArguments = [], + } + ); + } + + sealed class TypeSymbolEqualityComparer : IEqualityComparer + { + public static readonly TypeSymbolEqualityComparer Instance = new(); + + public bool Equals(ITypeSymbol? x, ITypeSymbol? y) => SymbolEqualityComparer.Default.Equals(x, y); + + public int GetHashCode(ITypeSymbol obj) => SymbolEqualityComparer.Default.GetHashCode(obj); + } + + sealed class TypeIdentityCacheEntry(TypeIdentity value) + { + public TypeIdentity Value = value; + } + + /// + /// Initializes a new instance of the struct from a recognized C# keyword + /// special type. + /// + public TypeIdentity(SpecialType specialType) + { + var knownType = KnownLangTypes.Get(specialType); + if (knownType == TypeMapping.Empty) + { + throw new ArgumentException( + $"The provided special type '{specialType}' is not a recognized C# keyword type.", + nameof(specialType) + ); + } + + Name = knownType.Type.Name; + Namespace = knownType.Type.Namespace; + Keyword = knownType.Keyword; + SpecialType = knownType.SpecialType; + ContainingTypes = []; + TypeArguments = []; + } + + /// + /// Gets the recognized C# keyword special type, or when the type is not a + /// recognized keyword type. + /// + /// + /// Populated only for C# keyword types. Roslyn populates for many + /// non-keyword types as well — System.DateTime, System.IDisposable, System.Nullable<T> + /// and others — so a special type mismatch is never on its own grounds for rejecting a match. + /// + public SpecialType SpecialType { get; init; } = SpecialType.None; + + /// + /// Gets the C# keyword for the type, or when the type has no keyword representation. + /// + public string? Keyword { get; init; } + + /// + /// Gets the type name without its namespace, containing types or generic arity suffix. + /// + public string Name { get; init; } + + /// + /// Gets the namespace, or when the type is in the global namespace. + /// + public string? Namespace { get; init; } + + /// + /// Gets the chain of containing types, outermost first, for a nested type. + /// + /// + /// Each entry is a lightweight carrying only a name and its own generic + /// arity; the namespace belongs to the chain as a whole and lives on this value instead. + /// + public ImmutableArray ContainingTypes { get; init; } + + /// + /// Gets the number of generic parameters declared by the type itself, excluding those inherited from + /// containing types. + /// + public int GenericArity { get; init; } + + /// + /// Gets the generic type arguments for a constructed type. + /// + /// + /// Empty for a non-generic type and for an open generic definition; use to + /// distinguish those cases. Arguments are so that composed arguments — + /// List<int[]>, List<T>, List<string?> — are represented exactly + /// rather than widened to the open definition. + /// + public ImmutableArray TypeArguments { get; init; } + + /// + /// Gets a value indicating whether this value represents an open generic type definition. + /// + public bool IsGenericTypeDefinition => GenericArity > 0 && TypeArguments.IsDefaultOrEmpty; + + /// Gets a value indicating whether the type is nested inside another type. + public bool IsNested => !ContainingTypes.IsDefaultOrEmpty; + + /// Gets a value indicating whether the type is in the global namespace. + public bool IsGlobalNamespace => Namespace is null; + + /// + /// Gets a value indicating whether this type's simple name uses the conventional C# attribute suffix. + /// + /// + /// This describes the type name only. Whether the type is emitted as an attribute application is determined + /// by . + /// + public bool IsAttribute => + Name.Length > TypeHelpers.AttributeSuffix.Length + && Name.EndsWith(TypeHelpers.AttributeSuffix, StringComparison.Ordinal); + + /// + /// Gets the CLR metadata name, including the generic arity suffix when required. + /// + public string MetadataName => GenericArity == 0 ? Name : $"{Name}`{GenericArity}"; + + /// + /// Gets the fully-qualified CLR metadata name used by Roslyn type lookup, using + to separate nested + /// types as required by Compilation.GetTypeByMetadataName and ForAttributeWithMetadataName. + /// + public string MetadataFullName + { + get + { + var name = MetadataName; + if (IsNested) + name = $"{string.Join("+", ContainingTypes.Select(static type => type.MetadataName))}+{name}"; + + return IsGlobalNamespace ? name : $"{Namespace}.{name}"; + } + } + + /// + /// Gets the fully-qualified global type name for use in generated code. + /// + public string RenderFullName => RenderFullNameCore(omitAttributeSuffix: false); + + /// + /// Gets the fully-qualified name for use in an attribute application, omitting the optional + /// Attribute suffix from the outer type name. + /// + public string RenderAttributeName => RenderFullNameCore(omitAttributeSuffix: true); + + string RenderFullNameCore(bool omitAttributeSuffix) + { + if (SpecialType != SpecialType.None) + return Keyword!; + + var name = RenderTypeNameCore(omitAttributeSuffix); + if (IsNested) + name = $"{string.Join(".", ContainingTypes.Select(static type => type.RenderTypeName))}.{name}"; + + return IsGlobalNamespace ? name : $"global::{Namespace}.{name}"; + } + + /// + /// Gets the type name suitable for use in generated code, without namespace or containing types. + /// + public string RenderTypeName => RenderTypeNameCore(omitAttributeSuffix: false); + + /// + /// Gets the unqualified type name for use in an attribute application, omitting the optional + /// Attribute suffix while retaining generic arguments. + /// + public string RenderAttributeTypeName => RenderTypeNameCore(omitAttributeSuffix: true); + + string RenderTypeNameCore(bool omitAttributeSuffix) + { + if (SpecialType != SpecialType.None) + return Keyword!; + + var name = + omitAttributeSuffix && IsAttribute + ? Name.Substring(0, Name.Length - TypeHelpers.AttributeSuffix.Length) + : Name; + + if (GenericArity == 0) + return name; + + // Render the open generic definition with commas for each type parameter, or the constructed form with + return TypeArguments.IsDefaultOrEmpty + ? $"{name}<{new string(',', GenericArity - 1)}>" + : $"{name}<{string.Join(", ", TypeArguments.Select(static argument => argument.RenderFullName))}>"; + } + + /// + /// Returns the rendered full name. + /// + public override string ToString() => RenderFullName; + + // --------------------------------------------------------------------------------------------- + // Matching + // --------------------------------------------------------------------------------------------- + + /// + /// Determines whether the specified represents this type. + /// + /// + /// when the symbol represents this type; otherwise . An open + /// generic definition matches every constructed form of that definition. + /// + public bool Matches(ITypeSymbol? other) + { + if (other is null) + return false; + + // Special types are unique, so a positive match here is conclusive and cheap. + // A mismatch is NOT conclusive: this value only stamps SpecialType for C# keyword types, whereas + // Roslyn stamps it for DateTime, IDisposable, Nullable, IEnumerable and more. + if (SpecialType != SpecialType.None && SpecialType == other.SpecialType) + return true; + + // Arrays, pointers, function pointers and dynamic are modelled by TypeReferenceOptions. Their symbols + // also expose a null ContainingNamespace, so they must be excluded before it is read. + if (other is not INamedTypeSymbol namedType || !IsRepresentable(namedType)) + return false; + + // Ordered cheapest-first: name and arity reject almost everything before any chain walking. + if (!string.Equals(Name, namedType.Name, StringComparison.Ordinal)) + return false; + + if (GenericArity != namedType.Arity) + return false; + + if (!ContainingTypesMatch(namedType.ContainingType)) + return false; + + if (!NamespaceMatches(Namespace, namedType.ContainingNamespace)) + return false; + + // An open definition represents every constructed form of that definition. + if (TypeArguments.IsDefaultOrEmpty) + return true; + + var otherArguments = namedType.TypeArguments; + if (TypeArguments.Length != otherArguments.Length) + return false; + + for (var index = 0; index < TypeArguments.Length; index++) + { + if (!TypeArguments[index].Matches(otherArguments[index])) + return false; + } + + return true; + } + + /// + /// Determines whether the type of the specified symbol is this type. + /// + /// + /// Fields, properties, events, parameters and locals resolve to their declared type; methods resolve to + /// their return type; aliases resolve to their target. Type symbols are matched directly. + /// + public bool Matches(ISymbol? other) => Matches(SymbolTypeResolver.Resolve(other)); + + /// + /// Determines whether the specified represents this type. + /// + /// + /// An alias for retained for call-site ergonomics. It is deliberately + /// not surfaced through : the relation is asymmetric — an open definition + /// matches its constructions — and cannot be made consistent with a symbol's own hash code. + /// + public bool Equals(ITypeSymbol? other) => Matches(other); + + /// Determines whether the specified runtime type represents the same semantic type. + public bool Equals(Type? other) => other is not null && TryCreate(other, out var value) && Equals(value); + + /// Determines whether the specified structured reference is an unmodified reference to this type. + public bool Equals(TypeReference? other) => other is not null && other.Equals(this); + + /// + /// Determines whether the specified value represents the same type. + /// + public bool Equals(TypeIdentity other) => + string.Equals(Name, other.Name, StringComparison.Ordinal) + && GenericArity == other.GenericArity + && SpecialType == other.SpecialType + && string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) + && string.Equals(Keyword, other.Keyword, StringComparison.Ordinal) + && ContainingTypesEqual(ContainingTypes, other.ContainingTypes) + && TypeArgumentsEqual(TypeArguments, other.TypeArguments); + + /// + /// Returns a structural hash code for this type, its containing types and its generic arguments. + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = Name is null ? 0 : StringComparer.Ordinal.GetHashCode(Name); + hashCode = (hashCode * 397) ^ (Namespace is null ? 0 : StringComparer.Ordinal.GetHashCode(Namespace)); + hashCode = (hashCode * 397) ^ (Keyword is null ? 0 : StringComparer.Ordinal.GetHashCode(Keyword)); + hashCode = (hashCode * 397) ^ (int)SpecialType; + hashCode = (hashCode * 397) ^ GenericArity; + + if (!ContainingTypes.IsDefaultOrEmpty) + { + foreach (var containingType in ContainingTypes) + hashCode = (hashCode * 397) ^ containingType.GetHashCode(); + } + + if (!TypeArguments.IsDefaultOrEmpty) + { + foreach (var argument in TypeArguments) + hashCode = (hashCode * 397) ^ (argument?.GetHashCode() ?? 0); + } + + return hashCode; + } + } + + /// + /// Implicitly converts a to its rendered full name. + /// + public static implicit operator string(TypeIdentity typeValueObject) => typeValueObject.RenderFullName; + + // --------------------------------------------------------------------------------------------- + // Composition + // --------------------------------------------------------------------------------------------- + + /// Creates the canonical source-generation type reference for this type. + public TypeReference AsTypeReference() => new(this); + + /// Creates a nullable structured type reference. + public TypeReference MakeNullable() => AsTypeReference().Nullable(); + + /// Creates an array structured type reference with the specified rank. + public TypeReference MakeArray(int rank = 1) => AsTypeReference().MakeArray(rank); + + /// Creates a pointer structured type reference. + public TypeReference MakePointer() => AsTypeReference().MakePointer(); + + /// + /// Creates a value describing a type nested inside this one. + /// + /// The nested type's simple name. + /// The nested type's own generic arity. + public TypeIdentity Nested(string typeName, int genericArity = 0) + { + if (typeName == null) + throw new ArgumentNullException(nameof(typeName)); + + if (SpecialType != SpecialType.None) + throw new InvalidOperationException($"Cannot nest a type inside the special type '{SpecialType}'."); + + var existing = ContainingTypes.IsDefaultOrEmpty ? 0 : ContainingTypes.Length; + var chain = ImmutableArray.CreateBuilder(existing + 1); + + if (existing > 0) + chain.AddRange(ContainingTypes); + + chain.Add(new ContainingType(Name, GenericArity)); + + return new TypeIdentity + { + Name = typeName, + Namespace = Namespace, + ContainingTypes = chain.MoveToImmutable(), + GenericArity = genericArity, + TypeArguments = [], + }; + } + + /// + /// Creates a generic variant of this type from type argument names. + /// + /// + /// Each string is a literal named type argument, not a wildcard. For example, + /// MakeGeneric("TKey", "TValue") describes arguments literally named TKey and + /// TValue; it does not mean “any two arguments.” Leave an identity created from + /// typeof(Dictionary<,>) unconstructed to match any construction of that definition. + /// + public TypeIdentity MakeGeneric(params string[] typeArguments) + { + if (typeArguments == null) + throw new ArgumentNullException(nameof(typeArguments)); + + var references = new TypeReference[typeArguments.Length]; + for (var index = 0; index < typeArguments.Length; index++) + references[index] = new TypeReference(new TypeIdentity(typeArguments[index], null)); + + return MakeGeneric(references); + } + + /// + /// Creates a constructed generic type using the specified named type arguments. + /// + /// + /// Supplying arguments creates a typed construction used by structural equality and matching. To represent + /// an open generic definition, do not call this method; construct the identity from an open runtime type or + /// Roslyn original definition instead. + /// + public TypeIdentity MakeGeneric(params TypeIdentity[] typeArguments) + { + if (typeArguments == null) + throw new ArgumentNullException(nameof(typeArguments)); + + var references = new TypeReference[typeArguments.Length]; + for (var index = 0; index < typeArguments.Length; index++) + references[index] = typeArguments[index].AsTypeReference(); + + return MakeGeneric(references); + } + + /// + /// Creates a constructed generic type using the specified composed type arguments. + /// + /// + /// Use this overload when an argument is itself composed, such as an array, nullable type, pointer, generic + /// parameter or nested generic construction. Arguments are compared as real type references, not placeholders. + /// + public TypeIdentity MakeGeneric(params TypeReference[] typeArguments) + { + if (typeArguments == null) + throw new ArgumentNullException(nameof(typeArguments)); + + if (typeArguments.Length == 0) + throw new ArgumentException("At least one type argument must be provided.", nameof(typeArguments)); + + if (GenericArity > 0 && typeArguments.Length != GenericArity) + { + throw new ArgumentException( + $"Type '{MetadataFullName}' requires {GenericArity} type arguments, but {typeArguments.Length} were supplied.", + nameof(typeArguments) + ); + } + + if (SpecialType != SpecialType.None) + throw new InvalidOperationException($"Cannot create a generic type from the special type '{SpecialType}'."); + + // The new generic arity is the existing arity if already set, otherwise the number of arguments supplied. + return this with + { + GenericArity = GenericArity == 0 ? typeArguments.Length : GenericArity, + TypeArguments = [.. typeArguments], + }; + } + + // --------------------------------------------------------------------------------------------- + // Factories + // --------------------------------------------------------------------------------------------- + + /// Gets an empty . + public static readonly TypeIdentity Empty; + + /// + /// Creates a from a runtime type. + /// + /// + /// Runtime values do not retain nullable-reference annotations. To represent + /// string?, create the string value then call , or use + /// when working + /// with Roslyn symbols. + /// + public static TypeIdentity Create() => new(typeof(T)); + + /// + /// Attempts to create a from a type symbol, returning + /// for symbols that cannot be represented. + /// + /// + /// Prefer this inside generator and analyzer pipelines, where array, pointer, type-parameter and unresolved + /// error symbols are routine and must not throw. + /// + public static bool TryCreate(ITypeSymbol? typeSymbol, out TypeIdentity value) + { + if (typeSymbol is INamedTypeSymbol namedType && IsRepresentable(namedType)) + { + value = new TypeIdentity(namedType); + + return true; + } + + value = Empty; + + return false; + } + + /// + /// Attempts to create a from a runtime type, returning + /// for types that cannot be represented. + /// + public static bool TryCreate(Type? type, out TypeIdentity value) + { + if (type is not null && IsRepresentable(type)) + { + value = new TypeIdentity(type); + + return true; + } + + value = Empty; + + return false; + } + + // --------------------------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------------------------- + + /// + /// Determines whether a symbol is a resolvable named type this value object can describe. + /// + static bool IsRepresentable(ITypeSymbol? typeSymbol) => + typeSymbol is INamedTypeSymbol + && typeSymbol.TypeKind + is not ( + TypeKind.Error + or TypeKind.Dynamic + or TypeKind.Pointer + or TypeKind.FunctionPointer + or TypeKind.Array + or TypeKind.TypeParameter + or TypeKind.Submission + ); + + static bool IsRepresentable(Type type) => + !type.IsArray && !type.IsPointer && !type.IsByRef && !type.IsGenericParameter; + + /// + /// Builds a dotted namespace string from a namespace symbol chain in a single allocation. + /// + /// + /// Replaces ContainingNamespace.ToDisplayString(), which routes through Roslyn's full symbol-display + /// machinery — a per-symbol cost that dominates construction in a generator processing thousands of types. + /// + static string? BuildNamespace(INamespaceSymbol? @namespace) + { + if (@namespace is null || @namespace.IsGlobalNamespace) + return null; + + // Overwhelmingly the common case: a single segment, or a namespace whose name is already interned. + if (@namespace.ContainingNamespace is null or { IsGlobalNamespace: true }) + return @namespace.Name; + + // Measure, then fill back-to-front. One char[] and one string, no intermediate concatenation. + var length = -1; + for ( + var segment = @namespace; + segment is not null && !segment.IsGlobalNamespace; + segment = segment.ContainingNamespace + ) + length += segment.Name.Length + 1; + + if (length <= 0) + return null; + + var characters = new char[length]; + var position = length; + + for ( + var segment = @namespace; + segment is not null && !segment.IsGlobalNamespace; + segment = segment.ContainingNamespace + ) + { + var name = segment.Name; + position -= name.Length; + name.CopyTo(0, characters, position, name.Length); + + if (position > 0) + characters[--position] = '.'; + } + + return new string(characters); + } + + /// + /// Compares a dotted namespace string against a namespace symbol chain without allocating. + /// + static bool NamespaceMatches(string? expected, INamespaceSymbol? actual) + { + if (actual is null || actual.IsGlobalNamespace) + return expected is null; + + if (expected is null) + return false; + + var end = expected.Length; + for ( + var segment = actual; + segment is not null && !segment.IsGlobalNamespace; + segment = segment.ContainingNamespace + ) + { + var name = segment.Name; + var start = end - name.Length; + + if (start < 0 || string.CompareOrdinal(expected, start, name, 0, name.Length) != 0) + return false; + + if (start == 0) + return segment.ContainingNamespace is null or { IsGlobalNamespace: true }; + + if (expected[start - 1] != '.') + return false; + + end = start - 1; + } + + return false; + } + + /// + /// Compares the expected containing-type chain against a symbol chain without allocating. + /// + bool ContainingTypesMatch(INamedTypeSymbol? containingType) + { + // Fast path: neither side is nested, which is the majority of comparisons. + if (containingType is null) + return ContainingTypes.IsDefaultOrEmpty; + + if (ContainingTypes.IsDefaultOrEmpty) + return false; + + // Walk the symbol chain innermost-first against the expected chain in reverse. + var index = ContainingTypes.Length - 1; + for (var symbol = containingType; symbol is not null; symbol = symbol.ContainingType, index--) + { + if (index < 0) + return false; + + var expected = ContainingTypes[index]; + if (expected.GenericArity != symbol.Arity) + return false; + + if (!string.Equals(expected.Name, symbol.Name, StringComparison.Ordinal)) + return false; + } + + return index == -1; + } + + static bool ContainingTypesEqual(ImmutableArray left, ImmutableArray right) + { + var leftCount = left.IsDefaultOrEmpty ? 0 : left.Length; + var rightCount = right.IsDefaultOrEmpty ? 0 : right.Length; + + if (leftCount != rightCount) + return false; + + for (var index = 0; index < leftCount; index++) + { + if (!left[index].Equals(right[index])) + return false; + } + + return true; + } + + static bool TypeArgumentsEqual(ImmutableArray left, ImmutableArray right) + { + var leftCount = left.IsDefaultOrEmpty ? 0 : left.Length; + var rightCount = right.IsDefaultOrEmpty ? 0 : right.Length; + + if (leftCount != rightCount) + return false; + + for (var index = 0; index < leftCount; index++) + { + if (!Equals(left[index], right[index])) + return false; + } + + return true; + } + + /// + /// Builds the containing-type chain from a symbol: one length pass, one fill pass, one allocation. + /// + static ImmutableArray BuildContainingTypes(INamedTypeSymbol typeSymbol) + { + var containing = typeSymbol.ContainingType; + if (containing is null) + return []; + + var depth = 0; + for (var symbol = containing; symbol is not null; symbol = symbol.ContainingType) + depth++; + + var builder = ImmutableArray.CreateBuilder(depth); + builder.Count = depth; + + // The symbol chain runs innermost-first; the stored chain is outermost-first, so fill backwards + // rather than appending and reversing. + var index = depth - 1; + for (var symbol = containing; symbol is not null; symbol = symbol.ContainingType) + builder[index--] = new ContainingType(symbol.Name, symbol.Arity); + + return builder.MoveToImmutable(); + } + + /// + /// Builds the containing-type chain from a runtime type, and reports how many generic arguments the + /// containing types consume from the flattened argument list. + /// + /// + /// Arity is parsed from the metadata name rather than obtained from GetGenericArguments(), which + /// allocates a array on every call and was previously invoked twice per link. + /// + static ImmutableArray BuildContainingTypes(Type type, out int consumed) + { + consumed = 0; + + var declaring = type.DeclaringType; + if (declaring is null) + return []; + + var depth = 0; + for (var link = declaring; link is not null; link = link.DeclaringType) + depth++; + + var builder = ImmutableArray.CreateBuilder(depth); + builder.Count = depth; + + var index = depth - 1; + for (var link = declaring; link is not null; link = link.DeclaringType) + { + var arity = ParseArity(link.Name); + builder[index--] = new ContainingType(StripArity(link.Name), arity); + consumed += arity; + } + + return builder.MoveToImmutable(); + } + + static ImmutableArray BuildArguments(INamedTypeSymbol typeSymbol) + { + var arguments = typeSymbol.TypeArguments; + if (arguments.Length == 0) + return []; + + // An unbound or original definition carries its own type parameters as arguments; treat it as open. + if ( + typeSymbol.IsUnboundGenericType + || SymbolEqualityComparer.Default.Equals(typeSymbol, typeSymbol.OriginalDefinition) + ) + return []; + + var builder = ImmutableArray.CreateBuilder(arguments.Length); + foreach (var argument in arguments) + { + // Only genuinely unrepresentable arguments (function pointers, error types) widen to the definition. + if (!TypeReference.TryCreate(argument, out var value)) + return []; + + builder.Add(value); + } + + return builder.MoveToImmutable(); + } + + static ImmutableArray BuildArguments(Type[] allArguments, int offset, int count) + { + if (count == 0 || allArguments.Length < offset + count) + return []; + + var builder = ImmutableArray.CreateBuilder(count); + for (var index = offset; index < offset + count; index++) + { + if (!TypeReference.TryCreate(allArguments[index], out var value)) + return []; + + builder.Add(value); + } + + return builder.MoveToImmutable(); + } + + /// + /// Reads the generic arity encoded in a CLR metadata name's backtick suffix. + /// + /// + /// The suffix records the type's own arity, excluding parameters inherited from containing types — + /// Outer<T>.Inner is Inner and Outer<T>.Inner<U> is Inner`1 — + /// which is exactly the value needed, and it costs no allocation. + /// + static int ParseArity(string metadataName) + { + var separator = metadataName.LastIndexOf('`'); + if (separator < 0 || separator == metadataName.Length - 1) + return 0; + + var arity = 0; + for (var index = separator + 1; index < metadataName.Length; index++) + { + var character = metadataName[index]; + if (character is < '0' or > '9') + return 0; + + arity = (arity * 10) + (character - '0'); + } + + return arity; + } + + static string StripArity(string metadataName) + { + var separator = metadataName.IndexOf('`'); + + return separator < 0 ? metadataName : metadataName.Substring(0, separator); + } +} diff --git a/src/src/SourceGeneratorFramework/TypeValueObjectExpressionExtensions.cs b/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs similarity index 61% rename from src/src/SourceGeneratorFramework/TypeValueObjectExpressionExtensions.cs rename to src/src/SourceGeneratorShared/TypeIdentityExtensions.cs index 83d4d6d..0617f32 100644 --- a/src/src/SourceGeneratorFramework/TypeValueObjectExpressionExtensions.cs +++ b/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs @@ -1,20 +1,22 @@ +using System.ComponentModel; + namespace Purview.SourceGeneratorFramework; -/// Provides expression-building helpers for structured type descriptors. -public static class TypeValueObjectExpressionExtensions +[EditorBrowsable(EditorBrowsableState.Never)] +public static class TypeIdentityExtensions { /// Returns a fully qualified reference to a static member on the specified type. - /// The type that declares the static member. + /// The type that declares the static member. /// The static field, property, method, or nested-type name. /// A C# expression in the form global::Namespace.Type.Member. - public static string StaticMember(this TypeValueObject type, string memberName) + public static string StaticMember(this in TypeIdentity typeIdentity, string memberName) { - if (type == TypeValueObject.Empty) - throw new ArgumentException("The declaring type cannot be empty.", nameof(type)); + if (typeIdentity == TypeIdentity.Empty) + throw new ArgumentException("The declaring type cannot be empty.", nameof(typeIdentity)); if (string.IsNullOrWhiteSpace(memberName)) throw new ArgumentException("Member name cannot be null or whitespace.", nameof(memberName)); // The RenderFullName property already includes the global:: prefix and the namespace, so we can just append the member name. - return $"{type.RenderFullName}.{memberName}"; + return $"{typeIdentity.RenderFullName}.{memberName}"; } } diff --git a/src/src/SourceGeneratorShared/TypeModifier.cs b/src/src/SourceGeneratorShared/TypeModifier.cs new file mode 100644 index 0000000..f8a8894 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeModifier.cs @@ -0,0 +1,67 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies a single composition step applied to a type reference. +/// +public enum TypeModifierKind +{ + /// A nullable annotation, or a wrapper for a value type. + Nullable = 0, + + /// A pointer indirection. + /// + /// Calling this field `Pointer` results in a CA1720 warning because it is a reserved keyword in C#. The name `PointerModifier` is used instead to avoid the warning. + /// + PointerModifier = 1, + + /// An array of the given rank. + Array = 2, +} + +/// +/// A single composition step applied to a type reference. +/// +/// +/// Modifiers are stored innermost-first, so int?[] is [Nullable, Array(1)] and int[]? +/// is [Array(1), Nullable]. Rendering appends each suffix in order; matching consumes them in reverse. +/// +public readonly record struct TypeModifier +{ + /// Gets the kind of composition step. + public TypeModifierKind Kind { get; init; } + + /// Gets the array rank. Only meaningful when is . + public int Rank { get; init; } + + /// Gets a nullable modifier. + public static TypeModifier Nullable => new() { Kind = TypeModifierKind.Nullable, Rank = 0 }; + + /// Gets a pointer modifier. + public static TypeModifier PointerModifier => new() { Kind = TypeModifierKind.PointerModifier, Rank = 0 }; + + /// Creates an array modifier of the given rank. + /// Thrown when is less than one. + public static TypeModifier Array(int rank = 1) + { + if (rank < 1) + throw new ArgumentOutOfRangeException(nameof(rank), rank, "An array rank must be at least one."); + + // The rank is stored in the modifier, but it is not used for equality or hashing. This is because the rank is not part of the C# type system; it is only used for rendering. + return new() { Kind = TypeModifierKind.Array, Rank = rank }; + } + + /// + /// Gets the C# suffix for this modifier. + /// + public string Suffix => + Kind switch + { + TypeModifierKind.Nullable => "?", + TypeModifierKind.PointerModifier => "*", + TypeModifierKind.Array => Rank == 1 ? "[]" : $"[{new string(',', Rank - 1)}]", + _ => string.Empty, + }; + + /// + public override string ToString() => Suffix; +} diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs new file mode 100644 index 0000000..b40c458 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -0,0 +1,577 @@ +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +/// +/// A structured reference to a type as it appears at a use site: a named type, generic parameter or +/// , composed with nullable, pointer and array modifiers. +/// +/// +/// +/// models type identity; this models a type reference. Anything +/// a use site can spell that identity cannot — T, int[], byte*, string? — is +/// expressed here, which is why is a collection of these. +/// +/// +/// This is a reference type by necessity, not by preference. As a struct it would embed a +/// by value while holds an +/// of these — a mutual value-type layout cycle. The C# compiler accepts that +/// (no CS0523, because 's only field is an array reference) but the CLR type +/// loader rejects it at runtime with a : see dotnet/runtime#11259. Making one +/// side of the cycle a reference type is the fix, and this is the cheaper side to convert — it is the larger +/// of the two and lives in arrays, so references cost less to copy than the embedded value did. +/// +/// +/// Nullable reference annotations are recorded but not enforced during matching: string? matches both +/// the annotated and unannotated symbol, because annotation is metadata rather than identity. Nullable +/// value types are enforced, because int? is a genuinely different type from int. +/// +/// +/// A named reference inherits its generic comparison behavior from . A reference +/// wrapping an open generic definition matches constructed symbols of that definition; a reference wrapping a +/// constructed identity validates its arguments. Reference modifiers are always significant, so an open +/// List<> reference does not make List<int>[] match unless the reference also has the array +/// modifier. +/// +/// +public sealed record TypeReference +{ + /// + /// Initializes a new, empty reference. Prefer the named factories. + /// + TypeReference() + { + Kind = TypeReferenceKind.None; + Modifiers = []; + } + + /// + /// Initializes a new reference to the given named type. + /// + public TypeReference(TypeIdentity typeIdentity) + { + Kind = TypeReferenceKind.Named; + Identity = typeIdentity; + Modifiers = []; + } + + /// Gets a value indicating whether this reference is empty. + public bool IsEmpty => Kind == TypeReferenceKind.None; + + /// Gets what this reference refers to beneath its modifiers. + public TypeReferenceKind Kind { get; init; } + + /// Gets the named type, when is . + public TypeIdentity Identity { get; init; } + + /// + /// Gets the generic parameter name, when is . + /// + public string? TypeParameterName { get; init; } + + /// + /// Gets the composition modifiers, innermost-first. + /// + public ImmutableArray Modifiers { get; init; } + + /// + /// Gets a value indicating whether this is an unmodified reference to a named type, and therefore + /// interchangeable with a bare . + /// + public bool IsPlainNamedType => Kind == TypeReferenceKind.Named && Modifiers.IsDefaultOrEmpty; + + /// Gets a value indicating whether the outermost modifier is an array. + public bool IsArray => LastModifier?.Kind == TypeModifierKind.Array; + + /// Gets a value indicating whether the outermost modifier is a pointer. + public bool IsPointer => LastModifier?.Kind == TypeModifierKind.PointerModifier; + + /// Gets a value indicating whether the outermost modifier is a nullable annotation. + public bool IsNullable => LastModifier?.Kind == TypeModifierKind.Nullable; + + TypeModifier? LastModifier => Modifiers.IsDefaultOrEmpty ? null : Modifiers[Modifiers.Length - 1]; + + /// + /// Gets the fully-qualified reference as it should be rendered in generated code. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public string RenderFullName + { + get + { + var core = Kind switch + { + TypeReferenceKind.Named => Identity.RenderFullName, + TypeReferenceKind.TypeParameter => TypeParameterName ?? string.Empty, + TypeReferenceKind.Dynamic => "dynamic", + _ => string.Empty, + }; + + if (Modifiers.IsDefaultOrEmpty) + return core; + + StringBuilder builder = new(core); + + // `?` and `*` read innermost-first, but a run of array declarators reads outermost-first: + // `int[][,]` is a rank-1 array of rank-2 arrays. Each contiguous array run is therefore emitted + // in reverse. + var index = 0; + while (index < Modifiers.Length) + { + if (Modifiers[index].Kind != TypeModifierKind.Array) + { + builder.Append(Modifiers[index].Suffix); + index++; + + continue; + } + + var start = index; + while (index < Modifiers.Length && Modifiers[index].Kind == TypeModifierKind.Array) + index++; + + for (var reverse = index - 1; reverse >= start; reverse--) + builder.Append(Modifiers[reverse].Suffix); + } + + return builder.ToString(); + } + } + + /// + /// Gets the fully-qualified type name for use in an attribute application. + /// + /// + /// Thrown when this is not an unmodified named type. C# attributes cannot be arrays, pointers, nullable + /// types, type parameters, or . + /// + public string RenderAttributeName + { + get + { + if (!IsPlainNamedType) + throw new InvalidOperationException("An attribute type must be an unmodified named type."); + + // The attribute type name is the same as the named type's full name, but with the "Attribute" suffix + return Identity.RenderAttributeName; + } + } + + /// + public override string ToString() => RenderFullName; + + /// + /// Implicitly converts a named type to an unmodified reference. + /// + public static implicit operator TypeReference(TypeIdentity type) => type == TypeIdentity.Empty ? Empty : new(type); + + /// + /// Implicitly converts a named type to an unmodified reference, or if the type is + /// + /// + public static implicit operator TypeReference?(TypeIdentity? type) => + type == null || type == TypeIdentity.Empty ? null : new(type!); + + /// + /// Implicitly converts a reference to its rendered name. + /// + public static implicit operator string(TypeReference? reference) => reference?.RenderFullName ?? string.Empty; + + /// + /// Implicitly converts a reference to its underlying type value object, discarding any modifiers. + /// + public static implicit operator TypeIdentity(TypeReference? reference) => reference?.Identity ?? TypeIdentity.Empty; + + // --------------------------------------------------------------------------------------------- + // Composition + // --------------------------------------------------------------------------------------------- + + /// Appends a nullable annotation. + public TypeReference Nullable() => Append(TypeModifier.Nullable); + + /// Appends an array of the given rank. + public TypeReference MakeArray(int rank = 1) => Append(TypeModifier.Array(rank)); + + /// Appends a pointer indirection. + public TypeReference MakePointer() => Append(TypeModifier.PointerModifier); + + TypeReference Append(TypeModifier modifier) + { + if (Kind == TypeReferenceKind.None) + throw new InvalidOperationException("Cannot compose modifiers onto an empty type reference."); + + var existing = Modifiers.IsDefaultOrEmpty ? 0 : Modifiers.Length; + var builder = ImmutableArray.CreateBuilder(existing + 1); + + if (existing > 0) + builder.AddRange(Modifiers); + + builder.Add(modifier); + + return this with + { + Modifiers = builder.MoveToImmutable(), + }; + } + + // --------------------------------------------------------------------------------------------- + // Matching + // --------------------------------------------------------------------------------------------- + + /// + /// Determines whether the given type symbol is this composed reference. + /// + /// + /// For named types, open-versus-constructed generic matching is performed by + /// after this reference's modifiers have been consumed. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public bool Matches(ITypeSymbol? other) + { + if (other is null || Kind == TypeReferenceKind.None) + return false; + + var current = other; + + // Modifiers are stored innermost-first, so they are consumed in reverse against the symbol. + if (!Modifiers.IsDefaultOrEmpty) + { + for (var index = Modifiers.Length - 1; index >= 0; index--) + { + var modifier = Modifiers[index]; + switch (modifier.Kind) + { + case TypeModifierKind.Array: + if (current is not IArrayTypeSymbol array || array.Rank != modifier.Rank) + return false; + + current = array.ElementType; + + break; + + case TypeModifierKind.PointerModifier: + if (current is not IPointerTypeSymbol pointer) + return false; + + current = pointer.PointedAtType; + + break; + + case TypeModifierKind.Nullable: + // Nullable value types are a distinct type and must be unwrapped; nullable reference + // annotations are metadata and are deliberately ignored. + if ( + current is INamedTypeSymbol nullable + && nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + && nullable.TypeArguments.Length == 1 + ) + current = nullable.TypeArguments[0]; + else if (current.IsValueType) + return current.NullableAnnotation == NullableAnnotation.Annotated; + + break; + + default: + return false; + } + } + } + + return Kind switch + { + TypeReferenceKind.Named => Identity.Matches(current), + TypeReferenceKind.TypeParameter => current is ITypeParameterSymbol parameter + && string.Equals(TypeParameterName, parameter.Name, StringComparison.Ordinal), + TypeReferenceKind.Dynamic => current is IDynamicTypeSymbol, + _ => false, + }; + } + + /// + /// Determines whether the type of the given symbol is this composed reference. + /// + /// + /// Resolves fields, properties, events, parameters, locals and method return types to their type before + /// matching. Type symbols are matched directly. + /// + public bool Matches(ISymbol? other) => Matches(SymbolTypeResolver.Resolve(other)); + + /// + /// Determines whether this reference is an unmodified reference to the given named type. + /// + public bool Equals(TypeIdentity other) => IsPlainNamedType && Identity.Equals(other); + + /// + /// Determines whether the specified reference describes the same composed type. + /// + /// + /// Declared explicitly because the synthesised record equality would compare + /// by its default comparer, which is reference equality on the underlying + /// array rather than structural equality of the modifiers. + /// + public bool Equals(TypeReference? other) + { + if (ReferenceEquals(this, other)) + return true; + + if (other is null || Kind != other.Kind) + return false; + + if (!string.Equals(TypeParameterName, other.TypeParameterName, StringComparison.Ordinal)) + return false; + + if (Kind == TypeReferenceKind.Named && !Identity.Equals(other.Identity)) + return false; + + var count = Modifiers.IsDefaultOrEmpty ? 0 : Modifiers.Length; + var otherCount = other.Modifiers.IsDefaultOrEmpty ? 0 : other.Modifiers.Length; + + if (count != otherCount) + return false; + + for (var index = 0; index < count; index++) + { + if (!Modifiers[index].Equals(other.Modifiers[index])) + return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = (int)Kind; + hashCode = + (hashCode * 397) + ^ (TypeParameterName is null ? 0 : StringComparer.Ordinal.GetHashCode(TypeParameterName)); + + if (Kind == TypeReferenceKind.Named) + hashCode = (hashCode * 397) ^ Identity.GetHashCode(); + + if (!Modifiers.IsDefaultOrEmpty) + { + foreach (var modifier in Modifiers) + hashCode = (hashCode * 397) ^ modifier.GetHashCode(); + } + + return hashCode; + } + } + + // --------------------------------------------------------------------------------------------- + // Factories + // --------------------------------------------------------------------------------------------- + + /// Gets the empty reference. + public static readonly TypeReference Empty = new(); + + /// Gets a reference to . + public static TypeReference Dynamic { get; } = new() { Kind = TypeReferenceKind.Dynamic, Modifiers = [] }; + + /// Creates a reference to an open generic parameter. + public static TypeReference ForTypeParameter(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name)); + + // The name is stored in the reference, but it is not used for equality or hashing. This is because the name is not part of the C# type system; it is only used for rendering. + return new() + { + Kind = TypeReferenceKind.TypeParameter, + TypeParameterName = name, + Modifiers = [], + }; + } + + /// Creates a reference from a runtime type. + public static TypeReference Create() => Create(typeof(T)); + + /// Creates a reference from a runtime type. + /// Thrown when the type cannot be represented. + public static TypeReference Create(Type type) + { + if (type == null) + throw new ArgumentNullException(nameof(type)); + + if (!TryCreate(type, out var value)) + throw new ArgumentException($"The type '{type}' cannot be represented as a type reference.", nameof(type)); + + // The type is known to be representable, so the out value is guaranteed to be valid. + return value; + } + + /// Creates a reference from a type symbol. + /// Thrown when the symbol cannot be represented. + public static TypeReference Create(ITypeSymbol typeSymbol) + { + if (typeSymbol == null) + throw new ArgumentNullException(nameof(typeSymbol)); + + if (!TryCreate(typeSymbol, out var value)) + { + throw new ArgumentException( + $"The symbol '{typeSymbol.ToDisplayString()}' cannot be represented as a type reference.", + nameof(typeSymbol) + ); + } + + // The symbol is known to be representable, so the out value is guaranteed to be valid. + return value; + } + + /// + /// Attempts to create a reference from a type symbol, peeling arrays, pointers and nullability into + /// modifiers. + /// + /// for unresolved, function-pointer and other unrepresentable symbols. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0010:Add missing cases")] + public static bool TryCreate(ITypeSymbol? typeSymbol, out TypeReference value) + { + value = Empty; + + if (typeSymbol is null) + return false; + + // Fast path: the overwhelmingly common case is an unmodified named type. + if ( + typeSymbol is INamedTypeSymbol named + && named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T + && named.NullableAnnotation != NullableAnnotation.Annotated + ) + { + if (!TypeIdentity.TryCreate(named, out var plain)) + return false; + + value = new TypeReference(plain); + + return true; + } + + // Collected outermost-first, then reversed into innermost-first storage order. + var modifiers = ImmutableArray.CreateBuilder(); + var current = typeSymbol; + + while (true) + { + if (current.IsReferenceType && current.NullableAnnotation == NullableAnnotation.Annotated) + modifiers.Add(TypeModifier.Nullable); + + switch (current) + { + case IArrayTypeSymbol array: + modifiers.Add(TypeModifier.Array(array.Rank)); + current = array.ElementType; + + continue; + + case IPointerTypeSymbol pointer: + modifiers.Add(TypeModifier.PointerModifier); + current = pointer.PointedAtType; + + continue; + + case INamedTypeSymbol nullable + when nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + && nullable.TypeArguments.Length == 1: + modifiers.Add(TypeModifier.Nullable); + current = nullable.TypeArguments[0]; + + continue; + } + + break; + } + + modifiers.Reverse(); + + switch (current) + { + case IDynamicTypeSymbol: + value = Dynamic with { Modifiers = modifiers.ToImmutable() }; + + return true; + + case ITypeParameterSymbol parameter: + value = ForTypeParameter(parameter.Name) with { Modifiers = modifiers.ToImmutable() }; + + return true; + + default: + if (!TypeIdentity.TryCreate(current, out var namedType)) + return false; + + value = new TypeReference(namedType) { Modifiers = modifiers.ToImmutable() }; + + return true; + } + } + + /// + /// Attempts to create a reference from a runtime type, peeling arrays, pointers and nullability into + /// modifiers. + /// + public static bool TryCreate(Type? type, out TypeReference value) + { + value = Empty; + + if (type is null) + return false; + + var modifiers = ImmutableArray.CreateBuilder(); + var current = type; + + if (current.IsByRef) + current = current.GetElementType(); + + while (true) + { + if (current.IsArray) + { + modifiers.Add(TypeModifier.Array(current.GetArrayRank())); + current = current.GetElementType(); + + continue; + } + + if (current.IsPointer) + { + modifiers.Add(TypeModifier.PointerModifier); + current = current.GetElementType(); + + continue; + } + + var underlying = System.Nullable.GetUnderlyingType(current); + if (underlying is not null) + { + modifiers.Add(TypeModifier.Nullable); + current = underlying; + + continue; + } + + break; + } + + modifiers.Reverse(); + + if (current.IsGenericParameter) + { + value = ForTypeParameter(current.Name) with { Modifiers = modifiers.ToImmutable() }; + + return true; + } + + if (!TypeIdentity.TryCreate(current, out var namedType)) + return false; + + value = new TypeReference(namedType) { Modifiers = modifiers.ToImmutable() }; + + return true; + } +} diff --git a/src/src/SourceGeneratorShared/TypeReferenceExtensions.cs b/src/src/SourceGeneratorShared/TypeReferenceExtensions.cs new file mode 100644 index 0000000..4ef9929 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeReferenceExtensions.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Purview.SourceGeneratorFramework; + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class TypeReferenceExtensions +{ + /// + /// Determines whether the specified is null or empty. + /// + /// The to check. + /// if the specified is null or empty; otherwise, . + public static bool IsNullOrEmpty(this TypeReference? typeReference) => + typeReference is null || typeReference.Kind == TypeReferenceKind.None; +} diff --git a/src/src/SourceGeneratorShared/TypeReferenceKind.cs b/src/src/SourceGeneratorShared/TypeReferenceKind.cs new file mode 100644 index 0000000..cddf150 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeReferenceKind.cs @@ -0,0 +1,19 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies what a refers to at its core, beneath any modifiers. +/// +public enum TypeReferenceKind +{ + /// No type. The default, uninitialised state. + None = 0, + + /// A named type described by a . + Named = 1, + + /// An open generic parameter, identified by name. + TypeParameter = 2, + + /// The type. + Dynamic = 3, +} diff --git a/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs b/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs new file mode 100644 index 0000000..feb905b --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeSyntaxFacts.cs @@ -0,0 +1,304 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Low-level syntax helpers shared by the matching extensions, exposed as pure static functions so they can be +/// unit tested without a compilation. +/// +public static class TypeSyntaxFacts +{ + public const string AttributeSuffix = "Attribute"; + + /// + /// Peels array, pointer, nullable and ref composition from a type syntax. + /// + /// The syntax to peel. + /// The innermost name or predefined type. + /// + /// The significant modifiers, outermost-first, or when the type carries none. + /// Nullable annotations are omitted. The list is allocated lazily because this method runs in the + /// predicate stage against every candidate node, and the overwhelming majority of types are uncomposed. + /// + /// for tuples, function pointers and other unrepresentable forms. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetCore(TypeSyntax? typeSyntax, out TypeSyntax core, out IList? modifiers) + { + core = null!; + modifiers = null; + + while (true) + { +#pragma warning disable format + switch (typeSyntax) + { + case null: + return false; + + case RefTypeSyntax refType: + typeSyntax = refType.Type; + + continue; + + // Syntax cannot distinguish Nullable from a nullable reference annotation, so nullability is + // not treated as a significant modifier at this tier. + case NullableTypeSyntax nullableType: + typeSyntax = nullableType.ElementType; + + continue; + + case ArrayTypeSyntax arrayType: + { + // `int[][,]` parses as a single node with a rank-specifier list, outermost-first. + modifiers ??= [with(arrayType.RankSpecifiers.Count)]; + + foreach (var rankSpecifier in arrayType.RankSpecifiers) + modifiers.Add(TypeModifier.Array(rankSpecifier.Rank)); + + typeSyntax = arrayType.ElementType; + + continue; + } + + case PointerTypeSyntax pointerType: + modifiers ??= []; + modifiers.Add(TypeModifier.PointerModifier); + typeSyntax = pointerType.ElementType; + + continue; + + case TupleTypeSyntax: + case FunctionPointerTypeSyntax: + return false; + + default: + core = typeSyntax; + + return true; + } +#pragma warning restore format + } + } + + /// + /// Splits a name into its rightmost simple name and its optional left-hand qualifier. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static void Split(NameSyntax name, out SimpleNameSyntax simpleName, out NameSyntax? qualifier) + { + if (name is null) + throw new ArgumentNullException(nameof(name)); + + switch (name) + { + case QualifiedNameSyntax qualified: + simpleName = qualified.Right; + qualifier = qualified.Left; + + return; + + case AliasQualifiedNameSyntax aliasQualified: + simpleName = aliasQualified.Name; + // `global::Foo` is rooted but contributes no segments; `Alias::Foo` is opaque to syntax. + qualifier = null; + + return; + + case SimpleNameSyntax simple: + simpleName = simple; + qualifier = null; + + return; + + default: + simpleName = SyntaxFactory.IdentifierName(name.ToString()); + qualifier = null; + + return; + } + } + + /// + /// Gets the generic arity written at a name site. + /// + public static int GetArity(SimpleNameSyntax simpleName) => + simpleName is GenericNameSyntax generic ? generic.TypeArgumentList.Arguments.Count : 0; + + /// + /// Collects the dotted segments of a qualifier, outermost first. + /// + /// when the qualifier is rooted with global::. + public static bool CollectQualifierSegments(NameSyntax? qualifier, ImmutableArray.Builder segments) + { + if (segments is null) + throw new ArgumentNullException(nameof(segments)); + +#pragma warning disable format + switch (qualifier) + { + case null: + return false; + + case QualifiedNameSyntax qualified: + { + var rooted = CollectQualifierSegments(qualified.Left, segments); + segments.Add(qualified.Right.Identifier.ValueText); + + return rooted; + } + + case AliasQualifiedNameSyntax aliasQualified: + { + segments.Add(aliasQualified.Name.Identifier.ValueText); + + return aliasQualified.Alias.Identifier.IsKind(SyntaxKind.GlobalKeyword) + || string.Equals(aliasQualified.Alias.Identifier.ValueText, "global", StringComparison.Ordinal); + } + + case SimpleNameSyntax simple: + segments.Add(simple.Identifier.ValueText); + + return false; + + default: + segments.Add(qualifier.ToString()); + + return false; + } +#pragma warning restore format + } + + /// + /// Maps a predefined-type keyword to its special type. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public static SpecialType GetSpecialType(SyntaxKind keyword) => + keyword switch + { + SyntaxKind.BoolKeyword => SpecialType.System_Boolean, + SyntaxKind.ByteKeyword => SpecialType.System_Byte, + SyntaxKind.SByteKeyword => SpecialType.System_SByte, + SyntaxKind.CharKeyword => SpecialType.System_Char, + SyntaxKind.DecimalKeyword => SpecialType.System_Decimal, + SyntaxKind.DoubleKeyword => SpecialType.System_Double, + SyntaxKind.FloatKeyword => SpecialType.System_Single, + SyntaxKind.IntKeyword => SpecialType.System_Int32, + SyntaxKind.UIntKeyword => SpecialType.System_UInt32, + SyntaxKind.LongKeyword => SpecialType.System_Int64, + SyntaxKind.ULongKeyword => SpecialType.System_UInt64, + SyntaxKind.ShortKeyword => SpecialType.System_Int16, + SyntaxKind.UShortKeyword => SpecialType.System_UInt16, + SyntaxKind.ObjectKeyword => SpecialType.System_Object, + SyntaxKind.StringKeyword => SpecialType.System_String, + SyntaxKind.VoidKeyword => SpecialType.System_Void, + _ => SpecialType.None, + }; + + /// + /// Gets the declared identifier and generic arity for any type-defining declaration node. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetDeclarationName(SyntaxNode? node, out string identifier, out int arity) + { + switch (node) + { + case TypeDeclarationSyntax typeDeclaration: + identifier = typeDeclaration.Identifier.ValueText; + arity = typeDeclaration.TypeParameterList?.Parameters.Count ?? 0; + + return true; + + case EnumDeclarationSyntax enumDeclaration: + identifier = enumDeclaration.Identifier.ValueText; + arity = 0; + + return true; + + case DelegateDeclarationSyntax delegateDeclaration: + identifier = delegateDeclaration.Identifier.ValueText; + arity = delegateDeclaration.TypeParameterList?.Parameters.Count ?? 0; + + return true; + + default: + identifier = null!; + arity = 0; + + return false; + } + } + + /// + /// Reconstructs the full namespace of a declaration from its ancestors, handling both block-scoped and + /// file-scoped namespace declarations. + /// + /// The dotted namespace, or for the global namespace. + public static string? GetDeclaredNamespace(SyntaxNode node) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + + string? result = null; + + for (var parent = node.Parent; parent is not null; parent = parent.Parent) + { + if (parent is not BaseNamespaceDeclarationSyntax namespaceDeclaration) + continue; + + var name = namespaceDeclaration.Name.ToString(); + result = result is null ? name : $"{name}.{result}"; + } + + return result; + } + + /// + /// Resolves the type a node refers to, preferring symbol resolution over type inference. + /// + public static ITypeSymbol? ResolveType( + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken + ) + { + if (node is null) + return null; + + if (semanticModel == null) + throw new ArgumentNullException(nameof(semanticModel)); + + // Prefer symbol resolution because it is more accurate than type inference, especially for generic type parameters. + return semanticModel.GetSymbolInfo(node, cancellationToken).Symbol as ITypeSymbol + ?? semanticModel.GetTypeInfo(node, cancellationToken).Type; + } + + /// + /// Resolves the symbol declared by a member node, unwrapping single-variable field and event declarations + /// to their variable declarator. + /// + public static ISymbol? ResolveDeclaredSymbol( + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken + ) + { + if (node is null) + return null; + + if (semanticModel == null) + throw new ArgumentNullException(nameof(semanticModel)); + + // Prefer symbol resolution because it is more accurate than type inference, especially for generic type parameters. + return node switch + { + BaseFieldDeclarationSyntax field => field.Declaration.Variables.Count == 1 + ? semanticModel.GetDeclaredSymbol(field.Declaration.Variables[0], cancellationToken) + : null, + VariableDeclaratorSyntax declarator => semanticModel.GetDeclaredSymbol(declarator, cancellationToken), + _ => semanticModel.GetDeclaredSymbol(node, cancellationToken), + }; + } +} diff --git a/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs b/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs new file mode 100644 index 0000000..349fe70 --- /dev/null +++ b/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs @@ -0,0 +1,425 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Syntax-level matching for and . +/// +/// +/// +/// Two tiers are provided, mirroring the incremental generator pipeline. The CouldMatch* methods are +/// purely syntactic and are intended for the predicate stage, where no is +/// available and the work must be cheap. The Matches* methods resolve symbols and are intended for the +/// transform stage. +/// +/// +/// Directionality. CouldMatch* over-approximates for type references: it may return +/// for a node that does not resolve to this type, and it cannot see using +/// aliases, so a reference written through an alias is not recognised. Where that matters, predicate on syntax +/// kind alone and filter with Matches* in the transform. CouldMatchDeclaration has no such +/// limitation — a declaration's name, arity, containing types and namespace are fully determined by syntax. +/// +/// +public static class TypeSyntaxMatchingExtensions +{ + // --------------------------------------------------------------------------------------------- + // TypeValueObject — references + // --------------------------------------------------------------------------------------------- + + /// + /// Determines, without a semantic model, whether the node could be a reference to this type. + /// + public static bool CouldMatchTypeReference(this in TypeIdentity type, SyntaxNode? node) + { + if (node is not TypeSyntax typeSyntax) + return false; + + if (!TypeSyntaxFacts.TryGetCore(typeSyntax, out var core, out var modifiers)) + return false; + + // A bare named type identity accepts no array or pointer composition. + return modifiers is { Count: > 0 } ? false : CoreMatchesNamedType(type, core); + } + + /// + /// Determines whether the node resolves to this type. + /// + public static bool MatchesTypeReference( + this in TypeIdentity type, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) => type.Matches(TypeSyntaxFacts.ResolveType(node, semanticModel, cancellationToken)); + + // --------------------------------------------------------------------------------------------- + // TypeReferenceOptions — references + // --------------------------------------------------------------------------------------------- + + /// + /// Determines, without a semantic model, whether the node could be this composed reference. + /// + /// + /// Nullable modifiers are ignored on both sides: syntax alone cannot distinguish + /// Nullable<int> from a nullable reference annotation. Array ranks and pointer depth are + /// compared exactly. + /// + public static bool CouldMatchTypeReference(this TypeReference? reference, SyntaxNode? node) + { + if (reference is null || node is not TypeSyntax typeSyntax) + return false; + + if (!TypeSyntaxFacts.TryGetCore(typeSyntax, out var core, out var written)) + return false; + + // Compare the significant modifiers, outermost-first on both sides. This runs in the predicate stage + // for every candidate node, so the comparison is done in place rather than by materialising a list. + var modifiers = reference.Modifiers; + var expectedCount = 0; + + if (!modifiers.IsDefaultOrEmpty) + { + foreach (var modifier in modifiers) + { + if (modifier.Kind != TypeModifierKind.Nullable) + expectedCount++; + } + } + + var writtenCount = written?.Count ?? 0; + if (expectedCount != writtenCount) + return false; + + if (expectedCount > 0) + { + // Stored innermost-first, written outermost-first, so walk the stored side in reverse. + var writtenIndex = 0; + for (var index = modifiers.Length - 1; index >= 0; index--) + { + var modifier = modifiers[index]; + if (modifier.Kind == TypeModifierKind.Nullable) + continue; + + var actual = written![writtenIndex++]; + if (modifier.Kind != actual.Kind || modifier.Rank != actual.Rank) + return false; + } + } + +#pragma warning disable IDE0072 // Add missing cases + return reference.Kind switch + { + TypeReferenceKind.Named => CoreMatchesNamedType(reference.Identity, core), + TypeReferenceKind.TypeParameter => core is IdentifierNameSyntax identifier + && string.Equals( + reference.TypeParameterName, + identifier.Identifier.ValueText, + StringComparison.Ordinal + ), + TypeReferenceKind.Dynamic => core is IdentifierNameSyntax { Identifier.ValueText: "dynamic" }, + _ => false, + }; +#pragma warning restore IDE0072 // Add missing cases + } + + /// + /// Determines whether the node resolves to this composed reference. + /// + public static bool MatchesTypeReference( + this TypeReference? reference, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) => reference?.Matches(TypeSyntaxFacts.ResolveType(node, semanticModel, cancellationToken)) ?? false; + + // --------------------------------------------------------------------------------------------- + // Declarations + // --------------------------------------------------------------------------------------------- + + /// + /// Determines, without a semantic model, whether the node is the declaration of this type. + /// + /// + /// Exact for declarations. Accepts class, struct, interface, record, + /// record struct, enum and delegate declarations. + /// + public static bool CouldMatchDeclaration(this in TypeIdentity type, SyntaxNode? node) + { + if (node is null) + return false; + + if (!TypeSyntaxFacts.TryGetDeclarationName(node, out var identifier, out var arity)) + return false; + + if (!string.Equals(type.Name, identifier, StringComparison.Ordinal)) + return false; + + if (type.GenericArity != arity) + return false; + + if (!DeclaredContainingTypesMatch(type, node)) + return false; + + // The namespace is the only part of a declaration that cannot be determined from the node alone, so + return string.Equals(type.Namespace, TypeSyntaxFacts.GetDeclaredNamespace(node), StringComparison.Ordinal); + } + + /// + /// Determines whether the node declares this type. + /// + public static bool MatchesDeclaration( + this in TypeIdentity type, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) + { + if (node is not MemberDeclarationSyntax declaration) + return false; + + if (semanticModel == null) + throw new ArgumentNullException(nameof(semanticModel)); + + // The declared symbol is always a named type, so the cast is safe. + return type.Matches(semanticModel.GetDeclaredSymbol(declaration, cancellationToken) as ITypeSymbol); + } + + // --------------------------------------------------------------------------------------------- + // Members + // --------------------------------------------------------------------------------------------- + + /// + /// Determines whether the declared member's type is this type: the declared type for fields, properties, + /// events and parameters, and the return type for methods. + /// + public static bool MatchesDeclaredType( + this in TypeIdentity type, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) => type.Matches(TypeSyntaxFacts.ResolveDeclaredSymbol(node, semanticModel, cancellationToken)); + + /// + /// Determines whether the declared member's type is this composed reference. + /// + public static bool MatchesDeclaredType( + this TypeReference? reference, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) => reference?.Matches(TypeSyntaxFacts.ResolveDeclaredSymbol(node, semanticModel, cancellationToken)) ?? false; + + // --------------------------------------------------------------------------------------------- + // Attributes + // --------------------------------------------------------------------------------------------- + + /// + /// Determines, without a semantic model, whether the attribute could be an application of this type. + /// + /// The Attribute suffix is optional at the application site, so both spellings are accepted. + public static bool CouldMatchAttribute(this in TypeIdentity type, SyntaxNode? node) + { + var name = node switch + { + AttributeSyntax attribute => attribute.Name, + NameSyntax nameSyntax => nameSyntax, + _ => null, + }; + + if (name is null) + return false; + + TypeSyntaxFacts.Split(name, out var simpleName, out var qualifier); + + var written = simpleName.Identifier.ValueText; + var matchesName = + string.Equals(type.Name, written, StringComparison.Ordinal) + || string.Equals(type.Name, written + TypeSyntaxFacts.AttributeSuffix, StringComparison.Ordinal); + + if (!matchesName) + return false; + + if (type.GenericArity != TypeSyntaxFacts.GetArity(simpleName)) + return false; + + // The qualifier is optional, so the match is always possible if none is written. + return QualifierMatches(type, qualifier); + } + + /// + /// Determines whether the attribute application resolves to this attribute type. + /// + public static bool MatchesAttribute( + this in TypeIdentity type, + SyntaxNode? node, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) + { + if (semanticModel == null) + throw new ArgumentNullException(nameof(semanticModel)); + + // The attribute application is always a method symbol, so the cast is safe. + return node switch + { + AttributeSyntax attribute => type.Matches( + (semanticModel.GetSymbolInfo(attribute, cancellationToken).Symbol as IMethodSymbol)?.ContainingType + ?? semanticModel.GetTypeInfo(attribute, cancellationToken).Type + ), + AttributeListSyntax list => MatchesAny(type, list.Attributes, semanticModel, cancellationToken), + _ => false, + }; + } + + /// + /// Determines whether this type is applied as an attribute anywhere on the given declaration. + /// + public static bool HasAttribute( + this in TypeIdentity type, + MemberDeclarationSyntax? declaration, + SemanticModel semanticModel, + CancellationToken cancellationToken = default + ) + { + if (declaration is null) + return false; + + foreach (var list in declaration.AttributeLists) + { + if (MatchesAny(type, list.Attributes, semanticModel, cancellationToken)) + return true; + } + + return false; + } + + // --------------------------------------------------------------------------------------------- + // Shared + // --------------------------------------------------------------------------------------------- + + static bool MatchesAny( + in TypeIdentity type, + SeparatedSyntaxList attributes, + SemanticModel semanticModel, + CancellationToken cancellationToken + ) + { + foreach (var attribute in attributes) + { + if (type.MatchesAttribute(attribute, semanticModel, cancellationToken)) + return true; + } + + return false; + } + + static bool CoreMatchesNamedType(in TypeIdentity type, TypeSyntax core) + { + if (core is PredefinedTypeSyntax predefined) + { + var specialType = TypeSyntaxFacts.GetSpecialType(predefined.Keyword.Kind()); + + return specialType != SpecialType.None && specialType == type.SpecialType; + } + + if (core is not NameSyntax name) + return false; + + TypeSyntaxFacts.Split(name, out var simpleName, out var qualifier); + + if (!string.Equals(type.Name, simpleName.Identifier.ValueText, StringComparison.Ordinal)) + return false; + + if (type.GenericArity != TypeSyntaxFacts.GetArity(simpleName)) + return false; + + // The qualifier is optional, so the match is always possible if none is written. + return QualifierMatches(type, qualifier); + } + + /// + /// Compares the written left-hand qualifier of a name against the type's namespace and containing types. + /// + static bool QualifierMatches(in TypeIdentity type, NameSyntax? qualifier) + { + // An unqualified reference is always possible via a using directive or the containing scope. + if (qualifier is null) + return true; + + var expected = BuildExpectedQualifier(type); + + var written = ImmutableArray.CreateBuilder(); + var rooted = TypeSyntaxFacts.CollectQualifierSegments(qualifier, written); + + if (written.Count > expected.Count) + return false; + + if (rooted && written.Count != expected.Count) + return false; + + // The written qualifier must be a trailing run of the full qualifier: with `using System.Collections;` + // in scope, `Generic.List` is a legal spelling. + var offset = expected.Count - written.Count; + for (var index = 0; index < written.Count; index++) + { + if (!string.Equals(expected[offset + index], written[index], StringComparison.Ordinal)) + return false; + } + + return true; + } + + static List BuildExpectedQualifier(in TypeIdentity type) + { + var segments = new List(); + + if (type.Namespace is { Length: > 0 } @namespace) + { + var start = 0; + for (var index = 0; index <= @namespace.Length; index++) + { + if (index != @namespace.Length && @namespace[index] != '.') + continue; + + segments.Add(@namespace.Substring(start, index - start)); + start = index + 1; + } + } + + if (!type.ContainingTypes.IsDefaultOrEmpty) + { + foreach (var containingType in type.ContainingTypes) + segments.Add(containingType.Name); + } + + return segments; + } + + static bool DeclaredContainingTypesMatch(in TypeIdentity type, SyntaxNode node) + { + var expectedCount = type.ContainingTypes.IsDefaultOrEmpty ? 0 : type.ContainingTypes.Length; + + var index = expectedCount - 1; + for (var parent = node.Parent; parent is not null; parent = parent.Parent) + { + if (parent is not TypeDeclarationSyntax containing) + continue; + + if (index < 0) + return false; + + var expected = type.ContainingTypes[index]; + if ( + !string.Equals(expected.Name, containing.Identifier.ValueText, StringComparison.Ordinal) + || expected.GenericArity != (containing.TypeParameterList?.Parameters.Count ?? 0) + ) + return false; + + index--; + } + + return index == -1; + } +} diff --git a/src/src/SourceGeneratorFramework/XmlCommentWriter.cs b/src/src/SourceGeneratorShared/XmlCommentWriter.cs similarity index 99% rename from src/src/SourceGeneratorFramework/XmlCommentWriter.cs rename to src/src/SourceGeneratorShared/XmlCommentWriter.cs index 7653a13..11a3b61 100644 --- a/src/src/SourceGeneratorFramework/XmlCommentWriter.cs +++ b/src/src/SourceGeneratorShared/XmlCommentWriter.cs @@ -94,7 +94,7 @@ public CodeWriter XmlException(string exceptionType, params string[] content) => /// The type of the exception. /// The content lines. /// The current writer. - public CodeWriter XmlException(TypeValueObject exceptionType, params string[] content) => + public CodeWriter XmlException(TypeIdentity exceptionType, params string[] content) => XmlCore(writer, BuildXmlTag("exception", ("cref", exceptionType)), "exception", content); /// @@ -103,7 +103,7 @@ public CodeWriter XmlException(TypeValueObject exceptionType, params string[] co /// The name of the type. /// The content lines. /// The current writer. - public CodeWriter XmlCref(TypeValueObject typeName, params string[] content) => + public CodeWriter XmlCref(TypeIdentity typeName, params string[] content) => XmlCore(writer, BuildXmlTag("cref", ("cref", typeName)), "cref", content); /// diff --git a/src/tests/SourceGeneratorFramework.UnitTests/Extensions/CodeWriterAssertionsExtensions.cs b/src/tests/SharedTestingInfra/Assertions/CodeWriterAssertionsExtensions.cs similarity index 96% rename from src/tests/SourceGeneratorFramework.UnitTests/Extensions/CodeWriterAssertionsExtensions.cs rename to src/tests/SharedTestingInfra/Assertions/CodeWriterAssertionsExtensions.cs index c62e27f..9647be6 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/Extensions/CodeWriterAssertionsExtensions.cs +++ b/src/tests/SharedTestingInfra/Assertions/CodeWriterAssertionsExtensions.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using TUnit.Assertions.Attributes; -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Assertions; [EditorBrowsable(EditorBrowsableState.Never)] public static partial class CodeWriterAssertionsExtensions diff --git a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj new file mode 100644 index 0000000..a97e7c9 --- /dev/null +++ b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/tests/SharedTestingInfra/TestCompilation.cs b/src/tests/SharedTestingInfra/TestCompilation.cs new file mode 100644 index 0000000..8752497 --- /dev/null +++ b/src/tests/SharedTestingInfra/TestCompilation.cs @@ -0,0 +1,85 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.SourceGeneratorFramework.Testing; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Builds compilations for symbol- and syntax-level matching tests. +/// +public static class TestCompilation +{ + // Basic.Reference.Assemblies provides a single, unambiguous reference set. Building references from + // AppDomain.CurrentDomain.GetAssemblies() makes GetTypeByMetadataName return null for types that are + // forwarded across facade assemblies. + static CSharpParseOptions ParseOptions => new(LanguageVersion.Latest); + + static CSharpCompilationOptions CompilationOptions => + new( + OutputKind.DynamicallyLinkedLibrary, + allowUnsafe: true, + nullableContextOptions: NullableContextOptions.Enable + ); + + public static CSharpCompilation Create(string? source = null) + { + SyntaxTree[] trees = source is null ? [] : [CSharpSyntaxTree.ParseText(source, ParseOptions)]; + + return CSharpCompilation.Create( + "Tests", + trees, + SourceGeneratorHelpers.ResolveTrustedReferences, + CompilationOptions + ); + } + + public static (CSharpCompilation Compilation, CompilationUnitSyntax Root) CreateWithRoot(string source) + { + var compilation = Create(source); + var root = (CompilationUnitSyntax)compilation.SyntaxTrees.Single().GetRoot(); + + return (compilation, root); + } + + public static (SyntaxTree Tree, CompilationUnitSyntax Root) Parse(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, ParseOptions); + + return (tree, (CompilationUnitSyntax)tree.GetRoot()); + } + + /// + /// Resolves the type of a field declared in the given source, by field name. + /// + /// + /// Declares a single field inside Sample.Holder<T> and returns its resolved type symbol. + /// + /// A field declaration, for example public int[] Value;. + /// The name of the field to resolve. + public static ITypeSymbol FieldType(string fieldDeclaration, string fieldName = "Value") + { + var compilation = Create(Holder(fieldDeclaration)); + var containing = compilation.GetTypeByMetadataName("Sample.Holder`1")!; + + return ((IFieldSymbol)containing.GetMembers(fieldName).Single()).Type; + } + + /// + /// Wraps member declarations in Sample.Holder<T>, with nullable and unsafe enabled and a + /// type parameter T in scope. + /// + public static string Holder(string members) => + $$""" + #nullable enable + using System; + using System.Collections.Generic; + + namespace Sample; + + public unsafe class Holder + { + {{members}} + } + """; +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelSymbolPropertyAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelSymbolPropertyAnalyzerTests.cs new file mode 100644 index 0000000..5c65c7a --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelSymbolPropertyAnalyzerTests.cs @@ -0,0 +1,176 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class AttributeDataModelSymbolPropertyAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + const string AttributeDefinition = """ + using System; + using Microsoft.CodeAnalysis; + + namespace Purview.SourceGeneratorFramework.Generators; + + [AttributeUsage(AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] + public sealed class GenerateAttribute : Attribute { } + + public readonly record struct TypeIdentity; + """; + + [Test] + public async Task ISymbolProperty_ReportsDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ISymbol SymbolProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + } + + [Test] + public async Task ITypeSymbolProperty_ReportsDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ITypeSymbol TypeSymbolProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + } + + [Test] + public async Task INamedTypeSymbolProperty_ReportsDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + INamedTypeSymbol NamedTypeSymbolProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + } + + [Test] + public async Task SystemTypeProperty_ReportsDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + Type TypeProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + } + + [Test] + public async Task NullableISymbolProperty_ReportsDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ISymbol? SymbolProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + } + + [Test] + public async Task TypeIdentityProperty_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + TypeIdentity TypeIdentityProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task StringProperty_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + string StringProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task IntProperty_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + int IntProperty + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) => base.OnBeforeRun(sources, options.WithAdditionalAssemblyTypes(typeof(ISymbol)), cancellationToken); +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AvoidRegisterImplementationSourceOutputAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AvoidRegisterImplementationSourceOutputAnalyzerTests.cs index da436b1..548314c 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AvoidRegisterImplementationSourceOutputAnalyzerTests.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AvoidRegisterImplementationSourceOutputAnalyzerTests.cs @@ -1,6 +1,9 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + namespace Purview.SourceGeneratorFramework.Analyzers; -public class AvoidRegisterImplementationSourceOutputAnalyzerTests +public sealed class AvoidRegisterImplementationSourceOutputAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase { [Test] public async Task RegisterImplementationSourceOutput_ReportsDiagnostic(CancellationToken cancellationToken) @@ -20,13 +23,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } """; - var diagnostics = await new AvoidRegisterImplementationSourceOutputAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).Count().IsEqualTo(1); - await Assert.That(diagnostics.First().Id).IsEqualTo("PSGFR14"); + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AvoidRegisterImplementationSourceOutputAnalyzer.Rule.Id); } [Test] @@ -47,11 +47,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } """; - var diagnostics = await new AvoidRegisterImplementationSourceOutputAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).IsEmpty(); + await Assert.That(result).HasNoDiagnostics(); } } diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/GenerationCapabilitiesMustBeRecordAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/GenerationCapabilitiesMustBeRecordAnalyzerTests.cs new file mode 100644 index 0000000..fa0e68c --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/GenerationCapabilitiesMustBeRecordAnalyzerTests.cs @@ -0,0 +1,126 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class GenerationCapabilitiesMustBeRecordAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task GenerationContext_GivenClassCapabilities_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + public sealed class MyCapabilities + { + } + + public sealed class MyContext + : GenerationContext + { + public MyContext( + MyCapabilities capabilities, + GenerationSettings settings + ) + : base(capabilities, settings) + { + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(GenerationCapabilitiesMustBeRecordAnalyzer.Rule.Id); + } + + [Test] + public async Task GenerationContext_GivenRecordCapabilities_DoesNotReportDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + public sealed record MyCapabilities( + bool HasEntityFrameworkCore + ); + + public sealed class MyContext + : GenerationContext + { + public MyContext( + MyCapabilities capabilities, + GenerationSettings settings + ) + : base(capabilities, settings) + { + } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(diagnostics).HasNoDiagnostics(); + } + + [Test] + public async Task GenerationContext_GivenRecordStructCapabilities_DoesNotReportDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + public readonly record struct MyCapabilities( + bool HasEntityFrameworkCore + ); + + public sealed class MyContext + : GenerationContext + { + public MyContext( + MyCapabilities capabilities, + GenerationSettings settings + ) + : base(capabilities, settings) + { + } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(diagnostics).HasNoDiagnostics(); + } + + [Test] + public async Task UnrelatedGenericType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + public sealed class MyCapabilities + { + } + + public sealed class Wrapper + { + } + + public sealed class Consumer + { + public Wrapper? Value { get; } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(diagnostics).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) => base.OnBeforeRun(sources, options.WithAdditionalAssemblyTypes(typeof(GenerationContext<>)), cancellationToken); +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PipelineModelReferenceEqualityCollectionAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PipelineModelReferenceEqualityCollectionAnalyzerTests.cs new file mode 100644 index 0000000..f5414d8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PipelineModelReferenceEqualityCollectionAnalyzerTests.cs @@ -0,0 +1,235 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class PipelineModelReferenceEqualityCollectionAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task ImmutableArrayProperty_InPipelineModel_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Immutable; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public ImmutableArray Values { get; init; } + } + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } + + [Test] + public async Task EquatableArrayProperty_InPipelineModel_DoesNotReportDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public EquatableArray Values { get; init; } + } + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task ImmutableArrayRecordParameter_InPipelineModel_ReportsDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using System.Collections.Immutable; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel(ImmutableArray Values); + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } + + [Test] + public async Task ListProperty_InPipelineModel_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Generic; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public List Values { get; init; } + } + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } + + [Test] + public async Task ArrayProperty_InPipelineModel_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public string[] Values { get; init; } + } + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } + + [Test] + public async Task ImmutableArrayProperty_InNonPipelineType_DoesNotReportDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using System.Collections.Immutable; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public ImmutableArray Values { get; init; } + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task NestedModelElement_WithImmutableArray_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Immutable; + using Purview.SourceGeneratorFramework; + + public sealed record ChildModel(ImmutableArray Values); + + public sealed record ParentModel + { + public EquatableArray Children { get; init; } + } + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes(typeof(GeneratorResult<>)), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } + + [Test] + public async Task ImmutableArrayProperty_InIncrementalValuesProviderModel_ReportsDiagnostic( + CancellationToken cancellationToken + ) + { + const string source = """ + using System.Collections.Immutable; + using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel + { + public ImmutableArray Values { get; init; } + } + + public class Generator + { + public IncrementalValuesProvider Transform() => default!; + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions().WithAdditionalAssemblyTypes( + typeof(GeneratorResult<>), + typeof(IncrementalValuesProvider<>) + ), + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + } +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferForAttributeWithMetadataNameAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferForAttributeWithMetadataNameAnalyzerTests.cs index 6d702b5..3457f63 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferForAttributeWithMetadataNameAnalyzerTests.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferForAttributeWithMetadataNameAnalyzerTests.cs @@ -1,6 +1,9 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + namespace Purview.SourceGeneratorFramework.Analyzers; -public class PreferForAttributeWithMetadataNameAnalyzerTests +public sealed class PreferForAttributeWithMetadataNameAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase { [Test] public async Task CreateSyntaxProvider_ReportsDiagnostic(CancellationToken cancellationToken) @@ -20,13 +23,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } """; - var diagnostics = await new PreferForAttributeWithMetadataNameAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).Count().IsEqualTo(1); - await Assert.That(diagnostics.First().Id).IsEqualTo("PSGFR11"); + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PreferForAttributeWithMetadataNameAnalyzer.Rule.Id); } [Test] @@ -48,11 +48,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } """; - var diagnostics = await new PreferForAttributeWithMetadataNameAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).IsEmpty(); + await Assert.That(result).HasNoDiagnostics(); } } diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SourceGeneratorFramework.Analyzers.UnitTests.csproj b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SourceGeneratorFramework.Analyzers.UnitTests.csproj index 5a837c9..c65f15a 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SourceGeneratorFramework.Analyzers.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/SourceGeneratorFramework.Analyzers.UnitTests.csproj @@ -1,6 +1,6 @@ - + - + diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/UseIncrementalGeneratorAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/UseIncrementalGeneratorAnalyzerTests.cs index b1c1381..df7a494 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/UseIncrementalGeneratorAnalyzerTests.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/UseIncrementalGeneratorAnalyzerTests.cs @@ -1,6 +1,9 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + namespace Purview.SourceGeneratorFramework.Analyzers; -public class UseIncrementalGeneratorAnalyzerTests +public sealed class UseIncrementalGeneratorAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase { [Test] public async Task ISourceGenerator_ReportsDiagnostic(CancellationToken cancellationToken) @@ -15,13 +18,10 @@ public void Execute(GeneratorExecutionContext context) { } } """; - var diagnostics = await new UseIncrementalGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).Count().IsEqualTo(1); - await Assert.That(diagnostics.First().Id).IsEqualTo("PSGFR12"); + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(UseIncrementalGeneratorAnalyzer.Rule.Id); } [Test] @@ -36,11 +36,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { } } """; - var diagnostics = await new UseIncrementalGeneratorAnalyzer().GetAnalyzerDiagnosticsAsync( - source, - cancellationToken - ); + var result = await AnalyzeAsync(source, cancellationToken); - await Assert.That(diagnostics).IsEmpty(); + await Assert.That(result).HasNoDiagnostics(); } } diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AttributeDataModelSymbolPropertyCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AttributeDataModelSymbolPropertyCodeFixProviderTests.cs new file mode 100644 index 0000000..2cca8f2 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AttributeDataModelSymbolPropertyCodeFixProviderTests.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class AttributeDataModelSymbolPropertyCodeFixProviderTests + : TUnitCodeFixTestBase +{ + const string AttributeDefinition = """ + using System; + using Microsoft.CodeAnalysis; + + namespace Purview.SourceGeneratorFramework.Generators; + + [AttributeUsage(AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] + public sealed class GenerateAttribute : Attribute { } + + public readonly record struct TypeIdentity; + """; + + [Test] + public async Task ISymbolProperty_ChangesToTypeIdentity(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ISymbol SymbolProperty + ); + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = "TypeIdentity", AdditionalAssemblyTypes = [typeof(ISymbol)] }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("TypeIdentity SymbolProperty"); + await Assert.That(result.FixedSource).DoesNotContain("ISymbol SymbolProperty"); + } + + [Test] + public async Task ISymbolProperty_ChangesToString(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ISymbol SymbolProperty + ); + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = "string", AdditionalAssemblyTypes = [typeof(ISymbol)] }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("string SymbolProperty"); + await Assert.That(result.FixedSource).DoesNotContain("ISymbol SymbolProperty"); + } + + [Test] + public async Task NullableISymbolProperty_ChangesToNullableString(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + + [Generate] + public readonly record struct MyModel( + ISymbol? SymbolProperty + ); + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = "string", AdditionalAssemblyTypes = [typeof(ISymbol)] }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(AttributeDataModelSymbolPropertyAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("string? SymbolProperty"); + await Assert.That(result.FixedSource).DoesNotContain("ISymbol? SymbolProperty"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PipelineModelReferenceEqualityCollectionCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PipelineModelReferenceEqualityCollectionCodeFixProviderTests.cs new file mode 100644 index 0000000..1080710 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PipelineModelReferenceEqualityCollectionCodeFixProviderTests.cs @@ -0,0 +1,42 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PipelineModelReferenceEqualityCollectionCodeFixProviderTests + : TUnitCodeFixTestBase< + PipelineModelReferenceEqualityCollectionAnalyzer, + PipelineModelReferenceEqualityCollectionCodeFixProvider + > +{ + [Test] + public async Task ImmutableArrayRecordParameter_ChangesToEquatableArray(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Immutable; + using Purview.SourceGeneratorFramework; + + public sealed record MyModel(ImmutableArray Values); + + public class Generator + { + public GeneratorResult Transform() => default; + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PipelineModelReferenceEqualityCollectionCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(GeneratorResult<>)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PipelineModelReferenceEqualityCollectionAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("EquatableArray Values"); + await Assert.That(result.FixedSource).DoesNotContain("ImmutableArray Values"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/SourceGeneratorFramework.CodeFixers.UnitTests.csproj b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/SourceGeneratorFramework.CodeFixers.UnitTests.csproj new file mode 100644 index 0000000..36f5252 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/SourceGeneratorFramework.CodeFixers.UnitTests.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs index 9118448..833c8b7 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs @@ -1,5 +1,5 @@ +using System.Collections.Immutable; using Purview.SourceGeneratorFramework.Examples; -using Purview.SourceGeneratorFramework.Helpers; using Purview.SourceGeneratorFramework.Testing.TUnit; namespace Purview.SourceGeneratorFramework.ExampleGenerator; @@ -114,13 +114,10 @@ public class MyService { } source, new ServiceRegistrationTestOptions { - AnalyzerConfigOptions = + AnalyzerConfigOptions = new Dictionary { - [ - IncrementalPipeline.BuildProperty - + ServiceRegistrationGeneratorPropertyLibrary.EmitServiceRegistrationInfo - ] = "true", - }, + { PropertyLibrary.EmitServiceRegistrationInfo, "true" }, + }.ToImmutableDictionary(), }, cancellationToken ); diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationTestOptions.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationTestOptions.cs index 1e16c2a..79851f2 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationTestOptions.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationTestOptions.cs @@ -12,7 +12,6 @@ public ServiceRegistrationTestOptions() typeof(ServiceLifetime) ); AdditionalNamespaces = AdditionalNamespaces.Add("Purview.SourceGeneratorFramework.Examples"); - DisableSourceGeneratorPropertyName = - ServiceRegistrationGeneratorPropertyLibrary.DisableServiceRegistrationGenerator; + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableServiceRegistrationGenerator; } } diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj index c662837..049c49d 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj @@ -1,10 +1,9 @@ - + - diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs index da8b367..f4d60ce 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs @@ -2,18 +2,19 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Testing; -using Purview.SourceGeneratorFramework.Testing.TUnit; +using Purview.SourceGeneratorFramework.Generators.Helpers; namespace Purview.SourceGeneratorFramework.Generators; -public class AttributeDataModelGeneratorTests : TUnitSourceGeneratorTestBase +public class AttributeDataModelGeneratorTests + : TUnitSourceGeneratorTestBase { [Test] public async Task Generate_RequiredAttributeData_DefaultNamedAndNestedModel(CancellationToken cancellationToken) { var source = """ using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; using Purview.SourceGeneratorFramework.Generators; using System.ComponentModel.DataAnnotations; @@ -23,7 +24,7 @@ namespace Test public readonly partial record struct ValidationAttributeData( string? ErrorMessage, string? ErrorMessageResourceName, - ITypeSymbol? ErrorMessageResourceType + TypeIdentity? ErrorMessageResourceType ); [Generate(typeof(RequiredAttribute))] @@ -215,15 +216,14 @@ await Assert public async Task Generate_AutoDiscover_DiscoversNamedArguments(CancellationToken cancellationToken) { var source = """ - using Purview.SourceGeneratorFramework.Generators; - using System.ComponentModel.DataAnnotations; +using Purview.SourceGeneratorFramework.Generators; +using System.ComponentModel.DataAnnotations; - namespace Test - { - [Generate(typeof(RequiredAttribute), AutoDiscover = true)] - public readonly partial record struct RequiredAttributeData; - } - """; +namespace Test; + +[Generate(typeof(RequiredAttribute), AutoDiscover = true)] +public readonly partial record struct RequiredAttributeData; +"""; var result = await GenerateAsync(source, cancellationToken: cancellationToken); @@ -297,7 +297,7 @@ [NestedModel] NotAModel NotAModel var result = await GenerateAsync(source, cancellationToken: cancellationToken); - await Assert.That(result.DriverResult.Diagnostics).Contains(d => d.Id == "ADM0004"); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NestedModelNotGenerated); } [Test] @@ -490,6 +490,7 @@ public async Task Generate_ResourceDefinitionAttributeData_PullsDataOut(Cancella { var source = """ using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; using Purview.SourceGeneratorFramework.Generators; namespace Aspire.Hosting.ApplicationModel @@ -522,7 +523,7 @@ public ResourceDefinitionAttribute(string name) : base(name) { } public readonly partial record struct ResourceDefinitionAttributeData( [Argument("name")] string? Name, [Argument("propertyName")] string? PropertyName, - [GenericTypeArgument] INamedTypeSymbol? AspireResourceType + [GenericTypeArgument] TypeIdentity AspireResourceType ); [ResourceDefinition("myResource", "MyResource")] @@ -547,11 +548,13 @@ public class MyGenericResource : global::Aspire.Hosting.ApplicationModel.IResour await Assert.That(generated).Contains("readonly partial record struct ResourceDefinitionAttributeData"); await Assert.That(generated).Contains("string? Name"); await Assert.That(generated).Contains("string? PropertyName"); - await Assert.That(generated).Contains("INamedTypeSymbol? AspireResourceType"); + await Assert + .That(generated) + .Contains("global::Purview.SourceGeneratorFramework.TypeIdentity AspireResourceType"); await Assert .That(generated) .Contains( - "attributeData.TryGetGenericTypeArgument(0, out var aspireResourceType)" + "attributeData.TryGetGenericTypeArgument(0, out var aspireResourceType)" ); await Assert .That(generated) @@ -561,6 +564,7 @@ await Assert var runtimeSource = """ using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; namespace Aspire.Hosting.ApplicationModel { @@ -591,7 +595,7 @@ public ResourceDefinitionAttribute(string name) : base(name) { } public readonly partial record struct ResourceDefinitionAttributeData( string? Name, string? PropertyName, - INamedTypeSymbol? AspireResourceType + TypeIdentity AspireResourceType ); [ResourceDefinition("myResource", "MyResource")] @@ -675,13 +679,15 @@ public class MyGenericResource : global::Aspire.Hosting.ApplicationModel.IResour await Assert.That(nameProperty!.GetValue(resourceData)).IsEqualTo("myResource"); await Assert.That(propertyNameProperty!.GetValue(resourceData)).IsEqualTo("MyResource"); - await Assert.That(aspireResourceTypeProperty!.GetValue(resourceData)).IsNull(); + await Assert + .That((TypeIdentity)aspireResourceTypeProperty!.GetValue(resourceData)!) + .IsEqualTo(TypeIdentity.Empty); await Assert.That(nameProperty.GetValue(genericResourceData)).IsEqualTo("myGenericResource"); await Assert.That(propertyNameProperty.GetValue(genericResourceData)).IsEqualTo("MyGenericResource"); - var aspireResourceType = aspireResourceTypeProperty.GetValue(genericResourceData) as INamedTypeSymbol; + var aspireResourceType = (TypeIdentity?)aspireResourceTypeProperty.GetValue(genericResourceData); await Assert.That(aspireResourceType).IsNotNull(); - await Assert.That(SymbolEqualityComparer.Default.Equals(aspireResourceType, myResourceType)).IsTrue(); + await Assert.That(aspireResourceType!.Value.Matches(myResourceType)).IsTrue(); } [Test] @@ -689,13 +695,14 @@ public async Task Generate_SystemTypeProperty_MapsToINamedTypeSymbol(Cancellatio { var source = """ using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; using Purview.SourceGeneratorFramework.Generators; namespace Test { [Generate(typeof(TestingAttribute))] public readonly partial record struct TestingAttributeData( - [Argument("typeThing")] [Property] INamedTypeSymbol? TypeThing + [Argument("typeThing")] [Property] TypeIdentity? TypeThing ); public class TestingAttribute : System.Attribute @@ -706,11 +713,7 @@ public class TestingAttribute : System.Attribute } """; - var result = await GenerateAsync( - source, - new SourceGeneratorTestOptions { CompileToAssembly = false }, - cancellationToken: cancellationToken - ); + var result = await GenerateAsync(source, cancellationToken: cancellationToken); result.AssertNoGenerationExceptions().AssertNoLogErrors(); @@ -722,16 +725,16 @@ public class TestingAttribute : System.Attribute await Assert.That(generated).IsNotNull(); await Assert.That(generated).Contains("readonly partial record struct TestingAttributeData"); - await Assert.That(generated).Contains("INamedTypeSymbol? TypeThing"); + await Assert.That(generated).Contains("global::Purview.SourceGeneratorFramework.TypeIdentity? TypeThing"); await Assert .That(generated) .Contains( - "attributeData.TryGetConstructorArgument(\"typeThing\", out typeThing)" + "attributeData.TryGetConstructorArgument(\"typeThing\", out typeThing)" ); await Assert .That(generated) .Contains( - "attributeData.TryGetNamedArgument(\"TypeThing\", out typeThing)" + "attributeData.TryGetNamedArgument(\"TypeThing\", out typeThing)" ); } diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelTestOptions.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelTestOptions.cs new file mode 100644 index 0000000..70c7e4d --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelTestOptions.cs @@ -0,0 +1,9 @@ +namespace Purview.SourceGeneratorFramework.Generators; + +public sealed record AttributeDataModelTestOptions : SourceGeneratorTestOptions +{ + public AttributeDataModelTestOptions() + { + AdditionalAssemblyTypes = AdditionalAssemblyTypes.Add(typeof(TypeIdentity)); + } +} diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/GlobalUsings.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..bf37c08 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Purview.SourceGeneratorFramework.Testing; +global using Purview.SourceGeneratorFramework.Testing.TUnit; diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/SourceGeneratorFramework.Generators.UnitTests.csproj b/src/tests/SourceGeneratorFramework.Generators.UnitTests/SourceGeneratorFramework.Generators.UnitTests.csproj index 80741bc..c3b508c 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/SourceGeneratorFramework.Generators.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/SourceGeneratorFramework.Generators.UnitTests.csproj @@ -3,8 +3,4 @@ - - - - diff --git a/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataExtensionsTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataExtensionsTests.cs index c068a1a..9162d5c 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataExtensionsTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/AttributeDataExtensionsTests.cs @@ -3,7 +3,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Purview.SourceGeneratorFramework.Extensions; namespace Purview.SourceGeneratorFramework; diff --git a/src/tests/SourceGeneratorFramework.UnitTests/GenerationContextTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/GenerationContextTests.cs deleted file mode 100644 index ace7c29..0000000 --- a/src/tests/SourceGeneratorFramework.UnitTests/GenerationContextTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Logging; -using Purview.SourceGeneratorFramework.Models; - -namespace Purview.SourceGeneratorFramework; - -public class GenerationContextTests -{ - static CSharpCompilation CreateCompilation() - { - var syntaxTree = CSharpSyntaxTree.ParseText("class C { }"); - return CSharpCompilation.Create( - "TestAssembly", - [syntaxTree], - references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)] - ); - } - - sealed class TestContext(Compilation compilation) - : GenerationContext(compilation, new GenerationSettings("TestGenerator", "1.0.0")); - - sealed class TestLogger : ISourceGenLogger - { - public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) { } - } - - [Test] - public async Task GetTypeByMetadataName_KnownType_ReturnsSymbol() - { - var compilation = CreateCompilation(); - var context = new TestContext(compilation); - - var symbol = context.GetTypeByMetadataName("System.Object"); - - await Assert.That(symbol).IsNotNull(); - await Assert.That(symbol!.Name).IsEqualTo("Object"); - } - - [Test] - public async Task GetTypeByMetadataName_UnknownType_ReturnsNull() - { - var compilation = CreateCompilation(); - var context = new TestContext(compilation); - - var symbol = context.GetTypeByMetadataName("NonExistent.Type"); - - await Assert.That(symbol).IsNull(); - } - - [Test] - public async Task GetTypeByMetadataName_TypeValueObject_ReturnsSymbol() - { - var compilation = CreateCompilation(); - var context = new TestContext(compilation); - var type = new TypeValueObject("Object", "System"); - - var symbol = context.GetTypeByMetadataName(type); - - await Assert.That(symbol).IsNotNull(); - await Assert.That(symbol!.Name).IsEqualTo("Object"); - } - - [Test] - public async Task CreateCodeWriter_GivenScopeValidationEnabled_ConfiguresEveryWriter() - { - // Arrange - var compilation = CreateCompilation(); - var context = new GenerationContext( - compilation, - new GenerationSettings("TestGenerator", "1.0.0", validateCodeWriterScopes: true) - ); - - // Act - var writer = context.CreateCodeWriter(); - - // Assert - await Assert.That(writer.ThrowOnUnclosedScopes).IsTrue(); - await Assert.That(ReferenceEquals(writer, context.CreateCodeWriter())).IsFalse(); - } - - [Test] - public async Task CreateCodeWriter_GivenGeneratorIdentity_PropagatesIdentity() - { - var context = new GenerationContext(CreateCompilation(), new GenerationSettings("HostKitGenerator", "2.3.4")); - - var writer = context.CreateCodeWriter(); - - await Assert.That(writer.GeneratorName).IsEqualTo("HostKitGenerator"); - await Assert.That(writer.GeneratorVersion).IsEqualTo("2.3.4"); - } - - [Test] - public async Task Constructor_GivenLogger_ExposesLogger() - { - // Arrange - var logger = new TestLogger(); - var context = new GenerationContext( - CreateCompilation(), - new GenerationSettings("TestGenerator", "1.0.0"), - logger - ); - - // Act / Assert - await Assert.That(context.Logger).IsSameReferenceAs(logger); - } - - [Test] - public async Task Contexts_GivenDifferentCompilations_AreDistinctReferences() - { - var first = CreateCompilation(); - var second = CSharpCompilation.Create( - "TestAssembly", - [CSharpSyntaxTree.ParseText("class D { }")], - references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)] - ); - - var settings = new GenerationSettings("TestGenerator", "1.0.0"); - var contextA = new GenerationContext(first, settings); - var contextB = new GenerationContext(second, settings); - - await Assert.That(contextA).IsNotEqualTo(contextB); - } - - [Test] - public async Task GenerationSettings_UsesValueEquality() - { - var settingsA = new GenerationSettings("A", "1.0.0"); - var settingsB = new GenerationSettings("A", "1.0.0"); - var settingsC = settingsA with { ValidateCodeWriterScopes = true }; - - await Assert.That(settingsA).IsEqualTo(settingsB); - await Assert.That(settingsA).IsNotEqualTo(settingsC); - } -} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/EmbeddedResourcesTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/Helpers/EmbeddedResourcesTests.cs similarity index 93% rename from src/tests/SourceGeneratorFramework.UnitTests/EmbeddedResourcesTests.cs rename to src/tests/SourceGeneratorFramework.UnitTests/Helpers/EmbeddedResourcesTests.cs index 4f5e9d3..86d749a 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/EmbeddedResourcesTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/Helpers/EmbeddedResourcesTests.cs @@ -1,6 +1,4 @@ -using Purview.SourceGeneratorFramework.Helpers; - -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Helpers; public class EmbeddedResourcesTests { diff --git a/src/tests/SourceGeneratorFramework.UnitTests/SymbolResolverTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/Helpers/SymbolResolverTests.cs similarity index 90% rename from src/tests/SourceGeneratorFramework.UnitTests/SymbolResolverTests.cs rename to src/tests/SourceGeneratorFramework.UnitTests/Helpers/SymbolResolverTests.cs index 788f65f..35ea78d 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/SymbolResolverTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/Helpers/SymbolResolverTests.cs @@ -1,9 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Helpers; -using Purview.SourceGeneratorFramework.Models; -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Helpers; public class SymbolResolverTests { @@ -42,7 +40,7 @@ public async Task Resolve_UnknownType_ReturnsNull() public async Task Resolve_TypeValueObject_ReturnsSymbol() { var compilation = CreateCompilation(); - var type = new TypeValueObject("Object", "System"); + var type = new TypeIdentity("Object", "System"); var symbol = SymbolResolver.Resolve(compilation, type); diff --git a/src/tests/SourceGeneratorFramework.UnitTests/IncrementalPipelineTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/IncrementalPipelineTests.cs deleted file mode 100644 index 82857cc..0000000 --- a/src/tests/SourceGeneratorFramework.UnitTests/IncrementalPipelineTests.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Purview.SourceGeneratorFramework.Helpers; -using Purview.SourceGeneratorFramework.Models; - -namespace Purview.SourceGeneratorFramework; - -public class IncrementalPipelineTests2 -{ - const string TestAttributeSource = """ - namespace Test - { - [System.AttributeUsage(System.AttributeTargets.Class)] - public sealed class TestAttribute : System.Attribute { } - } - """; - - [Test] - public async Task ForAttributeWithMetadataName_FindsAttributedClass() - { - var source = - """ - using Test; - - [TestAttribute] - public partial class MyClass { } - """ - + "\n" - + TestAttributeSource; - - var runner = new SourceGeneratorTestRunner(); - var result = await runner.RunAsync(source); - - var tree = result.GetGeneratedTree("MyClass.g.cs"); - await Assert.That(tree).IsNotNull(); - await Assert.That(tree!.FilePath).EndsWith("MyClass.g.cs"); - await Assert.That(tree.ToString()).Contains("class MyClass"); - } - - [Test] - public async Task IsDisabledValueProvider_WhenDisabled_DoesNotGenerate() - { - var source = - """ - using Test; - - [TestAttribute] - public partial class MyClass { } - """ - + "\n" - + TestAttributeSource; - - var runner = new SourceGeneratorTestRunner(); - var result = await runner.RunAsync( - source, - new SourceGeneratorTestOptions - { - DisableSourceGeneratorPropertyName = "DisableTestGenerator", - DisableSourceGeneratorValue = true, - } - ); - - await Assert.That(result.AllSyntaxTrees.Any()).IsFalse(); - } - - internal sealed class TestGenerator : IIncrementalGenerator - { - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var isDisabled = IncrementalPipeline.IsDisabledValueProvider(context, "DisableTestGenerator"); - var targets = IncrementalPipeline.ForAttributeWithMetadataName( - context, - new TypeValueObject("TestAttribute", "Test"), - static (ctx, ct) => - { - var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.TargetNode, ct); - return new TargetInfo(symbol?.Name ?? "Unknown"); - }, - predicate: static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax - ); - - var inputs = isDisabled - .CombineWith( - context.CompilationProvider.Select( - static (compilation, _) => compilation.AssemblyName ?? "Unknown" - ), - static (disabled, assemblyName, _) => new GenerationInputs(disabled, assemblyName), - "CreateGenerationInputs" - ) - .CollectWith( - targets, - static (state, collectedTargets, _) => state with { Targets = collectedTargets }, - "AddGenerationTargets" - ); - - context.RegisterSourceOutput( - inputs, - static (spc, source) => - { - if (source.IsDisabled) - return; - - foreach (var target in source.Targets) - { - spc.AddSource($"{target.Name}.g.cs", $"partial class {target.Name} {{ }}"); - } - } - ); - } - } - - [Test] - public async Task RegisterSourceOutput_ReportsDiagnosticsAndGeneratesForSuccessfulTargets() - { - var source = - """ - using Test; - - [TestAttribute] - public partial class MyClass { } - """ - + "\n" - + TestAttributeSource; - - var runner = new SourceGeneratorTestRunner(); - var result = await runner.RunAsync(source); - - var tree = result.GetGeneratedTree("MyClass.g.cs"); - await Assert.That(tree).IsNotNull(); - await Assert.That(tree!.ToString()).Contains("class MyClass"); - await Assert.That(result.DriverResult.Diagnostics).IsNotEmpty(); - } - - [Test] - public async Task GenerationContextValueProvider_WithoutScopeParameter_DoesNotRecurse() - { - var runner = new SourceGeneratorTestRunner(); - var result = await runner.RunAsync("public class C { }"); - - var tree = result.GetGeneratedTree("Context.g.cs"); - await Assert.That(tree).IsNotNull(); - await Assert.That(tree!.ToString()).Contains("TestAssembly"); - } - - readonly record struct TargetInfo(string Name); - - sealed record GenerationInputs(bool IsDisabled, string AssemblyName) - { - public ImmutableArray Targets { get; init; } = []; - } - - internal sealed class DiagnosticTestGenerator : IIncrementalGenerator - { - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var targets = IncrementalPipeline.ForAttributeWithMetadataName( - context, - new TypeValueObject("TestAttribute", "Test"), - static (ctx, ct) => - { - var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.TargetNode, ct); - var name = symbol?.Name ?? "Unknown"; - var diagnostic = DiagnosticInfo.Create( - new DiagnosticDescriptor( - "TEST001", - "Test diagnostic", - $"Processed {name}", - "Test", - DiagnosticSeverity.Info, - isEnabledByDefault: true - ), - Location.None - ); - return GeneratorResult.Ok(new TargetInfo(name), diagnostic); - } - ); - - var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( - context, - "DiagnosticTestGenerator", - "1.0.0" - ); - - IncrementalPipeline.RegisterSourceOutput( - context, - targets, - generationContext, - static (spc, target, ctx) => - { - var writer = ctx.CreateCodeWriter(); - writer.WriteLine($"partial class {target.Name} {{ }}"); - spc.AddSource($"{target.Name}.g.cs", writer.ToString()); - } - ); - } - } - - internal sealed class GenerationContextTestGenerator : IIncrementalGenerator - { - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var generationContext = IncrementalPipeline.GenerationContextValueProvider( - context, - "GenerationContextTestGenerator", - "1.0.0", - static (compilation, settings, logger, _) => new GenerationContext(compilation, settings, logger) - ); - - context.RegisterSourceOutput( - generationContext, - static (spc, value) => spc.AddSource("Context.g.cs", $"// {value.AssemblyName}") - ); - } - } -} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/SourceGenLoggingTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/Logging/SourceGenLoggingTests.cs similarity index 89% rename from src/tests/SourceGeneratorFramework.UnitTests/SourceGenLoggingTests.cs rename to src/tests/SourceGeneratorFramework.UnitTests/Logging/SourceGenLoggingTests.cs index e0bf04e..46792cf 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/SourceGenLoggingTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/Logging/SourceGenLoggingTests.cs @@ -1,6 +1,4 @@ -using Purview.SourceGeneratorFramework.Logging; - -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Logging; public class SourceGenLoggingTests { diff --git a/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj b/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj index 37f6d51..caaecd0 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/tests/SourceGeneratorFramework.UnitTests/TestingFrameworkTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/TestingFrameworkTests.cs index 3bc3a55..c90cc40 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/TestingFrameworkTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/TestingFrameworkTests.cs @@ -232,7 +232,7 @@ CancellationToken cancellationToken // Assert await Assert.That(options.ValidateCodeWriterScopes).IsTrue(); - await Assert.That(result.GetSource()).Contains("Value = \"true\""); + await Assert.That(result.GetSource()).Contains("Value = \"True\""); } [Test] @@ -241,7 +241,7 @@ CancellationToken cancellationToken ) { var runner = new SourceGeneratorTestRunner(); - var options = new SourceGeneratorTestOptions { AnalyzerConfigOptions = { ["CustomOption"] = "enabled" } }; + var options = new SourceGeneratorTestOptions().WithAnalyzerConfigOptions(("CustomOption", "enabled")); var result = await runner.RunAsync("public sealed class Input { }", options, cancellationToken); @@ -277,14 +277,12 @@ public async Task Constructor_DerivedOptions_CopiesConfiguredDefaultWithoutShari var originalDefault = SourceGeneratorTestOptions.Default; try { - SourceGeneratorTestOptions.Default = originalDefault with - { - AnalyzerConfigOptions = new Dictionary { ["Shared"] = "default" }, - }; + SourceGeneratorTestOptions.Default = originalDefault.WithAnalyzerConfigOptions(("Shared", "default")); var first = new CustomSourceGeneratorTestOptions(); var second = new CustomSourceGeneratorTestOptions(); - first.AnalyzerConfigOptions["OnlyFirst"] = "value"; + + first = first.WithAnalyzerConfigOptions(("OnlyFirst", "value")); await Assert.That(first.AnalyzerConfigOptions["Shared"]).IsEqualTo("default"); await Assert.That(second.AnalyzerConfigOptions.ContainsKey("OnlyFirst")).IsFalse(); @@ -295,4 +293,53 @@ public async Task Constructor_DerivedOptions_CopiesConfiguredDefaultWithoutShari SourceGeneratorTestOptions.Default = originalDefault; } } + + // --------------------------------------------------------------------------------------------- + // Compile() preserves the concrete options type for downstream derived records + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Compile_OnBaseOptions_SetsCompileToAssembly() + { + var options = new SourceGeneratorTestOptions(); + + var result = options.Compile(); + + await Assert.That(result.CompileToAssembly).IsTrue(); + await Assert.That(result.GetType()).IsEqualTo(typeof(SourceGeneratorTestOptions)); + } + + [Test] + public async Task Compile_OnAnalyzerOptions_PreservesConcreteType() + { + var options = new AnalyzerTestOptions(); + + var result = options.Compile(); + + await Assert.That(result.CompileToAssembly).IsTrue(); + await Assert.That(result.GetType()).IsEqualTo(typeof(AnalyzerTestOptions)); + } + + [Test] + public async Task Compile_OnCodeFixOptions_PreservesConcreteType() + { + var options = new CodeFixTestOptions(); + + var result = options.Compile(); + + await Assert.That(result.CompileToAssembly).IsTrue(); + await Assert.That(result.GetType()).IsEqualTo(typeof(CodeFixTestOptions)); + } + + [Test] + public async Task Compile_OnCustomDerivedOptions_PreservesConcreteTypeAndProperties() + { + var options = new CustomSourceGeneratorTestOptions(); + + var result = options.Compile(); + + await Assert.That(result.CompileToAssembly).IsTrue(); + await Assert.That(result.GetType()).IsEqualTo(typeof(CustomSourceGeneratorTestOptions)); + await Assert.That(result.CustomValue).IsEqualTo("custom"); + } } diff --git a/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.TestData.cs b/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.TestData.cs deleted file mode 100644 index 33cc682..0000000 --- a/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.TestData.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -namespace Purview.SourceGeneratorFramework; - -partial class TypeValueObjectTests -{ - public static IEnumerable> SymbolTestData - { - get - { - yield return () => - CreateSymbolTestData(new("Testing.Testing", true), "TestingClass", TypeDeclarationKind.Class); - yield return () => - CreateSymbolTestData(new("Testing.Testing", false), "TestingClass", TypeDeclarationKind.Class); - yield return () => - CreateSymbolTestData(NamespaceInfo.GlobalNamespace, "TestingClass", TypeDeclarationKind.Class); - } - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", - "CA1859:Use concrete types when possible for improved performance" - )] - static SymbolTestDataInfo CreateSymbolTestData( - NamespaceInfo namespaceInfo, - string typeName, - TypeDeclarationKind declarationKind = TypeDeclarationKind.Class, - TypeDeclarationAccessibility accessibility = TypeDeclarationAccessibility.Public - ) - { - var writer = CodeWriterFactory.ForTests(); - - IDisposable? blockNamespace = null; - if (namespaceInfo.HasNamespace) - { - if (namespaceInfo.IsFileScoped) - writer.WriteFileScopedNamespace(namespaceInfo.Namespace); - else - blockNamespace = writer.WriteBlockNamespaceScope(namespaceInfo.Namespace); - } - - using (writer.WriteTypeScope(new TypeDeclarationOptions(typeName, accessibility) { Kind = declarationKind })) - { - writer.WriteLine("public void DoAThing() {}"); - } - - blockNamespace?.Dispose(); - - SourceGeneratorTestOptions options = new(); - - var source = writer.ToString(); - var syntax = CSharpSyntaxTree.ParseText( - source, - encoding: System.Text.Encoding.UTF8, - options: new CSharpParseOptions(LanguageVersion.Preview), - cancellationToken: TestContext.Current?.Execution.CancellationToken ?? default - ); - - var references = SourceGeneratorHelpers.ResolveReferences(options, typeof(TypeValueObjectTests).Assembly); - var compilation = SourceGeneratorHelpers.CreateCompilation([syntax], references, options); - - //var model = compilation.GetSemanticModel(syntax); - var fullTypeName = namespaceInfo.HasNamespace ? $"{namespaceInfo.Namespace}.{typeName}" : typeName; - var symbol = compilation.GetTypeByMetadataName(fullTypeName) as ITypeSymbol; - - ArgumentNullException.ThrowIfNull(symbol); - - return new(fullTypeName, typeName, namespaceInfo, symbol); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] - public sealed record class SymbolTestDataInfo( - string FullTypeName, - string TypeName, - NamespaceInfo NamespaceInfo, - ITypeSymbol Symbol - ); - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] - public readonly record struct NamespaceInfo(string? Namespace, bool IsFileScoped) - { - public bool HasNamespace => !string.IsNullOrWhiteSpace(Namespace); - - public static NamespaceInfo GlobalNamespace => new(null, false); - } -} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.cs deleted file mode 100644 index dd0a2a8..0000000 --- a/src/tests/SourceGeneratorFramework.UnitTests/TypeValueObjectTests.cs +++ /dev/null @@ -1,327 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Models; - -namespace Purview.SourceGeneratorFramework; - -public partial class TypeValueObjectTests -{ - [Test] - public async Task TypeReferenceOptions_WithEmpty_ReturnsEmptySentinel() - { - TypeReferenceOptions sut = TypeValueObject.Empty; - - await Assert.That(sut).IsEqualTo(TypeReferenceOptions.Empty); - await Assert.That(sut.IsEmpty).IsTrue(); - } - - [Test] - public async Task TypeReferenceOptions_ComplexType_RendersAsCSharpSyntax() - { - var type = new TypeReferenceOptions(new TypeValueObject("List", "System.Collections.Generic")) - .MakeGeneric(new TypeValueObject("ValidationError", "ZodSharp.Core")) - .MakeArray() - .Nullable(); - - string implicitValue = type; - - await Assert - .That(implicitValue) - .IsEqualTo("global::System.Collections.Generic.List[]?"); - await Assert.That($"ref {type} errors").IsEqualTo($"ref {implicitValue} errors"); - } - - [Test] - public async Task Constructor_WithNamespace_RendersGlobalFullName() - { - var type = new TypeValueObject("MyType", "MyNamespace"); - - await Assert.That(type.MetadataFullName).IsEqualTo("MyNamespace.MyType"); - await Assert.That(type.RenderFullName).IsEqualTo("global::MyNamespace.MyType"); - } - - [Test] - public async Task Constructor_GlobalNamespace_RendersShortName() - { - var type = new TypeValueObject("MyType", null); - - await Assert.That(type.MetadataFullName).IsEqualTo("MyType"); - await Assert.That(type.RenderFullName).IsEqualTo("MyType"); - } - - [Test] - public async Task MakeGeneric_ProducesExpectedTypeName() - { - var type = new TypeValueObject("MyType", "MyNamespace"); - - var generic = type.MakeGeneric("string", "int"); - - await Assert.That(generic.RenderFullName).IsEqualTo("global::MyNamespace.MyType"); - } - - [Test] - public async Task DeclarationShapeHelpers_ReturnStructuredTypeReferences() - { - var type = new TypeValueObject("Widget", "Example"); - - await Assert.That(type.AsTypeReference().Name).IsEqualTo("global::Example.Widget"); - await Assert.That(type.MakeNullable().IsNullable).IsTrue(); - await Assert.That(type.MakeArray(2).ArrayRanks).IsEquivalentTo([2]); - await Assert.That(type.MakePointer().IsPointer).IsTrue(); - } - - [Test] - public async Task TypeReferenceOptions_RetainsSemanticTypeValueThroughSyntaxChanges() - { - var type = new TypeValueObject("Widget", "Example"); - var reference = type.AsTypeReference().Nullable().MakeArray(); - - await Assert.That(reference.TypeValue).IsEqualTo(type); - await Assert.That(reference.RenderTypeName).IsEqualTo("global::Example.Widget[]?"); - } - - [Test] - public async Task TypeReferenceOptions_RetainsSemanticTypeValueWhenAddingGenericSyntax() - { - var type = new TypeValueObject("Container", "Example"); - var reference = type.AsTypeReference().MakeGeneric(TypeValueObject.Create()); - - await Assert.That(reference.TypeValue).IsEqualTo(type); - await Assert.That(reference.RenderTypeName).IsEqualTo("global::Example.Container"); - } - - [Test] - public async Task TypeReferenceOptions_FromRuntimeType_PopulatesGenericAndArrayShape() - { - var reference = new TypeReferenceOptions(typeof(Dictionary[])); - - await Assert.That(reference.Name).IsEqualTo("global::System.Collections.Generic.Dictionary"); - await Assert - .That(reference.GenericArguments.Select(static argument => argument.RenderTypeName)) - .IsEquivalentTo(["string", "int"]); - await Assert.That(reference.ArrayRanks).IsEquivalentTo([1]); - await Assert.That(reference.TypeValue).IsNotEqualTo(TypeValueObject.Empty); - await Assert.That(reference.Equals(typeof(Dictionary[]))).IsTrue(); - await Assert - .That(new TypeValueObject(typeof(Dictionary[])).Equals(typeof(Dictionary[]))) - .IsTrue(); - } - - [Test] - public async Task TypeValueObject_AndTypeReference_CompareToRoslynSymbol() - { - const string source = "public sealed class Widget { }"; - var options = new SourceGeneratorTestOptions(); - var compilation = SourceGeneratorHelpers.CreateCompilation( - [CSharpSyntaxTree.ParseText(source)], - SourceGeneratorHelpers.ResolveReferences(options, typeof(TypeValueObjectTests).Assembly), - options - ); - var symbol = compilation.GetTypeByMetadataName("Widget")!; - var value = new TypeValueObject(symbol); - var reference = new TypeReferenceOptions(symbol); - - await Assert.That(value.Equals(symbol)).IsTrue(); - await Assert.That(reference.Equals(symbol)).IsTrue(); - } - - [Test] - public async Task TypeReferenceOptions_AndTypeValueObject_HaveSymmetricBaseTypeEquality() - { - var type = new TypeValueObject("Widget", "Example"); - var reference = type.AsTypeReference(); - - await Assert.That(reference.Equals(type)).IsTrue(); - await Assert.That(type.Equals(reference)).IsTrue(); - await Assert.That(reference).IsEqualTo(type.AsTypeReference()); - } - - [Test] - public async Task TypeReferenceOptions_SyntaxModifiersDoNotEqualUnderlyingTypeValue() - { - var type = new TypeValueObject("Widget", "Example"); - - await Assert.That(type.MakeNullable().Equals(type)).IsFalse(); - await Assert.That(type.MakeArray().Equals(type)).IsFalse(); - await Assert.That(type.MakePointer().Equals(type)).IsFalse(); - } - - [Test] - public async Task TypeReferenceOptions_FromRenderedStringHasNoSemanticTypeValue() - { - var reference = new TypeReferenceOptions(new TypeValueObject("Widget", "Example")); - - await Assert.That(reference.TypeValue).IsEqualTo(new TypeValueObject("Widget", "Example")); - await Assert.That(reference.Equals(new TypeValueObject("Widget", "Example"))).IsTrue(); - } - - [Test] - public async Task StaticMember_ReturnsFullyQualifiedExpression() - { - var type = new TypeValueObject("Severity", "Example"); - - var result = type.StaticMember("Inherit"); - - await Assert.That(result).IsEqualTo("global::Example.Severity.Inherit"); - } - - [Test] - [Arguments(null)] - [Arguments("")] - [Arguments(" ")] - public async Task StaticMember_GivenMissingName_Throws(string? memberName) - { - var type = new TypeValueObject("Severity", "Example"); - - await Assert.That(() => type.StaticMember(memberName!)).Throws(); - } - - [Test] - public async Task AttributeType_RendersAsTypeAndProvidesExplicitAttributeSyntax() - { - var type = new TypeValueObject("MyAttribute", "MyNamespace"); - - await Assert.That(type.RenderFullName).IsEqualTo("global::MyNamespace.MyAttribute"); - await Assert.That(type.RenderTypeName).IsEqualTo("MyAttribute"); - await Assert.That(type.RenderAttributeName).IsEqualTo("[global::MyNamespace.My]"); - } - - [Test] - public async Task Constructor_GivenOpenGenericReflectionType_PreservesDefinition() - { - // Arrange and Act - TypeValueObject type = new(typeof(List<>)); - - // Assert - await Assert.That(type.TypeName).IsEqualTo("List"); - await Assert.That(type.GenericArity).IsEqualTo(1); - await Assert.That(type.IsGenericTypeDefinition).IsTrue(); - await Assert.That(type.MetadataFullName).IsEqualTo("System.Collections.Generic.List`1"); - await Assert.That(type.RenderFullName).IsEqualTo("global::System.Collections.Generic.List<>"); - } - - [Test] - public async Task Constructor_GivenClosedGenericReflectionType_PreservesArguments() - { - // Arrange and Act - TypeValueObject type = new(typeof(Dictionary>)); - - // Assert - await Assert.That(type.GenericArity).IsEqualTo(2); - await Assert.That(type.IsGenericTypeDefinition).IsFalse(); - await Assert.That(type.TypeArguments).Count().IsEqualTo(2); - await Assert - .That(type.RenderFullName) - .IsEqualTo( - "global::System.Collections.Generic.Dictionary>" - ); - } - - [Test] - public async Task Constructor_GivenClosedGenericSymbol_PreservesArguments(CancellationToken cancellationToken) - { - // Arrange - const string source = """ - using System.Collections.Generic; - public sealed class Holder - { - public Dictionary> Value = new(); - } - """; - SourceGeneratorTestOptions options = new(); - var syntax = CSharpSyntaxTree.ParseText( - source, - options: new CSharpParseOptions(LanguageVersion.Preview), - cancellationToken: cancellationToken - ); - var compilation = SourceGeneratorHelpers.CreateCompilation( - [syntax], - SourceGeneratorHelpers.ResolveReferences(options, typeof(TypeValueObjectTests).Assembly), - options - ); - var holder = compilation.GetTypeByMetadataName("Holder"); - var field = holder?.GetMembers("Value").OfType().Single(); - - // Act - TypeValueObject type = new(field!.Type); - - // Assert - await Assert.That(type.TypeName).IsEqualTo("Dictionary"); - await Assert.That(type.GenericArity).IsEqualTo(2); - await Assert.That(type.TypeArguments).Count().IsEqualTo(2); - await Assert - .That(type.RenderFullName) - .IsEqualTo( - "global::System.Collections.Generic.Dictionary>" - ); - } - - [Test] - [MethodDataSource(nameof(SymbolTestData))] - public async Task Constructor_GivenISymbol_PopulatesCorrectly(SymbolTestDataInfo testData) - { - // Act - TypeValueObject sut = new(testData.Symbol); - - // Assert - await Assert.That(sut.Namespace).IsEqualTo(testData.NamespaceInfo.Namespace); - await Assert.That(sut.IsGlobalNamespace).IsEqualTo(!testData.NamespaceInfo.HasNamespace); - await Assert.That(sut.TypeName).IsEqualTo(testData.TypeName); - await Assert.That(sut.Keyword).IsNull(); - await Assert.That(sut.SpecialType).IsEqualTo(SpecialType.None); - } - - [Test] - public async Task Constructor_GivenKnownLangTypeSpecialType_PopulatesCorrectly() - { - // Arrange/ Act - TypeValueObject sut = new(SpecialType.System_String); - - // Assert - await Assert.That(sut.Keyword).IsEqualTo("string"); - await Assert.That(sut.SpecialType).IsEqualTo(SpecialType.System_String); - await Assert.That(sut.TypeName).IsEqualTo("String"); - await Assert.That(sut.Namespace).IsEqualTo("System"); - await Assert.That(sut.IsGlobalNamespace).IsFalse(); - await Assert.That(sut.RenderFullName).IsEqualTo("string"); - await Assert.That(sut.RenderTypeName).IsEqualTo("string"); - await Assert.That(sut.MetadataFullName).IsEqualTo("System.String"); - } - - [Test] - public async Task Constructor_GivenKnownLangTypeSystemType_PopulatesCorrectly() - { - // Arrange/ Act - TypeValueObject sut = new(typeof(string)); - - // Assert - await Assert.That(sut.Keyword).IsEqualTo("string"); - await Assert.That(sut.SpecialType).IsEqualTo(SpecialType.System_String); - await Assert.That(sut.TypeName).IsEqualTo("String"); - await Assert.That(sut.Namespace).IsEqualTo("System"); - await Assert.That(sut.IsGlobalNamespace).IsFalse(); - await Assert.That(sut.RenderFullName).IsEqualTo("string"); - await Assert.That(sut.RenderTypeName).IsEqualTo("string"); - await Assert.That(sut.MetadataFullName).IsEqualTo("System.String"); - } - - [Test] - public async Task Constructor_GivenKnownLangTypeITypeSymbol_PopulatesCorrectly() - { - // Arrange/ Act - var symbol = ITypeSymbol.Mock(); - symbol.SpecialType.Returns(SpecialType.System_String); - - TypeValueObject sut = new(symbol); - - // Assert - await Assert.That(sut.Keyword).IsEqualTo("string"); - await Assert.That(sut.SpecialType).IsEqualTo(SpecialType.System_String); - await Assert.That(sut.TypeName).IsEqualTo("String"); - await Assert.That(sut.Namespace).IsEqualTo("System"); - await Assert.That(sut.IsGlobalNamespace).IsFalse(); - await Assert.That(sut.RenderFullName).IsEqualTo("string"); - await Assert.That(sut.RenderTypeName).IsEqualTo("string"); - await Assert.That(sut.MetadataFullName).IsEqualTo("System.String"); - } -} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs similarity index 94% rename from src/tests/SourceGeneratorFramework.UnitTests/CodeWriterTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index 5bb207d..fb21a34 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -1,14 +1,12 @@ -using Purview.SourceGeneratorFramework.Models; - namespace Purview.SourceGeneratorFramework; public class CodeWriterTests { - static TypeReferenceOptions Type(string name) => new(new TypeValueObject(name, null)); + static TypeReference Type(string name) => new(new TypeIdentity(name, null)); static string GeneratedAttributes(bool includeCoverageExclusion = true) => - (includeCoverageExclusion ? "[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute]\n" : "") - + "[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]\n" + (includeCoverageExclusion ? "[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]\n" : "") + + "[global::System.Runtime.CompilerServices.CompilerGenerated]\n" + "[global::System.CodeDom.Compiler.GeneratedCode(\"TestGenerator\", \"1.0.0\")]\n"; static string IndentedGeneratedAttributes(bool includeCoverageExclusion = true) => @@ -30,9 +28,9 @@ public async Task MemberDeclarationOptions_AreValueTypes() [Arguments(null)] [Arguments("")] [Arguments(" ")] - public async Task TypeReferenceOptions_GivenMissingName_Throws(string? name) + public async Task TypeValueObject_GivenMissingName_Throws(string? name) { - await Assert.That(() => new TypeReferenceOptions(new TypeValueObject(name!, null))).Throws(); + await Assert.That(() => new TypeIdentity(name!, null)).Throws(); } [Test] @@ -42,9 +40,9 @@ public async Task EmptyTypeReference_IsIgnoredByMemberEmitters() var writer = CodeWriterFactory.ForTests(); // Act - writer.WriteField(new FieldDeclarationOptions("field", TypeReferenceOptions.Empty)); - writer.WriteProperty(new PropertyDeclarationOptions("Property", TypeReferenceOptions.Empty)); - writer.WriteMethodScope(new MethodDeclarationOptions("Method", TypeReferenceOptions.Empty)).Dispose(); + writer.WriteField(new FieldDeclarationOptions("field", TypeReference.Empty)); + writer.WriteProperty(new PropertyDeclarationOptions("Property", TypeReference.Empty)); + writer.WriteMethodScope(new MethodDeclarationOptions("Method", TypeReference.Empty)).Dispose(); // Assert await Assert.That(writer.ToString()).IsEmpty(); @@ -286,7 +284,7 @@ await Assert public async Task WriteBlockNamespace_TypeValueObject_WritesNamespaceBlock() { var writer = CodeWriterFactory.ForTests(); - var typeValue = new TypeValueObject("C", "Test"); + var typeValue = new TypeIdentity("C", "Test"); using (writer.WriteBlockNamespaceScope(typeValue)) { @@ -304,11 +302,10 @@ public async Task WriteBlockNamespace_TypeValueObject_WritesNamespaceBlock() public async Task WriteBlockNamespace_TypeValueObjectWithGlobalNamespace_ReturnsNoOpScope() { var writer = CodeWriterFactory.ForTests(); - var typeValue = new TypeValueObject("C", null); + var typeValue = new TypeIdentity("C", null); - using (var scope = writer.WriteBlockNamespaceScope(typeValue)) + using (writer.WriteBlockNamespaceScope(typeValue)) { - await Assert.That(scope).IsEqualTo(default); writer.WriteLine("public class C { }"); } @@ -322,7 +319,7 @@ public async Task WriteBlockNamespace_TypeValueObjectWithGlobalNamespace_Returns public async Task WriteFileScopedNamespace_TypeValueObject_WritesNamespace() { var writer = CodeWriterFactory.ForTests(); - var typeValue = new TypeValueObject("C", "Test"); + var typeValue = new TypeIdentity("C", "Test"); writer.WriteFileScopedNamespace(typeValue); @@ -335,7 +332,7 @@ public async Task WriteFileScopedNamespace_TypeValueObject_WritesNamespace() public async Task WriteFileScopedNamespace_TypeValueObjectWithGlobalNamespace_WritesNothing() { var writer = CodeWriterFactory.ForTests(); - var typeValue = new TypeValueObject("C", null); + var typeValue = new TypeIdentity("C", null); writer.WriteFileScopedNamespace(typeValue); @@ -377,8 +374,8 @@ public async Task WriteClass_WithOptions_WritesModifiersInheritanceAndConstraint var declaration = new TypeDeclarationOptions("Repository") { Accessibility = TypeDeclarationAccessibility.Public, - BaseType = Type("RepositoryBase").MakeGeneric(Type("T")), - Interfaces = [Type("IRepository").MakeGeneric(Type("T")), Type("IDisposable")], + BaseType = Type("RepositoryBase").Identity.MakeGeneric(Type("T")), + Interfaces = [Type("IRepository").Identity.MakeGeneric(Type("T")), Type("IDisposable")], GenericTypes = [new GenericTypeParameterOptions("T") { Constraints = ["class", "new()"] }], }; @@ -407,7 +404,7 @@ public async Task WriteRecordStruct_WithOptions_WritesReadonlyRecordStruct() { Accessibility = TypeDeclarationAccessibility.Internal, IsReadOnly = true, - Interfaces = [Type("IEquatable").MakeGeneric(Type("Identifier"))], + Interfaces = [Type("IEquatable").Identity.MakeGeneric(Type("Identifier"))], }; using (writer.WriteRecordStructScope(declaration)) @@ -558,7 +555,7 @@ await Assert public async Task WriteClass_WithEmptyBaseType_DoesNotWriteBaseListColon() { var writer = CodeWriterFactory.ForTests(); - var declaration = new TypeDeclarationOptions("ResourceKit") { BaseType = TypeReferenceOptions.Empty }; + var declaration = new TypeDeclarationOptions("ResourceKit") { BaseType = TypeReference.Empty }; writer.WriteClass(declaration, static _ => { }); @@ -573,12 +570,12 @@ public async Task WriteClass_WithEmptyBaseAndInterfaces_WritesOnlyNonEmptyInterf var writer = CodeWriterFactory.ForTests(); var declaration = new TypeDeclarationOptions("ResourceKit") { - BaseType = TypeReferenceOptions.Empty, + BaseType = TypeReference.Empty, Interfaces = [ - TypeReferenceOptions.Empty, - new TypeReferenceOptions(new TypeValueObject("IResourceKit", null)), - TypeReferenceOptions.Empty, + TypeReference.Empty, + new TypeReference(new TypeIdentity("IResourceKit", null)), + TypeReference.Empty, ], }; @@ -732,7 +729,7 @@ public async Task WriteAttributeClass_WithDefaults_WritesAttributeUsageAndSystem await Assert .That(writer.ToString()) .IsEqualTo( - "[global::Microsoft.CodeAnalysis.EmbeddedAttribute]\n" + "[global::Microsoft.CodeAnalysis.Embedded]\n" + GeneratedAttributes() + "[global::System.AttributeUsage(global::System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)]\n" + "public sealed class RegistryAttribute : global::System.Attribute\n" @@ -755,7 +752,7 @@ public async Task WriteAttributeClass_WithOptions_WritesCombinedTargetsFlagsAttr Accessibility = TypeDeclarationAccessibility.Internal, IsPartial = false, BaseType = Type("CustomAttributeBase"), - Attributes = [new(new TypeValueObject("Obsolete", null))], + Attributes = [new(new TypeIdentity("Obsolete", null))], }, AttributeTargets.Class | AttributeTargets.Property, _ => { }, @@ -767,7 +764,7 @@ public async Task WriteAttributeClass_WithOptions_WritesCombinedTargetsFlagsAttr await Assert .That(writer.ToString()) .IsEqualTo( - "[global::Microsoft.CodeAnalysis.EmbeddedAttribute]\n" + "[global::Microsoft.CodeAnalysis.Embedded]\n" + GeneratedAttributes() + "[global::System.AttributeUsage(global::System.AttributeTargets.Class | global::System.AttributeTargets.Property, Inherited = true, AllowMultiple = true)]\n" + "[Obsolete]\n" @@ -788,7 +785,7 @@ public async Task WriteAttributeClass_WithEmbeddedAttributeDisabled_OmitsEmbedde _ => { } ); - await Assert.That(writer.ToString()).DoesNotContain("[global::Microsoft.CodeAnalysis.EmbeddedAttribute]"); + await Assert.That(writer.ToString()).DoesNotContain("[global::Microsoft.CodeAnalysis.Embedded]"); } [Test] @@ -815,7 +812,7 @@ public async Task WriteEnum_WithStructuredFields_WritesSummariesAttributesAndVal new EnumFieldDeclarationOptions("None", 0) { XmlSummary = ["No status has been selected."], - Attributes = [new(new TypeValueObject("Obsolete", null))], + Attributes = [new(new TypeIdentity("Obsolete", null))], }, new EnumFieldDeclarationOptions("Ready", (object)"1 << 0"), new EnumFieldDeclarationOptions("Unknown") @@ -955,7 +952,7 @@ public async Task WriteMethod_GivenStructuredOptions_WritesModifiersGenericsAndB { // Arrange var writer = CodeWriterFactory.ForTests(); - var declaration = new MethodDeclarationOptions("CreateAsync", Type("Task").MakeGeneric(Type("T"))) + var declaration = new MethodDeclarationOptions("CreateAsync", Type("Task").Identity.MakeGeneric(Type("T"))) { Accessibility = TypeDeclarationAccessibility.Public, IsStatic = true, @@ -969,8 +966,8 @@ public async Task WriteMethod_GivenStructuredOptions_WritesModifiersGenericsAndB // Assert await Assert - .That(writer.ToString()) - .IsEqualTo( + .That(writer) + .Generates( GeneratedAttributes() + "public static async Task CreateAsync(T value, CancellationToken cancellationToken)\n" + "where T : class\n" @@ -985,7 +982,7 @@ public async Task WritePartialMethod_GivenPartialMethods_WritesDeclaration() { // Arrange var writer = CodeWriterFactory.ForTests(); - MethodDeclarationOptions declaration = new("CreateAsync", Type("Task").MakeGeneric(Type("T"))) + MethodDeclarationOptions declaration = new("CreateAsync", Type("Task").Identity.MakeGeneric(Type("T"))) { Accessibility = TypeDeclarationAccessibility.Public, IsAsync = true, @@ -1012,7 +1009,7 @@ public async Task StructuredDeclarations_GivenAttributes_WritesTypeMemberReturnA { // Arrange var writer = CodeWriterFactory.ForTests(); - var generatedCode = new AttributeDeclarationOptions(new TypeValueObject("GeneratedCode", null)) + var generatedCode = new AttributeDeclarationOptions(new TypeIdentity("GeneratedCode", null)) { Arguments = [ @@ -1029,14 +1026,14 @@ public async Task StructuredDeclarations_GivenAttributes_WritesTypeMemberReturnA var method = new MethodDeclarationOptions("TryGet", Type("bool")) { Accessibility = TypeDeclarationAccessibility.Public, - Attributes = [new(new TypeValueObject("Obsolete", null))], - ReturnAttributes = [new(new TypeValueObject("NotNull", null))], + Attributes = [new(new TypeIdentity("Obsolete", null))], + ReturnAttributes = [new(new TypeIdentity("NotNull", null))], Parameters = [ new("value", Type("string").Nullable()) { Modifier = ParameterModifier.Out, - Attributes = [new(new TypeValueObject("NotNullWhen", null)) { Arguments = [new(true)] }], + Attributes = [new(new TypeIdentity("NotNullWhen", null)) { Arguments = [new(true)] }], }, ], }; @@ -1067,17 +1064,13 @@ await Assert public async Task AttributeDeclaration_RendersRetainedTypeReferenceSyntax() { var writer = CodeWriterFactory.ForTests(); - var attributeType = new TypeValueObject("MarkerAttribute", "Example") - .AsTypeReference() - .MakeGeneric(TypeValueObject.Create()) - .Nullable(); + var attributeType = new TypeIdentity("MarkerAttribute", "Example") + .MakeGeneric(TypeIdentity.Create().MakeNullable()) + .AsTypeReference(); - writer.WriteClass( - new TypeDeclarationOptions("C") { Attributes = [new AttributeDeclarationOptions(attributeType)] }, - _ => { } - ); + writer.WriteClass(new("C") { Attributes = [new(attributeType)] }, _ => { }); - await Assert.That(writer.ToString()).Contains("[global::Example.Marker?]"); + await Assert.That(writer).ContainsGenerated("[global::Example.Marker]"); } [Test] @@ -1146,7 +1139,9 @@ public async Task WriteClass_GivenAttributeTypeValueObject_DoesNotDuplicateAttri { // Arrange var writer = CodeWriterFactory.ForTests(); - var attribute = new AttributeDeclarationOptions(new("HostKitAttribute", "Purview.Aspire.ResourceKit")) + var attribute = new AttributeDeclarationOptions( + new TypeIdentity("HostKitAttribute", "Purview.Aspire.ResourceKit") + ) { Arguments = [new AttributeArgumentOptions(true) { Name = "GenerateOptions", IsPropertyAssignment = true }], }; @@ -1170,7 +1165,7 @@ await Assert public async Task AttributeTypeValueObject_GivenDeclarationContexts_RendersUnderlyingType() { // Arrange - var attributeType = new TypeValueObject("RegistryAttribute", "Example"); + var attributeType = new TypeIdentity("RegistryAttribute", "Example"); var writer = CodeWriterFactory.ForTests(); // Act @@ -1200,16 +1195,19 @@ public async Task TypeReference_GivenNestedNullableGenericAndArray_RendersStruct // Arrange var writer = CodeWriterFactory.ForTests(); var valueType = Type("global::System.Collections.Generic.Dictionary") - .MakeGeneric(Type("string"), Type("Widget").Nullable()) + .Identity.MakeGeneric(Type("string"), Type("Widget").Nullable()) .MakeArray() .Nullable(); - var method = new MethodDeclarationOptions("Load", valueType) + MethodDeclarationOptions method = new("Load", valueType) { Parameters = [ new( "items", - Type("global::System.Collections.Generic.List").MakeGeneric(Type("Widget").Nullable()).Nullable() + Type("global::System.Collections.Generic.List") + .Identity.MakeGeneric(Type("Widget").Nullable()) + .AsTypeReference() + .Nullable() ), ], ExpressionBody = "items.ToArray()", @@ -1792,22 +1790,20 @@ public async Task WriteAutoGeneratedHeader_WritesHeader() [Test] public async Task GeneratorIdentity_GivenNoHeaderArguments_UsesDefaultsAndDecoratesDeclarations() { - var writer = new CodeWriter("HostKitGenerator", "2.3.4", throwOnUnclosedScopes: false); + var writer = new CodeWriter(new("HostKitGenerator", "2.3.4"), throwOnUnclosedScopes: false); writer.WriteAutoGeneratedHeader(); writer.WriteClass( new TypeDeclarationOptions("GeneratedType"), - body => body.WriteProperty(new PropertyDeclarationOptions("Value", TypeValueObject.Create())) + body => body.WriteProperty(new PropertyDeclarationOptions("Value", TypeIdentity.Create())) ); var result = writer.ToString(); await Assert.That(result).Contains("HostKitGenerator (version 2.3.4)"); await Assert.That(result).DoesNotContain("// Generated at "); - await Assert.That(result).DoesNotContain("[global::Microsoft.CodeAnalysis.EmbeddedAttribute]"); - await Assert - .That(result) - .Contains("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute]"); - await Assert.That(result).Contains("[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]"); + await Assert.That(result).DoesNotContain("[global::Microsoft.CodeAnalysis.Embedded]"); + await Assert.That(result).Contains("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + await Assert.That(result).Contains("[global::System.Runtime.CompilerServices.CompilerGenerated]"); await Assert .That(result) .Contains("[global::System.CodeDom.Compiler.GeneratedCode(\"HostKitGenerator\", \"2.3.4\")]"); @@ -1844,10 +1840,10 @@ await Assert [Test] public async Task GeneratorIdentity_GivenConstField_DoesNotWriteInvalidCoverageAttribute() { - var writer = new CodeWriter("HostKitGenerator", "2.3.4", throwOnUnclosedScopes: false); + var writer = new CodeWriter(new("HostKitGenerator", "2.3.4"), throwOnUnclosedScopes: false); writer.WriteField( - new FieldDeclarationOptions("SectionName", TypeValueObject.Create()) + new FieldDeclarationOptions("SectionName", TypeIdentity.Create()) { Accessibility = TypeDeclarationAccessibility.Public, IsConst = true, @@ -1856,8 +1852,8 @@ public async Task GeneratorIdentity_GivenConstField_DoesNotWriteInvalidCoverageA ); var result = writer.ToString(); - await Assert.That(result).DoesNotContain("ExcludeFromCodeCoverageAttribute"); - await Assert.That(result).Contains("CompilerGeneratedAttribute"); + await Assert.That(result).DoesNotContain("ExcludeFromCodeCoverage"); + await Assert.That(result).Contains("CompilerGenerated"); await Assert.That(result).Contains("GeneratedCode("); } @@ -2018,10 +2014,7 @@ public async Task ClassDeclaration_GivenDefaultCodeWriterAndNoOverride_EmitsGene var writer = CodeWriterFactory.ForTests(); // Act - writer.WriteClass( - new TypeDeclarationOptions("Sample") { Accessibility = TypeDeclarationAccessibility.Public }, - body => body.Comment("Empty") - ); + writer.WriteClass(new("Sample", TypeDeclarationAccessibility.Public), body => body.Comment("Empty")); // Assert var result = writer.ToString(); diff --git a/src/tests/SourceGeneratorFramework.UnitTests/DiagnosticInfoTests.cs b/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs similarity index 97% rename from src/tests/SourceGeneratorFramework.UnitTests/DiagnosticInfoTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs index 3ea612f..b96cd76 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/DiagnosticInfoTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs @@ -2,7 +2,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; -using Purview.SourceGeneratorFramework.Models; namespace Purview.SourceGeneratorFramework; diff --git a/src/tests/SourceGeneratorFramework.UnitTests/EquatableArrayTests.cs b/src/tests/SourceGeneratorShared.UnitTests/EquatableArrayTests.cs similarity index 98% rename from src/tests/SourceGeneratorFramework.UnitTests/EquatableArrayTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/EquatableArrayTests.cs index 0225d70..be45afd 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/EquatableArrayTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/EquatableArrayTests.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using Purview.SourceGeneratorFramework.Models; namespace Purview.SourceGeneratorFramework; diff --git a/src/tests/SourceGeneratorFramework.UnitTests/SourceProductionContextExtensionsTests.cs b/src/tests/SourceGeneratorShared.UnitTests/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensionsTests.cs similarity index 89% rename from src/tests/SourceGeneratorFramework.UnitTests/SourceProductionContextExtensionsTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensionsTests.cs index efd603e..23f087a 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/SourceProductionContextExtensionsTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/Extensions/Microsoft/CodeAnalysis/SourceProductionContextExtensionsTests.cs @@ -1,9 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Extensions; -using Purview.SourceGeneratorFramework.Models; -namespace Purview.SourceGeneratorFramework; +namespace Microsoft.CodeAnalysis; public class SourceProductionContextExtensionsTests { diff --git a/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs b/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs new file mode 100644 index 0000000..68f5746 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs @@ -0,0 +1,69 @@ +using Purview.SourceGeneratorFramework.Logging; + +namespace Purview.SourceGeneratorFramework; + +public class GenerationContextTests +{ + [Test] + public async Task CreateCodeWriter_GivenScopeValidationEnabled_ConfiguresEveryWriter() + { + // Arrange + var context = CreateGenerationContext( + new GenerationSettings("TestGenerator", "1.0.0") { ValidateCodeWriterScopes = true } + ); + + // Act + var writer = context.CreateCodeWriter(); + + // Assert + await Assert.That(writer.ThrowOnUnclosedScopes).IsTrue(); + await Assert.That(ReferenceEquals(writer, context.CreateCodeWriter())).IsFalse(); + } + + [Test] + public async Task CreateCodeWriter_GivenGeneratorIdentity_PropagatesIdentity() + { + var context = CreateGenerationContext(new GenerationSettings("HostKitGenerator", "2.3.4")); + + var writer = context.CreateCodeWriter(); + + await Assert.That(writer.GeneratorName).IsEqualTo("HostKitGenerator"); + await Assert.That(writer.GeneratorVersion).IsEqualTo("2.3.4"); + } + + [Test] + public async Task Constructor_GivenLogger_ExposesLogger() + { + // Arrange + var logger = new TestLogger(); + var context = CreateGenerationContext(new GenerationSettings("TestGenerator", "1.0.0"), logger); + + // Act / Assert + await Assert.That(context.Logger).IsSameReferenceAs(logger); + } + + [Test] + public async Task GenerationSettings_UsesValueEquality() + { + var settingsA = new GenerationSettings("A", "1.0.0"); + var settingsB = new GenerationSettings("A", "1.0.0"); + var settingsC = settingsA with { ValidateCodeWriterScopes = true }; + + await Assert.That(settingsA).IsEqualTo(settingsB); + await Assert.That(settingsA).IsNotEqualTo(settingsC); + } + + static GenerationContext CreateGenerationContext( + GenerationSettings? settings = null, + ISourceGenLogger? logger = null + ) + { + settings ??= new GenerationSettings("TestGenerator", "1.0.0"); + return new(EmptyCapabilities.Instance, settings, logger); + } + + sealed class TestLogger : ISourceGenLogger + { + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) { } + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/GeneratorResultTests.cs b/src/tests/SourceGeneratorShared.UnitTests/GeneratorResultTests.cs similarity index 63% rename from src/tests/SourceGeneratorFramework.UnitTests/GeneratorResultTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/GeneratorResultTests.cs index cff7d97..5b3ea43 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/GeneratorResultTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/GeneratorResultTests.cs @@ -1,5 +1,3 @@ -using Purview.SourceGeneratorFramework.Models; - namespace Purview.SourceGeneratorFramework; public class GeneratorResultTests @@ -7,11 +5,11 @@ public class GeneratorResultTests [Test] public async Task Ok_WithValue_IsSuccess() { - var result = GeneratorResult.Ok("value"); + var result = GeneratorResult.Create("value"); - await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.HasValue).IsTrue(); await Assert.That(result.IsEmpty).IsFalse(); - await Assert.That(result.IsFatal).IsFalse(); + await Assert.That(result.ShouldProcess).IsTrue(); await Assert.That(result.HasDiagnostics).IsFalse(); await Assert.That(result.Value).IsEqualTo("value"); } @@ -29,15 +27,15 @@ public async Task Ok_WithValueAndDiagnostics_HasDiagnostics() true ) ); - var result = GeneratorResult.Ok("value", diagnostic); + var result = GeneratorResult.Create("value", diagnostic); - await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.HasValue).IsTrue(); await Assert.That(result.HasDiagnostics).IsTrue(); await Assert.That(result.Value).IsEqualTo("value"); } [Test] - public async Task Fail_WithDiagnostics_IsFatal() + public async Task Fail_WithDiagnostics_ShouldProcessIsFalse() { var diagnostic = DiagnosticInfo.Create( new Microsoft.CodeAnalysis.DiagnosticDescriptor( @@ -45,14 +43,14 @@ public async Task Fail_WithDiagnostics_IsFatal() "Test", "Message", "Test", - Microsoft.CodeAnalysis.DiagnosticSeverity.Warning, + Microsoft.CodeAnalysis.DiagnosticSeverity.Error, true ) ); - var result = GeneratorResult.Fail(diagnostic); + var result = GeneratorResult.Create(diagnostic); - await Assert.That(result.IsSuccess).IsFalse(); - await Assert.That(result.IsFatal).IsTrue(); + await Assert.That(result.HasValue).IsFalse(); + await Assert.That(result.ShouldProcess).IsFalse(); await Assert.That(result.IsEmpty).IsFalse(); await Assert.That(result.HasDiagnostics).IsTrue(); await Assert.That(result.Value).IsNull(); @@ -61,7 +59,7 @@ public async Task Fail_WithDiagnostics_IsFatal() [Test] public void Fail_WithoutDiagnostics_Throws() { - Assert.Throws(() => GeneratorResult.Fail()); + Assert.Throws(() => GeneratorResult.Create()); } [Test] @@ -69,9 +67,9 @@ public async Task Empty_IsEmpty() { var result = GeneratorResult.Empty; - await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.HasValue).IsFalse(); await Assert.That(result.IsEmpty).IsTrue(); - await Assert.That(result.IsFatal).IsFalse(); + await Assert.That(result.ShouldProcess).IsFalse(); await Assert.That(result.HasDiagnostics).IsFalse(); await Assert.That(result.Value).IsNull(); } @@ -81,13 +79,13 @@ public async Task Empty_WithValueType_IsEmpty() { var result = GeneratorResult.Empty; - await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.HasValue).IsFalse(); await Assert.That(result.IsEmpty).IsTrue(); - await Assert.That(result.IsFatal).IsFalse(); + await Assert.That(result.ShouldProcess).IsFalse(); } [Test] - public async Task Fail_WithValueType_IsFatal() + public async Task Fail_WithValueType_ShouldProcessIsFalse() { var diagnostic = DiagnosticInfo.Create( new Microsoft.CodeAnalysis.DiagnosticDescriptor( @@ -95,15 +93,15 @@ public async Task Fail_WithValueType_IsFatal() "Test", "Message", "Test", - Microsoft.CodeAnalysis.DiagnosticSeverity.Warning, + Microsoft.CodeAnalysis.DiagnosticSeverity.Error, true ) ); - var result = GeneratorResult.Fail(diagnostic); + var result = GeneratorResult.Create(diagnostic); - await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.HasValue).IsFalse(); await Assert.That(result.IsEmpty).IsFalse(); - await Assert.That(result.IsFatal).IsTrue(); + await Assert.That(result.ShouldProcess).IsFalse(); } } diff --git a/src/tests/SourceGeneratorShared.UnitTests/GlobalUsings.cs b/src/tests/SourceGeneratorShared.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..2ec8b04 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Purview.SourceGeneratorFramework.Testing; diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_DiagnosticTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_DiagnosticTestGenerator.cs new file mode 100644 index 0000000..fb07a76 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_DiagnosticTestGenerator.cs @@ -0,0 +1,41 @@ +using Purview.SourceGeneratorFramework.TestGenerators; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Helpers; + +public class IncrementalPipelineTests_DiagnosticTestGenerator : TUnitSourceGeneratorTestBase +{ + const string TestAttributeSource = """ +[System.AttributeUsage(System.AttributeTargets.Class)] +public sealed class TestAttribute : System.Attribute { } +"""; + + [Test] + public async Task RegisterSourceOutput_ReportsDiagnosticsAndGeneratesForSuccessfulTargets( + CancellationToken cancellationToken + ) + { + const string source = """ +[TestAttribute] +public partial class MyClass; + +"""; + + var result = await GenerateAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic("TEST001"); + } + + protected override SourceGeneratorTestOptions OnBeforeRun( + IEnumerable sources, + SourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + return base.OnBeforeRun( + sources, + options.WithAdditionalSources(TestAttributeSource).WithExcludeGeneratedSourceHintNames("TestAttribute"), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_GenerationContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_GenerationContextTestGenerator.cs new file mode 100644 index 0000000..c208698 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_GenerationContextTestGenerator.cs @@ -0,0 +1,43 @@ +using Purview.SourceGeneratorFramework.TestGenerators; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Helpers; + +public class IncrementalPipelineTests_GenerationContextTestGenerator + : TUnitSourceGeneratorTestBase +{ + const string TestAttributeSource = """ +[System.AttributeUsage(System.AttributeTargets.Class)] +public sealed class TestAttribute : System.Attribute { } +"""; + + [Test] + public async Task GenerationContextValueProvider_WithoutScopeParameter_DoesNotRecurse( + CancellationToken cancellationToken + ) + { + const string source = """ +[TestAttribute] +public partial class MyClass; + +"""; + + var result = await GenerateAsync(source, cancellationToken); + var tree = result.GetSource(); + + await Assert.That(tree).IsNotNull(); + } + + protected override SourceGeneratorTestOptions OnBeforeRun( + IEnumerable sources, + SourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + return base.OnBeforeRun( + sources, + options.WithAdditionalSources(TestAttributeSource).WithExcludeGeneratedSourceHintNames("TestAttribute"), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_TestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_TestGenerator.cs new file mode 100644 index 0000000..0f944c3 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/IncrementalPipelineTests_TestGenerator.cs @@ -0,0 +1,58 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.TestGenerators; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Helpers; + +public class IncrementalPipelineTests_TestGenerator : TUnitSourceGeneratorTestBase +{ + const string TestAttributeSource = """ +[System.AttributeUsage(System.AttributeTargets.Class)] +public sealed class TestAttribute : System.Attribute { } +"""; + + [Test] + public async Task ForAttributeWithMetadataName_FindsAttributedClass(CancellationToken cancellationToken) + { + const string source = """ +[TestAttribute] +public partial class MyClass { } +"""; + + var result = await GenerateAsync(source, cancellationToken); + var tree = result.GetSource(); + + await Assert.That(tree).IsNotNull(); + await Assert.That(tree).ContainsGeneratedCode("class MyClass"); + } + + [Test] + public async Task IsDisabledValueProvider_WhenDisabled_DoesNotGenerate(CancellationToken cancellationToken) + { + const string source = """ +[TestAttribute] +public partial class MyClass { } +"""; + + var result = await GenerateAsync( + source, + new() { DisableSourceGeneratorPropertyName = "DisableTestGenerator", DisableSourceGeneratorValue = true }, + cancellationToken + ); + + await Assert.That(result.AllSyntaxTrees.Any()).IsFalse(); + } + + protected override SourceGeneratorTestOptions OnBeforeRun( + IEnumerable sources, + SourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + return base.OnBeforeRun( + sources, + options.WithAdditionalSources(TestAttributeSource).WithExcludeGeneratedSourceHintNames("TestAttribute"), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/KnownLangTypesTests.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/KnownLangTypesTests.cs similarity index 85% rename from src/tests/SourceGeneratorFramework.UnitTests/KnownLangTypesTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/Helpers/KnownLangTypesTests.cs index b9af049..df06c54 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/KnownLangTypesTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/KnownLangTypesTests.cs @@ -1,7 +1,6 @@ using Microsoft.CodeAnalysis; -using Purview.SourceGeneratorFramework.Helpers; -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Helpers; public class KnownLangTypesTests { diff --git a/src/tests/SourceGeneratorFramework.UnitTests/TypeHelpersTests.cs b/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs similarity index 58% rename from src/tests/SourceGeneratorFramework.UnitTests/TypeHelpersTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs index 26f13be..13fb229 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/TypeHelpersTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/Helpers/TypeHelpersTests.cs @@ -1,53 +1,82 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Purview.SourceGeneratorFramework.Helpers; -using Purview.SourceGeneratorFramework.Models; -namespace Purview.SourceGeneratorFramework; +namespace Purview.SourceGeneratorFramework.Helpers; public class TypeHelpersTests { - static async Task GetTypeSymbolAsync(string source, string typeName) + static async Task GetTypeSymbolAsync( + string source, + string typeName, + CancellationToken cancellationToken + ) { - var syntaxTree = CSharpSyntaxTree.ParseText(source); + var syntaxTree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); var compilation = CSharpCompilation.Create( "TestAssembly", [syntaxTree], [ MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(IEnumerable<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location), ], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); var model = compilation.GetSemanticModel(syntaxTree); - var root = await syntaxTree.GetRootAsync(); + var root = await syntaxTree.GetRootAsync(cancellationToken); var typeDeclaration = root.DescendantNodes() .OfType() .First(t => t.Identifier.ValueText == typeName); - return model.GetDeclaredSymbol(typeDeclaration)!; + return model.GetDeclaredSymbol(typeDeclaration, cancellationToken)!; + } + + static INamedTypeSymbol GetTypeReferenceSymbol(string typeName, CancellationToken cancellationToken) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + $"internal sealed class TypeReferenceHolder {{ public {typeName} Value {{ get; }} = default!; }}", + cancellationToken: cancellationToken + ); + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Dictionary<,>).Assembly.Location), + ], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + var model = compilation.GetSemanticModel(syntaxTree); + var root = syntaxTree.GetRoot(cancellationToken); + var property = root.DescendantNodes().OfType().Single(); + + return (INamedTypeSymbol)model.GetTypeInfo(property.Type, cancellationToken).Type!; } - static async Task GetTypeDescriptorAsync(string source, string typeName) + static (ITypeSymbol Symbol, TypeDeclarationSyntax Syntax) GetTypeDescriptor( + string source, + string typeName, + CancellationToken cancellationToken + ) { - var syntaxTree = CSharpSyntaxTree.ParseText(source); + var syntaxTree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); var compilation = CSharpCompilation.Create( "TestAssembly", [syntaxTree], [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) ); - var root = await syntaxTree.GetRootAsync(); + var root = syntaxTree.GetRoot(cancellationToken); var declaration = root.DescendantNodes() .OfType() .Single(type => type.Identifier.ValueText == typeName); - var symbol = compilation.GetSemanticModel(syntaxTree).GetDeclaredSymbol(declaration)!; + var symbol = compilation.GetSemanticModel(syntaxTree).GetDeclaredSymbol(declaration, cancellationToken)!; return new(symbol, declaration); } static string GeneratedAttributes(bool includeCoverageExclusion = true) => - (includeCoverageExclusion ? "[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute]\n" : "") - + "[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute]\n" + (includeCoverageExclusion ? "[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]\n" : "") + + "[global::System.Runtime.CompilerServices.CompilerGenerated]\n" + "[global::System.CodeDom.Compiler.GeneratedCode(\"TestGenerator\", \"1.0.0\")]\n"; [Test] @@ -55,43 +84,55 @@ public async Task IsAttribute_TypeNameEndsWithAttribute_ReturnsTrue() => await Assert.That(TypeHelpers.IsAttribute("MyAttribute")).IsTrue(); [Test] - public async Task IsDerivedFromExpectedBase_GenericArgumentImplementsExpectedContract_ReturnsTrue() + public async Task IsDerivedFromExpectedBase_GenericArgumentImplementsExpectedContract_ReturnsTrue( + CancellationToken cancellationToken + ) { // Arrange const string source = "namespace Testing { interface IResource { } class DefaultAspireResource : IResource { } class ResourceKitBase where T : IResource { } class HostKit : ResourceKitBase { } }"; - var descriptor = await GetTypeDescriptorAsync(source, "HostKit"); - var expectedBase = new TypeValueObject("ResourceKitBase", "Testing").MakeGeneric( - new TypeValueObject("IResource", "Testing") + var (Symbol, Syntax) = GetTypeDescriptor(source, "HostKit", cancellationToken); + var expectedBase = new TypeIdentity("ResourceKitBase", "Testing").MakeGeneric( + new TypeIdentity("IResource", "Testing") ); // Act - var result = TypeHelpers.IsDerivedFromExpectedBase(descriptor, expectedBase); + var symbolResult = TypeHelpers.IsDerivedFromExpectedBase(Symbol, expectedBase); + var syntaxResult = TypeHelpers.IsDerivedFromExpectedBase(Syntax, expectedBase); // Assert - await Assert.That(result).IsTrue(); + await Assert.That(symbolResult).IsTrue(); + await Assert.That(syntaxResult).IsTrue(); } [Test] - public async Task IsDerivedFromExpectedBase_NameOnlyGenericBase_ReturnsTrueForConstructedBase() + public async Task IsDerivedFromExpectedBase_NameOnlyGenericBase_ReturnsTrueForConstructedBase( + CancellationToken cancellationToken + ) { // Arrange const string source = "namespace Testing { interface IResource { } class DefaultAspireResource : IResource { } class ResourceKitBase where T : IResource { } class HostKit : ResourceKitBase { } }"; - var descriptor = await GetTypeDescriptorAsync(source, "HostKit"); - var expectedBase = new TypeValueObject("ResourceKitBase", "Testing"); + var (Symbol, Syntax) = GetTypeDescriptor(source, "HostKit", cancellationToken); + var expectedBase = new TypeIdentity("ResourceKitBase", "Testing"); // Act - var result = TypeHelpers.IsDerivedFromExpectedBase(descriptor, expectedBase); + var symbolResult = TypeHelpers.IsDerivedFromExpectedBase(Symbol, expectedBase); + var syntaxResult = TypeHelpers.IsDerivedFromExpectedBase(Syntax, expectedBase); // Assert - await Assert.That(result).IsTrue(); + await Assert.That(symbolResult).IsTrue(); + await Assert.That(syntaxResult).IsTrue(); } [Test] public async Task IsAttribute_TypeNameWithoutSuffix_ReturnsFalse() => await Assert.That(TypeHelpers.IsAttribute("MyClass")).IsFalse(); + [Test] + public async Task IsAttribute_ConstructedGenericAttribute_ReturnsTrue() => + await Assert.That(TypeHelpers.IsAttribute("global::Example.MarkerAttribute")).IsTrue(); + [Test] public async Task GetTypeName_AttributeType_TrimsSuffix() => await Assert.That(TypeHelpers.GetTypeName("MyAttribute")).IsEqualTo("My"); @@ -100,6 +141,23 @@ public async Task GetTypeName_AttributeType_TrimsSuffix() => public async Task GetTypeName_NonAttributeType_ReturnsOriginal() => await Assert.That(TypeHelpers.GetTypeName("MyClass")).IsEqualTo("MyClass"); + [Test] + public async Task GetTypeName_ConstructedGenericAttribute_PreservesTypeArgumentsAndTrimsSuffix() => + await Assert + .That(TypeHelpers.GetTypeName("global::Example.MarkerAttribute")) + .IsEqualTo("global::Example.Marker"); + + [Test] + public async Task TypeValueObject_RenderAttributeName_ConstructedGenericAttribute_PreservesTypeArguments() + { + var attribute = new TypeIdentity("MarkerAttribute", "Example").MakeGeneric( + new TypeIdentity(SpecialType.System_String) + ); + + await Assert.That(attribute.IsAttribute).IsTrue(); + await Assert.That(attribute.RenderAttributeName).IsEqualTo("global::Example.Marker"); + } + [Test] public async Task IsValidIdentifier_ValidIdentifier_ReturnsTrue() { @@ -117,27 +175,33 @@ public async Task IsValidIdentifier_InvalidIdentifier_ReturnsFalse(string? name) } [Test] - public async Task IsPartial_PartialClass_ReturnsTrue() + public async Task IsPartial_PartialClass_ReturnsTrue(CancellationToken cancellationToken) { var source = "public partial class MyClass { }"; - var tree = CSharpSyntaxTree.ParseText(source); - var declaration = (await tree.GetRootAsync()).DescendantNodes().OfType().First(); + var tree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); + var declaration = (await tree.GetRootAsync(cancellationToken)) + .DescendantNodes() + .OfType() + .First(); await Assert.That(TypeHelpers.IsPartial(declaration)).IsTrue(); } [Test] - public async Task IsPartial_NonPartialClass_ReturnsFalse() + public async Task IsPartial_NonPartialClass_ReturnsFalse(CancellationToken cancellationToken) { var source = "public class MyClass { }"; - var tree = CSharpSyntaxTree.ParseText(source); - var declaration = (await tree.GetRootAsync()).DescendantNodes().OfType().First(); + var tree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); + var declaration = (await tree.GetRootAsync(cancellationToken)) + .DescendantNodes() + .OfType() + .First(); await Assert.That(TypeHelpers.IsPartial(declaration)).IsFalse(); } [Test] - public async Task HasNonEmptyConstructors_WithParameterConstructor_ReturnsTrue() + public async Task HasNonEmptyConstructors_WithParameterConstructor_ReturnsTrue(CancellationToken cancellationToken) { var source = """ public class MyClass @@ -145,14 +209,17 @@ public class MyClass public MyClass(int value) { } } """; - var tree = CSharpSyntaxTree.ParseText(source); - var declaration = (await tree.GetRootAsync()).DescendantNodes().OfType().First(); + var tree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); + var declaration = (await tree.GetRootAsync(cancellationToken)) + .DescendantNodes() + .OfType() + .First(); await Assert.That(TypeHelpers.HasNonEmptyConstructors(declaration, "MyClass")).IsTrue(); } [Test] - public async Task HasNonEmptyConstructors_WithEmptyConstructor_ReturnsFalse() + public async Task HasNonEmptyConstructors_WithEmptyConstructor_ReturnsFalse(CancellationToken cancellationToken) { var source = """ public class MyClass @@ -160,14 +227,17 @@ public class MyClass public MyClass() { } } """; - var tree = CSharpSyntaxTree.ParseText(source); - var declaration = (await tree.GetRootAsync()).DescendantNodes().OfType().First(); + var tree = CSharpSyntaxTree.ParseText(source, cancellationToken: cancellationToken); + var declaration = (await tree.GetRootAsync(cancellationToken)) + .DescendantNodes() + .OfType() + .First(); await Assert.That(TypeHelpers.HasNonEmptyConstructors(declaration, "MyClass")).IsFalse(); } [Test] - public async Task HasAttribute_AttributedClass_ReturnsTrue() + public async Task HasAttribute_AttributedClass_ReturnsTrue(CancellationToken cancellationToken) { var source = """ using System; @@ -175,46 +245,46 @@ public async Task HasAttribute_AttributedClass_ReturnsTrue() [Serializable] public class MyClass { } """; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); await Assert.That(TypeHelpers.HasAttribute(symbol, "System.SerializableAttribute")).IsTrue(); } [Test] - public async Task HasAttribute_MissingAttribute_ReturnsFalse() + public async Task HasAttribute_MissingAttribute_ReturnsFalse(CancellationToken cancellationToken) { var source = "public class MyClass { }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); await Assert.That(TypeHelpers.HasAttribute(symbol, "System.SerializableAttribute")).IsFalse(); } [Test] - public async Task InheritsFrom_DerivedClass_ReturnsTrue() + public async Task InheritsFrom_DerivedClass_ReturnsTrue(CancellationToken cancellationToken) { var source = """ public class Base { } public class Derived : Base { } """; - var symbol = await GetTypeSymbolAsync(source, "Derived"); + var symbol = await GetTypeSymbolAsync(source, "Derived", cancellationToken); await Assert.That(TypeHelpers.InheritsFrom(symbol, "Base")).IsTrue(); } [Test] - public async Task InheritsFrom_UnrelatedClass_ReturnsFalse() + public async Task InheritsFrom_UnrelatedClass_ReturnsFalse(CancellationToken cancellationToken) { var source = """ public class Base { } public class Other { } """; - var symbol = await GetTypeSymbolAsync(source, "Other"); + var symbol = await GetTypeSymbolAsync(source, "Other", cancellationToken); await Assert.That(TypeHelpers.InheritsFrom(symbol, "Base")).IsFalse(); } [Test] - public async Task Implements_IEnumerableT_ReturnsTrue() + public async Task Implements_IEnumerableT_ReturnsTrue(CancellationToken cancellationToken) { var source = """ using System.Collections.Generic; @@ -224,45 +294,45 @@ public class MyCollection : IEnumerable System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => null; } """; - var symbol = await GetTypeSymbolAsync(source, "MyCollection"); + var symbol = await GetTypeSymbolAsync(source, "MyCollection", cancellationToken); await Assert.That(TypeHelpers.Implements(symbol, "System.Collections.Generic.IEnumerable`1")).IsTrue(); } [Test] - public async Task ToFullyQualifiedDisplayString_ReturnsGlobalQualifiedName() + public async Task ToFullyQualifiedDisplayString_ReturnsGlobalQualifiedName(CancellationToken cancellationToken) { var source = "namespace Test { public class MyClass { } }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); await Assert.That(TypeHelpers.ToFullyQualifiedDisplayString(symbol)).IsEqualTo("global::Test.MyClass"); } [Test] - public async Task IsCollectionLike_List_ReturnsTrue() + public async Task IsCollectionLike_List_ReturnsTrue(CancellationToken cancellationToken) { var source = "using System.Collections.Generic; public class MyClass { public List Items; }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); var fieldSymbol = symbol.GetMembers("Items").OfType().First(); await Assert.That(TypeHelpers.IsCollectionLike(fieldSymbol.Type)).IsTrue(); } [Test] - public async Task IsArray_Array_ReturnsTrue() + public async Task IsArray_Array_ReturnsTrue(CancellationToken cancellationToken) { var source = "using System; public class MyClass { public int[] Items; }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); var fieldSymbol = symbol.GetMembers("Items").OfType().First(); await Assert.That(TypeHelpers.IsArray(fieldSymbol.Type)).IsTrue(); } [Test] - public async Task TryGetElementType_List_ReturnsIntElement() + public async Task TryGetElementType_List_ReturnsIntElement(CancellationToken cancellationToken) { var source = "using System.Collections.Generic; public class MyClass { public List Items; }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); var fieldSymbol = symbol.GetMembers("Items").OfType().First(); var result = TypeHelpers.TryGetElementType(fieldSymbol.Type, out var elementType); @@ -327,7 +397,9 @@ await Assert } [Test] - public async Task CreatePartialTypeDeclarationOptions_GivenStaticGenericClass_RecreatesContainer() + public async Task CreatePartialTypeDeclarationOptions_GivenStaticGenericClass_RecreatesContainer( + CancellationToken cancellationToken + ) { // Arrange const string source = """ @@ -336,7 +408,7 @@ public static partial class Container { } """; - var symbol = await GetTypeSymbolAsync(source, "Container"); + var symbol = await GetTypeSymbolAsync(source, "Container", cancellationToken); // Act var declaration = TypeHelpers.CreatePartialTypeDeclarationOptions(symbol); @@ -362,7 +434,9 @@ await Assert } [Test] - public async Task CreatePartialTypeDeclarationOptions_GivenReadonlyRecordStruct_RecreatesContainer() + public async Task CreatePartialTypeDeclarationOptions_GivenReadonlyRecordStruct_RecreatesContainer( + CancellationToken cancellationToken + ) { // Arrange const string source = """ @@ -371,7 +445,7 @@ internal readonly partial record struct Container { } """; - var symbol = await GetTypeSymbolAsync(source, "Container"); + var symbol = await GetTypeSymbolAsync(source, "Container", cancellationToken); // Act var declaration = TypeHelpers.CreatePartialTypeDeclarationOptions(symbol); @@ -384,7 +458,9 @@ internal readonly partial record struct Container } [Test] - public async Task CreatePartialTypeDeclarationOptions_GivenBasicMode_OmitsOptionalParts() + public async Task CreatePartialTypeDeclarationOptions_GivenBasicMode_OmitsOptionalParts( + CancellationToken cancellationToken + ) { // Arrange const string source = """ @@ -393,7 +469,7 @@ public sealed partial class Container { } """; - var symbol = await GetTypeSymbolAsync(source, "Container"); + var symbol = await GetTypeSymbolAsync(source, "Container", cancellationToken); // Act var declaration = TypeHelpers.CreatePartialTypeDeclarationOptions(symbol, includeOptionalParts: false); @@ -411,16 +487,16 @@ public sealed partial class Container } [Test] - public async Task IsAccessibleAsPublicOrInternal_PublicType_ReturnsTrue() + public async Task IsAccessibleAsPublicOrInternal_PublicType_ReturnsTrue(CancellationToken cancellationToken) { var source = "public class MyClass { }"; - var symbol = await GetTypeSymbolAsync(source, "MyClass"); + var symbol = await GetTypeSymbolAsync(source, "MyClass", cancellationToken); await Assert.That(TypeHelpers.IsAccessibleAsPublicOrInternal(symbol)).IsTrue(); } [Test] - public async Task IsAccessibleAsPublicOrInternal_PrivateNestedType_ReturnsFalse() + public async Task IsAccessibleAsPublicOrInternal_PrivateNestedType_ReturnsFalse(CancellationToken cancellationToken) { var source = """ public class Outer @@ -428,9 +504,25 @@ public class Outer private class Inner { } } """; - var symbol = await GetTypeSymbolAsync(source, "Outer"); + var symbol = await GetTypeSymbolAsync(source, "Outer", cancellationToken); var innerSymbol = symbol.GetTypeMembers("Inner").First(); await Assert.That(TypeHelpers.IsAccessibleAsPublicOrInternal(innerSymbol)).IsFalse(); } + + [Test] + [Arguments("System.Collections.Generic.Dictionary")] + [Arguments("System.Collections.Generic.IDictionary")] + [Arguments("System.Collections.Generic.IReadOnlyDictionary")] + public async Task Is_GivenTypeIsAMatch_ReturnsTrue(string typeName, CancellationToken cancellationToken) + { + var dictionaryKV = new TypeIdentity(typeof(Dictionary<,>)); + var iDictionaryKV = new TypeIdentity(typeof(IDictionary<,>)); + var iReadOnlyDictionaryKV = new TypeIdentity(typeof(IReadOnlyDictionary<,>)); + + var symbol = GetTypeReferenceSymbol(typeName, cancellationToken); + + await Assert.That(symbol).IsNotNull(); + await Assert.That(TypeHelpers.Is(symbol, dictionaryKV, iDictionaryKV, iReadOnlyDictionaryKV)).IsTrue(); + } } diff --git a/src/tests/SourceGeneratorShared.UnitTests/SourceGeneratorShared.UnitTests.csproj b/src/tests/SourceGeneratorShared.UnitTests/SourceGeneratorShared.UnitTests.csproj new file mode 100644 index 0000000..03b4e8c --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/SourceGeneratorShared.UnitTests.csproj @@ -0,0 +1,14 @@ + + + $(NamespacePrefix) + + + + + + + + + + + diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/DiagnosticTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/DiagnosticTestGenerator.cs new file mode 100644 index 0000000..8b4e22f --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/DiagnosticTestGenerator.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +public sealed class DiagnosticTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + new TypeIdentity("TestAttribute", null), + static (ctx, ct) => + { + var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.TargetNode, ct); + var name = symbol?.Name ?? "Unknown"; + var diagnostic = DiagnosticInfo.Create( + new DiagnosticDescriptor( + "TEST001", + "Test diagnostic", + $"Processed {name}", + "Test", + DiagnosticSeverity.Info, + isEnabledByDefault: true + ), + Location.None + ); + return GeneratorResult.Create(new TargetInfo(name), diagnostic); + } + ); + + var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + new GenerationSettings("DiagnosticTestGenerator", "1.0.0") + ); + + context.RegisterSourceOutput( + targets, + generationContext, + static (spc, target, ctx) => + { + var writer = ctx.CreateCodeWriter(); + writer.WriteLine($"partial class {target.Name} {{ }}"); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/GenerationContextTestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/GenerationContextTestGenerator.cs new file mode 100644 index 0000000..10b0d49 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/GenerationContextTestGenerator.cs @@ -0,0 +1,20 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +public sealed class GenerationContextTestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var generationContext = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + new GenerationSettings("GenerationContextTestGenerator", "1.0.0") + ); + + context.RegisterSourceOutput( + generationContext, + static (spc, value) => spc.AddSource("Context.g.cs", $"// GenerationContextTestGenerator") + ); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs new file mode 100644 index 0000000..7676530 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +readonly record struct TargetInfo(string Name); + +sealed record GenerationInputs(bool IsDisabled, string AssemblyName) +{ + public ImmutableArray Targets { get; init; } = []; +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs new file mode 100644 index 0000000..e0953a9 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Purview.SourceGeneratorFramework.TestGenerators; + +public sealed class TestGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var isDisabled = IncrementalPipeline.IsDisabledValueProvider(context, "DisableTestGenerator"); + var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + new TypeIdentity("TestAttribute", null), + static (ctx, ct) => + { + var symbol = ctx.SemanticModel.GetDeclaredSymbol(ctx.TargetNode, ct); + return new TargetInfo(symbol?.Name ?? "Unknown"); + }, + predicate: static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax + ); + + var inputs = isDisabled + .CombineWith( + context.CompilationProvider.Select(static (compilation, _) => compilation.AssemblyName ?? "Unknown"), + static (disabled, assemblyName, _) => new GenerationInputs(disabled, assemblyName), + "CreateGenerationInputs" + ) + .CollectWith( + targets, + static (state, collectedTargets, _) => state with { Targets = collectedTargets }, + "AddGenerationTargets" + ); + + context.RegisterSourceOutput( + inputs, + static (spc, source) => + { + if (source.IsDisabled) + return; + + foreach (var target in source.Targets) + { + spc.AddSource($"{target.Name}.g.cs", $"partial class {target.Name} {{ }}"); + } + } + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/TypeDeclarationAccessibilityExtensionsTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeDeclarationAccessibilityExtensionsTests.cs similarity index 100% rename from src/tests/SourceGeneratorFramework.UnitTests/TypeDeclarationAccessibilityExtensionsTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/TypeDeclarationAccessibilityExtensionsTests.cs diff --git a/src/tests/SourceGeneratorShared.UnitTests/TypeIdentityTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeIdentityTests.cs new file mode 100644 index 0000000..15758f3 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TypeIdentityTests.cs @@ -0,0 +1,452 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +public sealed class TypeIdentityTests +{ + // --------------------------------------------------------------------------------------------- + // Keyword / special types + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("System.Int32")] + [Arguments("System.String")] + [Arguments("System.Boolean")] + [Arguments("System.Object")] + public async Task Matches_GivenKeywordType_ReturnsTrue(string metadataName) + { + var compilation = TestCompilation.Create(); + var symbol = compilation.GetTypeByMetadataName(metadataName)!; + var value = new TypeIdentity(symbol); + + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value.SpecialType).IsNotEqualTo(SpecialType.None); + } + + [Test] + public async Task Matches_GivenKeywordTypeDeclaredByName_ReturnsTrue() + { + var compilation = TestCompilation.Create(); + var symbol = compilation.GetTypeByMetadataName("System.Int32")!; + + // Constructed without keyword knowledge, so SpecialType is None on this side. + var value = new TypeIdentity("Int32", "System"); + + await Assert.That(value.SpecialType).IsEqualTo(SpecialType.None); + await Assert.That(value.Matches(symbol)).IsTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Regression: non-keyword special types must not be rejected + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("System.DateTime")] + [Arguments("System.IDisposable")] + [Arguments("System.Array")] + [Arguments("System.Enum")] + [Arguments("System.ValueType")] + [Arguments("System.Delegate")] + [Arguments("System.Collections.IEnumerable")] + public async Task Matches_GivenNonKeywordSpecialType_ReturnsTrue(string metadataName) + { + var compilation = TestCompilation.Create(); + var symbol = compilation.GetTypeByMetadataName(metadataName)!; + + // Guards the premise: Roslyn stamps SpecialType well beyond the C# keyword types. + await Assert.That(symbol.SpecialType).IsNotEqualTo(SpecialType.None); + + var fromSymbol = new TypeIdentity(symbol); + var fromName = new TypeIdentity(symbol.Name, symbol.ContainingNamespace.ToDisplayString()); + + await Assert.That(fromSymbol.Matches(symbol)).IsTrue(); + await Assert.That(fromName.Matches(symbol)).IsTrue(); + } + + [Test] + public async Task Matches_GivenOpenGenericSpecialTypeDefinition_ReturnsTrue() + { + var compilation = TestCompilation.Create(); + var definition = compilation.GetTypeByMetadataName("System.Collections.Generic.IEnumerable`1")!; + var constructed = definition.Construct(compilation.GetSpecialType(SpecialType.System_Int32)); + + var value = new TypeIdentity("IEnumerable", "System.Collections.Generic") with { GenericArity = 1 }; + + await Assert.That(definition.SpecialType).IsNotEqualTo(SpecialType.None); + await Assert.That(value.Matches(definition)).IsTrue(); + await Assert.That(value.Matches(constructed)).IsTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Regression: composed symbols must not throw + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Matches_GivenArrayPointerOrDynamicSymbol_ReturnsFalseWithoutThrowing() + { + var compilation = TestCompilation.Create(); + var int32 = compilation.GetSpecialType(SpecialType.System_Int32); + var value = TypeIdentity.Create(); + + await Assert.That(value.Matches(compilation.CreateArrayTypeSymbol(int32))).IsFalse(); + await Assert.That(value.Matches(compilation.CreatePointerTypeSymbol(int32))).IsFalse(); + await Assert.That(value.Matches(compilation.DynamicType)).IsFalse(); + } + + [Test] + public async Task Matches_GivenErrorType_ReturnsFalse() + { + var symbol = TestCompilation.FieldType("public Missing.Thing Value;"); + + await Assert.That(symbol.TypeKind).IsEqualTo(TypeKind.Error); + await Assert.That(new TypeIdentity("Thing", "Missing").Matches(symbol)).IsFalse(); + await Assert.That(TypeIdentity.TryCreate(symbol, out _)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Nested types + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Matches_GivenNestedType_DoesNotMatchTopLevelTypeOfSameName() + { + var compilation = TestCompilation.Create( + """ + namespace Sample + { + public class Outer { public class Inner { } } + public class Inner { } + } + """ + ); + + var nested = compilation.GetTypeByMetadataName("Sample.Outer+Inner")!; + var topLevel = compilation.GetTypeByMetadataName("Sample.Inner")!; + + var nestedValue = new TypeIdentity(nested); + var topLevelValue = new TypeIdentity(topLevel); + + await Assert.That(nestedValue.Matches(nested)).IsTrue(); + await Assert.That(nestedValue.Matches(topLevel)).IsFalse(); + await Assert.That(topLevelValue.Matches(nested)).IsFalse(); + await Assert.That(topLevelValue.Matches(topLevel)).IsTrue(); + } + + [Test] + public async Task MetadataFullName_GivenNestedType_UsesPlusSeparator() + { + var value = new TypeIdentity("Outer", "Sample").Nested("Inner"); + + await Assert.That(value.IsNested).IsTrue(); + await Assert.That(value.MetadataFullName).IsEqualTo("Sample.Outer+Inner"); + await Assert.That(value.RenderFullName).IsEqualTo("global::Sample.Outer.Inner"); + } + + [Test] + public async Task Nested_RoundTripsThroughSymbol() + { + var compilation = TestCompilation.Create( + "namespace Sample { public class Outer { public class Middle { public class Inner { } } } }" + ); + + var symbol = compilation.GetTypeByMetadataName("Sample.Outer+Middle+Inner")!; + var value = new TypeIdentity("Outer", "Sample").Nested("Middle").Nested("Inner"); + + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value).IsEqualTo(new TypeIdentity(symbol)); + } + + [Test] + public async Task Matches_GivenNestedTypeInsideGenericContainer_ComparesContainerArity() + { + var compilation = TestCompilation.Create( + "namespace Sample { public class Outer { public class Inner { } } }" + ); + + var symbol = compilation.GetTypeByMetadataName("Sample.Outer`1+Inner")!; + + var correct = (new TypeIdentity("Outer", "Sample") with { GenericArity = 1 }).Nested("Inner"); + var wrongArity = new TypeIdentity("Outer", "Sample").Nested("Inner"); + + await Assert.That(correct.Matches(symbol)).IsTrue(); + await Assert.That(wrongArity.Matches(symbol)).IsFalse(); + await Assert.That(correct.MetadataFullName).IsEqualTo("Sample.Outer`1+Inner"); + } + + // --------------------------------------------------------------------------------------------- + // Generic shape + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Matches_GivenOpenDefinition_MatchesEveryConstruction() + { + var compilation = TestCompilation.Create(); + var list = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1")!; + var value = new TypeIdentity("List", "System.Collections.Generic") with { GenericArity = 1 }; + + await Assert.That(value.IsGenericTypeDefinition).IsTrue(); + await Assert.That(value.Matches(list)).IsTrue(); + await Assert + .That(value.Matches(list.Construct(compilation.GetSpecialType(SpecialType.System_String)))) + .IsTrue(); + } + + [Test] + public async Task Matches_GivenConstructedGeneric_RequiresMatchingArguments() + { + var compilation = TestCompilation.Create(); + var list = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1")!; + + var value = new TypeIdentity("List", "System.Collections.Generic").MakeGeneric( + new TypeIdentity(SpecialType.System_Int32) + ); + + await Assert.That(value.Matches(list.Construct(compilation.GetSpecialType(SpecialType.System_Int32)))).IsTrue(); + await Assert + .That(value.Matches(list.Construct(compilation.GetSpecialType(SpecialType.System_String)))) + .IsFalse(); + await Assert.That(value.Matches(list)).IsFalse(); + } + + [Test] + public async Task Matches_GivenArityMismatch_ReturnsFalse() + { + var compilation = TestCompilation.Create(); + var dictionary = compilation.GetTypeByMetadataName("System.Collections.Generic.Dictionary`2")!; + var value = new TypeIdentity("Dictionary", "System.Collections.Generic") with { GenericArity = 1 }; + + await Assert.That(value.Matches(dictionary)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Composed type arguments no longer widen + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task TypeArguments_GivenArrayArgument_ArePreserved() + { + var symbol = TestCompilation.FieldType("public List Value = null!;"); + var value = new TypeIdentity(symbol); + + await Assert.That(value.IsGenericTypeDefinition).IsFalse(); + await Assert.That(value.TypeArguments.Length).IsEqualTo(1); + await Assert.That(value.TypeArguments[0].IsArray).IsTrue(); + await Assert.That(value.RenderFullName).IsEqualTo("global::System.Collections.Generic.List"); + + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + } + + [Test] + public async Task TypeArguments_GivenTypeParameterArgument_ArePreserved() + { + var symbol = TestCompilation.FieldType("public List Value = null!;"); + var value = new TypeIdentity(symbol); + + await Assert.That(value.IsGenericTypeDefinition).IsFalse(); + await Assert.That(value.TypeArguments[0].Kind).IsEqualTo(TypeReferenceKind.TypeParameter); + await Assert.That(value.TypeArguments[0].TypeParameterName).IsEqualTo("T"); + await Assert.That(value.RenderFullName).IsEqualTo("global::System.Collections.Generic.List"); + + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + } + + [Test] + public async Task TypeArguments_GivenNullableValueTypeArgument_ArePreserved() + { + var symbol = TestCompilation.FieldType("public List Value = null!;"); + var value = new TypeIdentity(symbol); + + await Assert.That(value.TypeArguments[0].IsNullable).IsTrue(); + await Assert.That(value.RenderFullName).IsEqualTo("global::System.Collections.Generic.List"); + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + } + + [Test] + public async Task TypeArguments_GivenNestedGenericArgument_ArePreserved() + { + var symbol = TestCompilation.FieldType("public Dictionary> Value = null!;"); + var value = new TypeIdentity(symbol); + + await Assert.That(value.TypeArguments.Length).IsEqualTo(2); + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert + .That(value.Matches(TestCompilation.FieldType("public Dictionary> Value = null!;"))) + .IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Namespace comparison + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("System.Collections.Generic", true)] + [Arguments("Collections.Generic", false)] + [Arguments("System.Collections", false)] + [Arguments("System.Collections.Generic.Extra", false)] + [Arguments(null, false)] + public async Task Matches_ComparesFullNamespace(string? @namespace, bool expected) + { + var compilation = TestCompilation.Create(); + var list = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1")!; + var value = new TypeIdentity("List", @namespace) with { GenericArity = 1 }; + + await Assert.That(value.Matches(list)).IsEqualTo(expected); + } + + [Test] + public async Task Matches_GivenGlobalNamespaceType_ReturnsTrue() + { + var compilation = TestCompilation.Create("public class Rootless { }"); + var symbol = compilation.GetTypeByMetadataName("Rootless")!; + var value = new TypeIdentity("Rootless", null); + + await Assert.That(value.IsGlobalNamespace).IsTrue(); + await Assert.That(value.Matches(symbol)).IsTrue(); + await Assert.That(value.RenderFullName).IsEqualTo("Rootless"); + } + + // --------------------------------------------------------------------------------------------- + // ISymbol matching + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Matches_GivenMemberSymbols_ResolvesTheirType() + { + var compilation = TestCompilation.Create( + """ + using System; + + namespace Sample; + + public class Holder + { + public Guid Field; + public Guid Property { get; set; } + public Guid Method(Guid parameter) => default; + public event EventHandler? Event; + } + """ + ); + + var holder = compilation.GetTypeByMetadataName("Sample.Holder")!; + var guid = new TypeIdentity("Guid", "System"); + + await Assert.That(guid.Matches(holder.GetMembers("Field").Single())).IsTrue(); + await Assert.That(guid.Matches(holder.GetMembers("Property").Single())).IsTrue(); + await Assert.That(guid.Matches(holder.GetMembers("Method").Single())).IsTrue(); + + var method = (IMethodSymbol)holder.GetMembers("Method").Single(); + await Assert.That(guid.Matches(method.Parameters[0])).IsTrue(); + + var eventHandler = new TypeIdentity("EventHandler", "System"); + await Assert.That(eventHandler.Matches(holder.GetMembers("Event").OfType().Single())).IsTrue(); + + await Assert.That(guid.Matches(holder.GetMembers("Event").OfType().Single())).IsFalse(); + await Assert.That(guid.Matches((ISymbol?)null)).IsFalse(); + } + + [Test] + public async Task Matches_GivenNamespaceSymbol_ReturnsFalse() + { + var compilation = TestCompilation.Create(); + var @namespace = compilation.GlobalNamespace.GetNamespaceMembers().First(); + + await Assert.That(new TypeIdentity("System", null).Matches(@namespace)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Reflection parity + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Create_GivenRuntimeType_MatchesEquivalentSymbol() + { + var compilation = TestCompilation.Create(); + + await Assert + .That(TypeIdentity.Create().Matches(compilation.GetTypeByMetadataName("System.DateTime"))) + .IsTrue(); + await Assert + .That(TypeIdentity.Create().Matches(compilation.GetTypeByMetadataName("System.Guid"))) + .IsTrue(); + + var listOfInt = compilation + .GetTypeByMetadataName("System.Collections.Generic.List`1")! + .Construct(compilation.GetSpecialType(SpecialType.System_Int32)); + + await Assert.That(TypeIdentity.Create>().Matches(listOfInt)).IsTrue(); + await Assert.That(TypeIdentity.Create>().Matches(listOfInt)).IsFalse(); + } + + [Test] + public async Task Create_GivenRuntimeTypeWithArrayArgument_DoesNotWiden() + { + var value = TypeIdentity.Create>(); + + await Assert.That(value.TypeArguments.Length).IsEqualTo(1); + await Assert.That(value.TypeArguments[0].IsArray).IsTrue(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsTrue(); + await Assert.That(value.Matches(TestCompilation.FieldType("public List Value = null!;"))).IsFalse(); + } + + [Test] + public async Task TryCreate_GivenUnrepresentableRuntimeType_ReturnsFalse() + { + await Assert.That(TypeIdentity.TryCreate(typeof(int[]), out _)).IsFalse(); + await Assert.That(TypeIdentity.TryCreate(typeof(int).MakeByRefType(), out _)).IsFalse(); + await Assert.That(TypeIdentity.TryCreate(typeof(List<>).GetGenericArguments()[0], out _)).IsFalse(); + await Assert.That(TypeIdentity.TryCreate((Type?)null, out _)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Equality contract + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Equality_IsStructuralAndHashConsistent() + { + var left = new TypeIdentity("Outer", "Sample") + .Nested("Inner") + .MakeGeneric(new TypeIdentity(SpecialType.System_String)); + var right = new TypeIdentity("Outer", "Sample") + .Nested("Inner") + .MakeGeneric(new TypeIdentity(SpecialType.System_String)); + var different = new TypeIdentity("Outer", "Sample") + .Nested("Inner") + .MakeGeneric(new TypeIdentity(SpecialType.System_Int32)); + + await Assert.That(left).IsEqualTo(right); + await Assert.That(left.GetHashCode()).IsEqualTo(right.GetHashCode()); + await Assert.That(left).IsNotEqualTo(different); + } + + [Test] + public async Task Equality_DistinguishesComposedTypeArguments() + { + var listOfInt = new TypeIdentity("List", "System.Collections.Generic").MakeGeneric( + new TypeIdentity(SpecialType.System_Int32) + ); + + var listOfIntArray = new TypeIdentity("List", "System.Collections.Generic").MakeGeneric( + new TypeIdentity(SpecialType.System_Int32).MakeArray() + ); + + await Assert.That(listOfInt).IsNotEqualTo(listOfIntArray); + await Assert.That(listOfIntArray.RenderFullName).IsEqualTo("global::System.Collections.Generic.List"); + } + + [Test] + public async Task MakeGeneric_GivenWrongArgumentCount_Throws() + { + var dictionary = new TypeIdentity("Dictionary", "System.Collections.Generic") with { GenericArity = 2 }; + + await Assert + .That(void () => _ = dictionary.MakeGeneric(new TypeIdentity(SpecialType.System_String))) + .Throws(); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs new file mode 100644 index 0000000..ac5e6b4 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs @@ -0,0 +1,219 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework; + +public sealed class TypeReferenceTests +{ + // --------------------------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task RenderFullName_RendersComposedSuffixesInSourceOrder() + { + var int32 = new TypeIdentity(SpecialType.System_Int32); + + await Assert.That(int32.AsTypeReference().RenderFullName).IsEqualTo("int"); + await Assert.That(int32.MakeArray().RenderFullName).IsEqualTo("int[]"); + await Assert.That(int32.MakeArray(2).RenderFullName).IsEqualTo("int[,]"); + await Assert.That(int32.MakeNullable().RenderFullName).IsEqualTo("int?"); + await Assert.That(int32.MakeNullable().MakeArray().RenderFullName).IsEqualTo("int?[]"); + await Assert.That(int32.MakeArray().Nullable().RenderFullName).IsEqualTo("int[]?"); + await Assert.That(int32.MakePointer().MakeArray().RenderFullName).IsEqualTo("int*[]"); + + // A run of array declarators reads outermost-first: a rank-1 array of rank-2 arrays. + await Assert.That(int32.MakeArray(2).MakeArray(1).RenderFullName).IsEqualTo("int[][,]"); + } + + [Test] + public async Task RenderFullName_GivenTypeParameterOrDynamic_RendersCore() + { + await Assert.That(TypeReference.ForTypeParameter("TKey").RenderFullName).IsEqualTo("TKey"); + await Assert.That(TypeReference.ForTypeParameter("TKey").MakeArray().RenderFullName).IsEqualTo("TKey[]"); + await Assert.That(TypeReference.Dynamic.RenderFullName).IsEqualTo("dynamic"); + } + + // --------------------------------------------------------------------------------------------- + // Round-tripping from symbols + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("public int[] Value = null!;", "int[]")] + [Arguments("public int[,] Value = null!;", "int[,]")] + [Arguments("public int[][,] Value = null!;", "int[][,]")] + [Arguments("public int? Value;", "int?")] + [Arguments("public byte* Value;", "byte*")] + [Arguments("public byte** Value;", "byte**")] + [Arguments("public byte*[] Value = null!;", "byte*[]")] + [Arguments("public T Value = default!;", "T")] + [Arguments("public T[] Value = null!;", "T[]")] + [Arguments("public dynamic Value = null!;", "dynamic")] + public async Task TryCreate_RoundTripsSymbolToRenderedSource(string fieldDeclaration, string expected) + { + var symbol = TestCompilation.FieldType(fieldDeclaration); + + await Assert.That(TypeReference.TryCreate(symbol, out var reference)).IsTrue(); + await Assert.That(reference.RenderFullName).IsEqualTo(expected); + await Assert.That(reference.Matches(symbol)).IsTrue(); + } + + [Test] + public async Task TryCreate_GivenNullableReferenceType_RecordsAnnotation() + { + var symbol = TestCompilation.FieldType("public string? Value;"); + + await Assert.That(TypeReference.TryCreate(symbol, out var reference)).IsTrue(); + await Assert.That(reference.IsNullable).IsTrue(); + await Assert.That(reference.RenderFullName).IsEqualTo("string?"); + } + + [Test] + public async Task TryCreate_GivenNullableArrayOfNullableElements_PreservesOrdering() + { + var symbol = TestCompilation.FieldType("public string?[]? Value;"); + + await Assert.That(TypeReference.TryCreate(symbol, out var reference)).IsTrue(); + await Assert.That(reference.RenderFullName).IsEqualTo("string?[]?"); + } + + [Test] + public async Task TryCreate_GivenErrorType_ReturnsFalse() + { + var symbol = TestCompilation.FieldType("public Missing.Thing Value;"); + + await Assert.That(TypeReference.TryCreate(symbol, out _)).IsFalse(); + } + + [Test] + [Arguments(typeof(int[]), "int[]")] + [Arguments(typeof(int[,]), "int[,]")] + [Arguments(typeof(int?), "int?")] + public async Task TryCreate_GivenRuntimeType_RoundTrips(Type type, string expected) + { + await Assert.That(TypeReference.TryCreate(type, out var reference)).IsTrue(); + await Assert.That(reference.RenderFullName).IsEqualTo(expected); + } + + // --------------------------------------------------------------------------------------------- + // Matching + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Matches_GivenArrayRankMismatch_ReturnsFalse() + { + var reference = new TypeIdentity(SpecialType.System_Int32).MakeArray(); + + await Assert.That(reference.Matches(TestCompilation.FieldType("public int[] Value = null!;"))).IsTrue(); + await Assert.That(reference.Matches(TestCompilation.FieldType("public int[,] Value = null!;"))).IsFalse(); + await Assert.That(reference.Matches(TestCompilation.FieldType("public int Value;"))).IsFalse(); + } + + [Test] + public async Task Matches_GivenNullableValueType_IsEnforced() + { + var nullable = new TypeIdentity(SpecialType.System_Int32).MakeNullable(); + + await Assert.That(nullable.Matches(TestCompilation.FieldType("public int? Value;"))).IsTrue(); + await Assert.That(nullable.Matches(TestCompilation.FieldType("public int Value;"))).IsFalse(); + } + + [Test] + public async Task Matches_GivenNullableReferenceType_IgnoresAnnotation() + { + var nullable = new TypeIdentity(SpecialType.System_String).MakeNullable(); + + // Annotation is metadata, not identity: both spellings resolve to System.String. + await Assert.That(nullable.Matches(TestCompilation.FieldType("public string? Value;"))).IsTrue(); + await Assert.That(nullable.Matches(TestCompilation.FieldType("public string Value = null!;"))).IsTrue(); + } + + [Test] + public async Task Matches_GivenTypeParameter_ComparesByName() + { + var symbol = TestCompilation.FieldType("public T Value = default!;"); + + await Assert.That(TypeReference.ForTypeParameter("T").Matches(symbol)).IsTrue(); + await Assert.That(TypeReference.ForTypeParameter("TOther").Matches(symbol)).IsFalse(); + await Assert.That(TypeReference.Dynamic.Matches(symbol)).IsFalse(); + } + + [Test] + public async Task Matches_GivenDynamic_ReturnsTrue() + { + var symbol = TestCompilation.FieldType("public dynamic Value = null!;"); + + await Assert.That(TypeReference.Dynamic.Matches(symbol)).IsTrue(); + await Assert.That(new TypeIdentity(SpecialType.System_Object).AsTypeReference().Matches(symbol)).IsFalse(); + } + + [Test] + public async Task Matches_GivenMemberSymbol_ResolvesItsType() + { + var compilation = TestCompilation.Create( + """ + namespace Sample; + + public class Holder + { + public int[] Field = null!; + public int Other; + } + """ + ); + + var holder = compilation.GetTypeByMetadataName("Sample.Holder")!; + var reference = new TypeIdentity(SpecialType.System_Int32).MakeArray(); + + await Assert.That(reference.Matches(holder.GetMembers("Field").Single())).IsTrue(); + await Assert.That(reference.Matches(holder.GetMembers("Other").Single())).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Equality + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Equality_DistinguishesModifierOrder() + { + var int32 = new TypeIdentity(SpecialType.System_Int32); + + await Assert.That(int32.MakeNullable().MakeArray()).IsNotEqualTo(int32.MakeArray().Nullable()); + await Assert.That(int32.MakeArray()).IsEqualTo(int32.MakeArray()); + await Assert.That(int32.MakeArray().GetHashCode()).IsEqualTo(int32.MakeArray().GetHashCode()); + await Assert.That(int32.MakeArray(1)).IsNotEqualTo(int32.MakeArray(2)); + } + + [Test] + public async Task Equals_GivenPlainNamedType_MatchesUnderlyingValueObject() + { + var int32 = new TypeIdentity(SpecialType.System_Int32); + + await Assert.That(int32.AsTypeReference().Equals(int32)).IsTrue(); + await Assert.That(int32.MakeArray().Equals(int32)).IsFalse(); + await Assert.That(int32.AsTypeReference().IsPlainNamedType).IsTrue(); + } + + [Test] + public async Task ImplicitConversion_FromNamedType_ProducesPlainReference() + { + TypeReference reference = new TypeIdentity("Order", "Sample"); + + await Assert.That(reference.Kind).IsEqualTo(TypeReferenceKind.Named); + await Assert.That(reference.IsPlainNamedType).IsTrue(); + await Assert.That(reference.RenderFullName).IsEqualTo("global::Sample.Order"); + } + + [Test] + public async Task Append_GivenEmptyReference_Throws() + { + await Assert.That(void () => _ = TypeReference.Empty.MakeArray()).Throws(); + } + + [Test] + public async Task MakeArray_GivenInvalidRank_Throws() + { + var int32 = new TypeIdentity(SpecialType.System_Int32); + + await Assert.That(void () => _ = int32.MakeArray(0)).Throws(); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TypeSyntaxMatchingTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeSyntaxMatchingTests.cs new file mode 100644 index 0000000..e26bcc7 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/TypeSyntaxMatchingTests.cs @@ -0,0 +1,429 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework; + +public sealed class TypeSyntaxMatchingTests +{ + // --------------------------------------------------------------------------------------------- + // Declarations — syntactic + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task CouldMatchDeclaration_GivenFileScopedNamespace_ReturnsTrue() + { + var (_, root) = TestCompilation.Parse( + """ + namespace Sample.Domain; + + public class Order { } + """ + ); + + var declaration = root.DescendantNodes().OfType().Single(); + + await Assert.That(new TypeIdentity("Order", "Sample.Domain").CouldMatchDeclaration(declaration)).IsTrue(); + await Assert.That(new TypeIdentity("Order", "Sample").CouldMatchDeclaration(declaration)).IsFalse(); + await Assert.That(new TypeIdentity("Order", null).CouldMatchDeclaration(declaration)).IsFalse(); + } + + [Test] + public async Task CouldMatchDeclaration_GivenNestedTypeInSplitNamespace_ReturnsTrue() + { + var (_, root) = TestCompilation.Parse( + """ + namespace Sample + { + namespace Domain + { + public partial class Outer + { + public class Inner { } + } + } + } + """ + ); + + var declaration = root.DescendantNodes() + .OfType() + .Single(node => node.Identifier.ValueText == "Inner"); + + var value = (new TypeIdentity("Outer", "Sample.Domain") with { GenericArity = 1 }).Nested("Inner"); + var wrongArity = new TypeIdentity("Outer", "Sample.Domain").Nested("Inner"); + + await Assert.That(value.CouldMatchDeclaration(declaration)).IsTrue(); + await Assert.That(wrongArity.CouldMatchDeclaration(declaration)).IsFalse(); + await Assert.That(new TypeIdentity("Inner", "Sample.Domain").CouldMatchDeclaration(declaration)).IsFalse(); + } + + [Test] + [Arguments("public class Target { }")] + [Arguments("public struct Target { }")] + [Arguments("public interface Target { }")] + [Arguments("public record Target { }")] + [Arguments("public record struct Target { }")] + [Arguments("public enum Target { A }")] + [Arguments("public delegate void Target();")] + public async Task CouldMatchDeclaration_SupportsEveryTypeDefiningDeclaration(string declarationText) + { + var (_, root) = TestCompilation.Parse($"namespace Sample;\n\n{declarationText}"); + + var declaration = root.DescendantNodes() + .First(node => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax); + + await Assert.That(new TypeIdentity("Target", "Sample").CouldMatchDeclaration(declaration)).IsTrue(); + await Assert.That(new TypeIdentity("Other", "Sample").CouldMatchDeclaration(declaration)).IsFalse(); + } + + [Test] + public async Task CouldMatchDeclaration_GivenGenericDelegate_ComparesArity() + { + var (_, root) = TestCompilation.Parse( + "namespace Sample;\n\npublic delegate TResult Projector(T value);" + ); + + var declaration = root.DescendantNodes().OfType().Single(); + + await Assert + .That( + (new TypeIdentity("Projector", "Sample") with { GenericArity = 2 }).CouldMatchDeclaration(declaration) + ) + .IsTrue(); + + await Assert.That(new TypeIdentity("Projector", "Sample").CouldMatchDeclaration(declaration)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Declarations — semantic + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task MatchesDeclaration_ResolvesDeclaredSymbol() + { + var (compilation, root) = TestCompilation.CreateWithRoot( + """ + namespace Sample.Domain; + + public class Outer + { + public class Inner { } + } + """ + ); + + var model = compilation.GetSemanticModel(root.SyntaxTree); + var inner = root.DescendantNodes() + .OfType() + .Single(node => node.Identifier.ValueText == "Inner"); + + var value = new TypeIdentity("Outer", "Sample.Domain").Nested("Inner"); + + await Assert.That(value.MatchesDeclaration(inner, model)).IsTrue(); + await Assert.That(new TypeIdentity("Inner", "Sample.Domain").MatchesDeclaration(inner, model)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // References — syntactic + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("global::System.Collections.Generic.List", true)] + [Arguments("System.Collections.Generic.List", true)] + [Arguments("Generic.List", true)] + [Arguments("List", true)] + [Arguments("Other.List", false)] + [Arguments("System.Collections.List", false)] + [Arguments("global::Generic.List", false)] + [Arguments("List", false)] + [Arguments("List", false)] + [Arguments("Queue", false)] + public async Task CouldMatchTypeReference_ChecksNameArityAndQualifier(string written, bool expected) + { + var typeSyntax = SyntaxFactory.ParseTypeName(written); + var value = new TypeIdentity("List", "System.Collections.Generic") with { GenericArity = 1 }; + + await Assert.That(value.CouldMatchTypeReference(typeSyntax)).IsEqualTo(expected); + } + + [Test] + [Arguments("int", true)] + [Arguments("string", false)] + [Arguments("System.Int32", true)] + public async Task CouldMatchTypeReference_GivenPredefinedType_MatchesKeyword(string written, bool expected) + { + var typeSyntax = SyntaxFactory.ParseTypeName(written); + var value = new TypeIdentity(SpecialType.System_Int32); + + await Assert.That(value.CouldMatchTypeReference(typeSyntax)).IsEqualTo(expected); + } + + [Test] + public async Task CouldMatchTypeReference_GivenNamedType_RejectsComposedSyntax() + { + var value = new TypeIdentity("List", "System.Collections.Generic") with { GenericArity = 1 }; + + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("List[]"))).IsFalse(); + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("(List, int)"))).IsFalse(); + } + + [Test] + public async Task CouldMatchTypeReference_GivenNestedTypeQualifier_ReturnsTrue() + { + var value = new TypeIdentity("Outer", "Sample").Nested("Inner"); + + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("Sample.Outer.Inner"))).IsTrue(); + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("Outer.Inner"))).IsTrue(); + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("Inner"))).IsTrue(); + await Assert.That(value.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("Sample.Inner"))).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Composed references — syntactic + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("int[]", true)] + [Arguments("int[,]", false)] + [Arguments("int", false)] + [Arguments("int[][]", false)] + public async Task CouldMatchTypeReference_GivenArrayReference_ComparesRanks(string written, bool expected) + { + var reference = new TypeIdentity(SpecialType.System_Int32).MakeArray(); + + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName(written))).IsEqualTo(expected); + } + + [Test] + public async Task CouldMatchTypeReference_GivenJaggedArray_ComparesRunOrder() + { + // A rank-1 array of rank-2 arrays. + var reference = new TypeIdentity(SpecialType.System_Int32).MakeArray(2).MakeArray(1); + + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("int[][,]"))).IsTrue(); + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("int[,][]"))).IsFalse(); + } + + [Test] + public async Task CouldMatchTypeReference_IgnoresNullableAnnotationOnBothSides() + { + var reference = new TypeIdentity(SpecialType.System_String).MakeNullable(); + + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("string?"))).IsTrue(); + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("string"))).IsTrue(); + } + + [Test] + public async Task CouldMatchTypeReference_GivenTypeParameterOrDynamic_MatchesCore() + { + await Assert + .That(TypeReference.ForTypeParameter("T").CouldMatchTypeReference(SyntaxFactory.ParseTypeName("T"))) + .IsTrue(); + + await Assert + .That( + TypeReference + .ForTypeParameter("T") + .MakeArray() + .CouldMatchTypeReference(SyntaxFactory.ParseTypeName("T[]")) + ) + .IsTrue(); + + await Assert + .That(TypeReference.ForTypeParameter("T").CouldMatchTypeReference(SyntaxFactory.ParseTypeName("TOther"))) + .IsFalse(); + + await Assert + .That(TypeReference.Dynamic.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("dynamic"))) + .IsTrue(); + } + + [Test] + public async Task CouldMatchTypeReference_GivenPointerReference_ComparesDepth() + { + var reference = new TypeIdentity(SpecialType.System_Byte).MakePointer(); + + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("byte*"))).IsTrue(); + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("byte**"))).IsFalse(); + await Assert.That(reference.CouldMatchTypeReference(SyntaxFactory.ParseTypeName("byte"))).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // References — semantic + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task MatchesTypeReference_ResolvesThroughUsings() + { + var (compilation, root) = TestCompilation.CreateWithRoot( + """ + using System.Collections.Generic; + + namespace Sample; + + public class Holder + { + public List Values = null!; + public List Names = null!; + } + """ + ); + + var model = compilation.GetSemanticModel(root.SyntaxTree); + var declarations = root.DescendantNodes().OfType().ToArray(); + + var value = new TypeIdentity("List", "System.Collections.Generic").MakeGeneric( + new TypeIdentity(SpecialType.System_Int32) + ); + + await Assert.That(value.MatchesTypeReference(declarations[0].Type, model)).IsTrue(); + await Assert.That(value.MatchesTypeReference(declarations[1].Type, model)).IsFalse(); + } + + [Test] + public async Task MatchesTypeReference_GivenComposedReference_ResolvesArrays() + { + var (compilation, root) = TestCompilation.CreateWithRoot( + """ + namespace Sample; + + public class Holder + { + public int[] Ranked = null!; + public int[,] Rectangular = null!; + } + """ + ); + + var model = compilation.GetSemanticModel(root.SyntaxTree); + var declarations = root.DescendantNodes().OfType().ToArray(); + var reference = new TypeIdentity(SpecialType.System_Int32).MakeArray(); + + await Assert.That(reference.MatchesTypeReference(declarations[0].Type, model)).IsTrue(); + await Assert.That(reference.MatchesTypeReference(declarations[1].Type, model)).IsFalse(); + } + + [Test] + public async Task MatchesDeclaredType_ResolvesMemberTypes() + { + var (compilation, root) = TestCompilation.CreateWithRoot( + """ + using System; + + namespace Sample; + + public class Holder + { + public Guid Field; + public Guid Property { get; set; } + public Guid Method() => default; + public string Other = null!; + } + """ + ); + + var model = compilation.GetSemanticModel(root.SyntaxTree); + var guid = new TypeIdentity("Guid", "System"); + + var field = root.DescendantNodes().OfType().First(); + var property = root.DescendantNodes().OfType().Single(); + var method = root.DescendantNodes().OfType().Single(); + var other = root.DescendantNodes().OfType().Last(); + + await Assert.That(guid.MatchesDeclaredType(field, model)).IsTrue(); + await Assert.That(guid.MatchesDeclaredType(property, model)).IsTrue(); + await Assert.That(guid.MatchesDeclaredType(method, model)).IsTrue(); + await Assert.That(guid.MatchesDeclaredType(other, model)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // Attributes + // --------------------------------------------------------------------------------------------- + + [Test] + [Arguments("[Sentinel]", true)] + [Arguments("[SentinelAttribute]", true)] + [Arguments("[Sample.Sentinel]", true)] + [Arguments("[global::Sample.SentinelAttribute]", true)] + [Arguments("[Other.Sentinel]", false)] + [Arguments("[Sentinels]", false)] + public async Task CouldMatchAttribute_AcceptsBothSpellings(string attributeText, bool expected) + { + var (_, root) = TestCompilation.Parse($"namespace Sample;\n\n{attributeText}\npublic class Target {{ }}"); + + var attribute = root.DescendantNodes().OfType().Single(); + var value = new TypeIdentity("SentinelAttribute", "Sample"); + + await Assert.That(value.CouldMatchAttribute(attribute)).IsEqualTo(expected); + } + + [Test] + public async Task HasAttribute_ResolvesAppliedAttribute() + { + var (compilation, root) = TestCompilation.CreateWithRoot( + """ + using System; + + namespace Sample; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class SentinelAttribute : Attribute { } + + [Sentinel] + public class Flagged { } + + public class Unflagged { } + """ + ); + + var model = compilation.GetSemanticModel(root.SyntaxTree); + var value = new TypeIdentity("SentinelAttribute", "Sample"); + + var flagged = root.DescendantNodes() + .OfType() + .Single(node => node.Identifier.ValueText == "Flagged"); + var unflagged = root.DescendantNodes() + .OfType() + .Single(node => node.Identifier.ValueText == "Unflagged"); + + await Assert.That(value.HasAttribute(flagged, model)).IsTrue(); + await Assert.That(value.HasAttribute(unflagged, model)).IsFalse(); + } + + // --------------------------------------------------------------------------------------------- + // TypeSyntaxFacts + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task GetDeclaredNamespace_GivenNoNamespace_ReturnsNull() + { + var (_, root) = TestCompilation.Parse("public class Rootless { }"); + var declaration = root.DescendantNodes().OfType().Single(); + + await Assert.That(TypeSyntaxFacts.GetDeclaredNamespace(declaration)).IsNull(); + } + + [Test] + public async Task TryGetCore_PeelsCompositionOutermostFirst() + { + await Assert + .That(TypeSyntaxFacts.TryGetCore(SyntaxFactory.ParseTypeName("int[][,]"), out var core, out var modifiers)) + .IsTrue(); + + await Assert.That(core.ToString()).IsEqualTo("int"); + + await Assert.That(modifiers).IsNotNull(); + await Assert.That(modifiers.Count).IsEqualTo(2); + await Assert.That(modifiers[0].Rank).IsEqualTo(1); + await Assert.That(modifiers[1].Rank).IsEqualTo(2); + } + + [Test] + public async Task TryGetCore_GivenUnrepresentableSyntax_ReturnsFalse() + { + await Assert + .That(TypeSyntaxFacts.TryGetCore(SyntaxFactory.ParseTypeName("(int, string)"), out _, out _)) + .IsFalse(); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/XmlCommentWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs similarity index 98% rename from src/tests/SourceGeneratorFramework.UnitTests/XmlCommentWriterTests.cs rename to src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs index a46f193..9b35d00 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/XmlCommentWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs @@ -1,5 +1,3 @@ -using Purview.SourceGeneratorFramework.Models; - namespace Purview.SourceGeneratorFramework; public class XmlCommentWriterTests @@ -81,7 +79,7 @@ public async Task XmlCref_TypeValueObject_WritesRenderedCrefAndMultipleContentLi { var writer = CodeWriterFactory.ForTests(); - writer.XmlCref(new TypeValueObject(typeof(string)), "first", "second"); + writer.XmlCref(new TypeIdentity(typeof(string)), "first", "second"); await Assert .That(writer.ToString()) @@ -116,7 +114,7 @@ public async Task XmlException_TypeValueObject_WritesRenderedCref() { var writer = CodeWriterFactory.ForTests(); - writer.XmlException(new TypeValueObject(typeof(ArgumentException)), "reason"); + writer.XmlException(new TypeIdentity(typeof(ArgumentException)), "reason"); await Assert .That(writer.ToString())