diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6e7d513..a0969d9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,21 +11,9 @@ concurrency: 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 - env: - Build__RunPack: "true" - Build__ValidatePack: "true" - run: dotnet run --project build/PipelineCLI/PipelineCLI.csproj --configuration Release + uses: purview-dev/build/.github/workflows/purview-build.yml@main + with: + build-version: "0.2.1" + run-pack: true + validate-pack: true + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8706da..97abb5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,41 +11,9 @@ concurrency: 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__Mode: NuGet - NuGet__ApiKey: ${{ secrets.NUGET__APIKEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: dotnet run --project build/PipelineCLI/PipelineCLI.csproj --configuration Release + uses: purview-dev/build/.github/workflows/purview-release.yml@main + with: + build-version: "0.2.1" + release-mode: NuGet + release-branch: main + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1baaa36..a047b9b 100644 --- a/.gitignore +++ b/.gitignore @@ -660,3 +660,5 @@ sketch BenchmarkDotNet.Artifacts/ !**/Sdk/build !build/ + +.tools/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 23c42bb..c67f4ae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,15 +4,8 @@ [4.13.0,) [5.9.0,) [1.65.51,) - [3.2.8,) - - - - - - diff --git a/Justfile b/Justfile index fb6706f..3272540 100644 --- a/Justfile +++ b/Justfile @@ -5,8 +5,9 @@ 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" +pipeline_version := "0.2.1" +pipeline_feed := "https://api.nuget.org/v3/index.json" +pipeline_tool := ".tools/purview-build/purview-build" current_version := `node -p "require('./package.json').version"` @@ -14,38 +15,50 @@ current_version := `node -p "require('./package.json').version"` default: just --list +# Install the shared Purview.Build tool (authenticated to the Purview-Dev feed) if not present +[private] +ensure-pipeline-tool: + if [ ! -x "{{ pipeline_tool }}" ]; then \ + dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}" --version "{{ pipeline_version }}"; \ + fi + # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] pipeline-pr *args: + just ensure-pipeline-tool echo "Running PR pipeline..." - dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} {{ args }} + "{{ pipeline_tool }}" {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] pipeline-build *args: + just ensure-pipeline-tool echo "Running build pipeline..." - dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Build:RunTests=false --Release:Mode=None {{ args }} + "{{ pipeline_tool }}" --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] pipeline-release *args: + just ensure-pipeline-tool echo "Running release pipeline..." - dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Release:Mode=NuGet {{ args }} + "{{ pipeline_tool }}" --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. +# Use the LOCAL_NUGET_FEED_PATH environment variable or forward slashes, e.g. # just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ [group('Pipeline')] pipeline-local-release *args: + just ensure-pipeline-tool echo "Running local release pipeline..." - dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Release:Mode=LocalNuGet {{ args }} + "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: + just ensure-pipeline-tool echo "Running tests pipeline..." - dotnet run --project {{ pipeline_project }} --configuration {{ build_configuration }} -- --Build:RunTests=true --Release:Mode=None {{ args }} + "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} # Build and test with the specified configuration, defaulting to "Release" [group('Build and Test')] @@ -105,8 +118,3 @@ lint-fix: [group('Utilities')] vs: open {{ solution }} - -# Open the solution in Visual Studio/ Registered application -[group('Utilities')] -vs-pipeline: - open {{ pipeline_solution }} diff --git a/build/Directory.Build.props b/build/Directory.Build.props deleted file mode 100644 index 772d7f7..0000000 --- a/build/Directory.Build.props +++ /dev/null @@ -1,12 +0,0 @@ - - - Purview.SourceGeneratorFramework - true - - - - - - $(NoWarn);CA1062;CA1515;CA2007;CA1873; - - diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets deleted file mode 100644 index a3bbd31..0000000 --- a/build/Directory.Build.targets +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/build/Pipeline.slnx b/build/Pipeline.slnx deleted file mode 100644 index 410fbba..0000000 --- a/build/Pipeline.slnx +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/build/PipelineCLI/GlobalUsings.cs b/build/PipelineCLI/GlobalUsings.cs deleted file mode 100644 index ba849f1..0000000 --- a/build/PipelineCLI/GlobalUsings.cs +++ /dev/null @@ -1,11 +0,0 @@ -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.SourceGeneratorFramework.PipelineCLI.Helpers; -global using Purview.SourceGeneratorFramework.PipelineCLI.Modules; -global using Purview.SourceGeneratorFramework.PipelineCLI.Settings; diff --git a/build/PipelineCLI/Helpers/DotNetCLIOptions.cs b/build/PipelineCLI/Helpers/DotNetCLIOptions.cs deleted file mode 100644 index e967e66..0000000 --- a/build/PipelineCLI/Helpers/DotNetCLIOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ModularPipelines.Options; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index aed0515..0000000 --- a/build/PipelineCLI/Helpers/PathHelpers.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Purview.SourceGeneratorFramework.PipelineCLI.Helpers; - -static class PathHelpers -{ - public static string FindRepositoryRoot(string? startDirectory = null) - { - if (string.IsNullOrEmpty(startDirectory)) - startDirectory = PipelineProjectDirectory.Find(); - - DirectoryInfo? directory = new(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 deleted file mode 100644 index 5fa7e51..0000000 --- a/build/PipelineCLI/Helpers/TestHelpers.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index 6062972..0000000 --- a/build/PipelineCLI/Modules/BuildModule.cs +++ /dev/null @@ -1,30 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Context; -using ModularPipelines.DotNet.Extensions; -using ModularPipelines.Models; -using ModularPipelines.Modules; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index ae0259a..0000000 --- a/build/PipelineCLI/Modules/CreateGitHubReleaseModule.cs +++ /dev/null @@ -1,55 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Configuration; -using ModularPipelines.Context; -using ModularPipelines.GitHub.Extensions; -using ModularPipelines.Models; -using ModularPipelines.Modules; - -namespace Purview.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Release")] -[DependsOn] -[DependsOn] -[DependsOn] -public class CreateGitHubReleaseModule(IOptions releaseSettings, IOptions gitSettings) - : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(_ => - releaseSettings.Value.Mode is not (ReleaseMode.NuGet or ReleaseMode.GitHubRelease) - || string.IsNullOrWhiteSpace(gitSettings.Value.GetGitHubToken()) - ? SkipDecision.Skip( - "GitHub release creation is disabled. Set Release__Mode=NuGet (or GitHubRelease) and 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 deleted file mode 100644 index 91ff181..0000000 --- a/build/PipelineCLI/Modules/LintModule.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Configuration; -using ModularPipelines.Context; -using ModularPipelines.DotNet.Extensions; -using ModularPipelines.Models; -using ModularPipelines.Modules; - -namespace Purview.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Build")] -public sealed class LintModule(IOptions settings) : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(_ => - settings.Value.RunLint - ? SkipDecision.DoNotSkip - : SkipDecision.Skip("Linting is disabled. Set Build__RunLint=true to enable it.") - ) - .Build(); - - protected override async Task ExecuteAsync( - IModuleContext context, - CancellationToken cancellationToken - ) - { - var repositoryRoot = PathHelpers.FindRepositoryRoot(); - var dotnet = context.DotNet(); - var restoreResult = await dotnet.Tool.Restore( - new() { Interactive = false, ToolManifest = Path.Combine(repositoryRoot, ".config", "dotnet-tools.json") }, - new() { WorkingDirectory = repositoryRoot }, - cancellationToken - ); - if (restoreResult.ExitCode != 0) - return restoreResult; - - // Restore worked, now run the linter - return await context.Shell.Command.ExecuteCommandLineTool( - DotNetCLIOptions.Create("tool", "run", "csharpier", "check", repositoryRoot), - new() { WorkingDirectory = repositoryRoot }, - cancellationToken: cancellationToken - ); - } -} diff --git a/build/PipelineCLI/Modules/PackModule.cs b/build/PipelineCLI/Modules/PackModule.cs deleted file mode 100644 index 166eec0..0000000 --- a/build/PipelineCLI/Modules/PackModule.cs +++ /dev/null @@ -1,52 +0,0 @@ -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.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Build")] -[DependsOn] -[DependsOn] -public sealed class PackModule(IOptions settings) : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(_ => - !settings.Value.RunPack - ? SkipDecision.Skip("Packing is disabled. Set Build__RunPack=true to enable it.") - : SkipDecision.DoNotSkip - ) - .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 deleted file mode 100644 index 5f1bbde..0000000 --- a/build/PipelineCLI/Modules/PublishLocalNuGetModule.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using ModularPipelines.Attributes; -using ModularPipelines.Configuration; -using ModularPipelines.Context; -using ModularPipelines.Models; -using ModularPipelines.Modules; -using NuGet.Versioning; - -namespace Purview.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Build")] -[DependsOn] -[DependsOn] -public class PublishLocalNuGetModule( - IOptions localNuGetFeedSettings, - IOptions releaseSettings, - IOptions buildSettings -) : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(ctx => - !ctx.IsRunningLocally() || releaseSettings.Value.Mode != ReleaseMode.LocalNuGet - ? SkipDecision.Skip( - "Local NuGet Feed publishing is disabled. Run the pipeline locally with Release__Mode=LocalNuGet to enable it." - ) - : SkipDecision.DoNotSkip - ) - .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, "*.nupkg") - .Concat(Directory.GetFiles(buildSettings.Value.ArtifactsFolder, "*.snupkg")) - .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 deleted file mode 100644 index f2195ab..0000000 --- a/build/PipelineCLI/Modules/PublishNuGetModule.cs +++ /dev/null @@ -1,71 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Configuration; -using ModularPipelines.Context; -using ModularPipelines.DotNet.Extensions; -using ModularPipelines.Models; -using ModularPipelines.Modules; - -namespace Purview.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Release")] -[DependsOn] -[DependsOn] -[DependsOn] -public class PublishNuGetModule( - IOptions buildSettings, - IOptions nugetSettings, - IOptions releaseSettings -) : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(_ => - releaseSettings.Value.Mode != ReleaseMode.NuGet - || string.IsNullOrWhiteSpace(nugetSettings.Value.GetNuGetAPIKey()) - ? SkipDecision.Skip( - "NuGet publishing is disabled. Set Release__Mode=NuGet and NuGet__ApiKey (or NUGET_APIKEY) to publish packages to nuget.org." - ) - : SkipDecision.DoNotSkip - ) - .Build(); - - protected override async Task ExecuteAsync( - IModuleContext context, - CancellationToken cancellationToken - ) - { - var artifactsFolder = buildSettings.Value.ArtifactsFolder; - if (!Directory.Exists(artifactsFolder)) - { - throw new InvalidOperationException( - $"The artifacts folder '{artifactsFolder}' does not exist. " - + "Ensure the pack step ran (Release__Mode must not be None) before publishing." - ); - } - - var packages = Directory.EnumerateFiles(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 deleted file mode 100644 index eff5547..0000000 --- a/build/PipelineCLI/Modules/RestoreModule.cs +++ /dev/null @@ -1,25 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Context; -using ModularPipelines.DotNet.Extensions; -using ModularPipelines.DotNet.Options; -using ModularPipelines.Models; -using ModularPipelines.Modules; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index e30dece..0000000 --- a/build/PipelineCLI/Modules/RunTestsModule.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; -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.SourceGeneratorFramework.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 = FilterTestProjects( - Directory.EnumerateFiles("src/tests", "*Tests.csproj", SearchOption.AllDirectories).ToList(), - settings.Value.TestProjects - ); - if (testProjects.Count == 0) - { - context.Logger.LogWarning( - "No test projects matched 'src/tests' (filter: {TestProjects}), despite tests being enabled. Skipping test execution.", - settings.Value.TestProjects - ); - - return []; - } - - var timings = new List<(string Project, TimeSpan Elapsed, int ExitCode)>(); - - var tasks = testProjects.Select(async project => - { - var stopwatch = Stopwatch.StartNew(); - var result = await 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 - ); - stopwatch.Stop(); - - lock (timings) - timings.Add((project, stopwatch.Elapsed, result.ExitCode)); - - return result; - }); - - var results = await Task.WhenAll(tasks); - - context.Logger.LogInformation( - "Test run timings:{NewLine}{Timings}", - Environment.NewLine, - string.Join( - Environment.NewLine, - timings - .OrderByDescending(t => t.Elapsed) - .Select(t => $" {Path.GetFileName(t.Project)}: {t.Elapsed.TotalSeconds:F1}s (exit {t.ExitCode})") - ) - ); - - return results; - } - - static IReadOnlyList FilterTestProjects(IReadOnlyList projects, string filter) - { - if (string.IsNullOrWhiteSpace(filter) || filter.Trim() == "*") - return projects; - - var patterns = filter - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(ToRegexPattern) - .ToArray(); - - return projects - .Where(project => - { - var fileName = Path.GetFileName(project); - return patterns.Any(pattern => Regex.IsMatch(fileName, pattern, RegexOptions.IgnoreCase)); - }) - .ToList(); - } - - static string ToRegexPattern(string entry) - { - if (entry.Contains('*', StringComparison.Ordinal)) - { - var escaped = Regex.Escape(entry); - return "^" + escaped.Replace("\\*", ".*", StringComparison.Ordinal) + "$"; - } - - return "^" + Regex.Escape(entry) + "$"; - } -} diff --git a/build/PipelineCLI/Modules/ValidatePackModule.cs b/build/PipelineCLI/Modules/ValidatePackModule.cs deleted file mode 100644 index 236a1d6..0000000 --- a/build/PipelineCLI/Modules/ValidatePackModule.cs +++ /dev/null @@ -1,344 +0,0 @@ -using ModularPipelines.Attributes; -using ModularPipelines.Configuration; -using ModularPipelines.Context; -using ModularPipelines.Models; -using ModularPipelines.Modules; -using NuGet.Packaging; -using NuGet.Versioning; - -namespace Purview.SourceGeneratorFramework.PipelineCLI.Modules; - -[ModuleCategory("Build")] -[DependsOn] -public sealed class ValidatePackModule( - IOptions buildSettings, - IOptions packValidationSettings -) : Module -{ - protected override ModuleConfiguration Configure() => - ModuleConfiguration - .Create() - .WithSkipWhen(_ => - !buildSettings.Value.ValidatePack - ? SkipDecision.Skip("Pack validation is disabled. Set Build__ValidatePack=true to enable it.") - : SkipDecision.DoNotSkip - ) - .Build(); - - protected override async Task ExecuteAsync( - IModuleContext context, - CancellationToken cancellationToken - ) - { - var artifactsFolder = Path.GetFullPath(buildSettings.Value.ArtifactsFolder); - if (!Directory.Exists(artifactsFolder)) - { - throw new InvalidOperationException( - $"The artifacts folder '{artifactsFolder}' does not exist. Run the pack step first." - ); - } - - var nupkgFiles = Directory.EnumerateFiles(artifactsFolder, "*.nupkg", SearchOption.TopDirectoryOnly).ToArray(); - var snupkgFiles = Directory - .EnumerateFiles(artifactsFolder, "*.snupkg", SearchOption.TopDirectoryOnly) - .ToArray(); - - if (nupkgFiles.Length == 0) - { - throw new InvalidOperationException($"No .nupkg files found in {artifactsFolder}."); - } - - var results = new List(nupkgFiles.Length + snupkgFiles.Length); - var packagePairs = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var package in nupkgFiles) - { - var result = await ValidateNupkgAsync(package, packValidationSettings.Value, cancellationToken); - results.Add(result); - - var pair = GetOrAddPair(packagePairs, result.PackageKey); - pair.Nupkg = result; - } - - foreach (var package in snupkgFiles) - { - var result = await ValidateSnupkgAsync(package, packValidationSettings.Value, cancellationToken); - results.Add(result); - - var pair = GetOrAddPair(packagePairs, result.PackageKey); - pair.Snupkg = result; - } - - if (packValidationSettings.Value.RequireSymbolPackage) - { - foreach (var pair in packagePairs.Values) - { - if (pair.Nupkg is not null && pair.Snupkg is null) - { - pair.Nupkg.AddError( - $"Package '{pair.Nupkg.PackageId}' {pair.Nupkg.Version.ToNormalizedString()} has no matching .snupkg." - ); - } - - if (pair.Snupkg is not null && pair.Nupkg is null) - { - pair.Snupkg.AddError( - $"Symbol package '{pair.Snupkg.PackageId}' {pair.Snupkg.Version.ToNormalizedString()} has no matching .nupkg." - ); - } - } - } - - var invalid = results.Where(result => result.Errors.Count > 0).ToList(); - foreach (var result in results) - { - if (result.Errors.Count == 0) - { - context.Logger.LogInformation( - "Validated {FileName} ({Kind}): {PackageId} {Version}.", - result.FileName, - result.Kind, - result.PackageId, - result.Version.ToNormalizedString() - ); - } - else - { - foreach (var error in result.Errors) - context.Logger.LogError("{FileName}: {Error}", result.FileName, error); - } - } - - var validCount = results.Count - invalid.Count; - context.Summary.KeyValue("PackValidation", "Valid packages", $"{validCount}/{results.Count}"); - context.Summary.KeyValue("PackValidation", "Invalid packages", $"{invalid.Count}/{results.Count}"); - - if (invalid.Count > 0) - { - var detail = string.Join( - Environment.NewLine, - invalid.Select(result => - $" {result.FileName}:{Environment.NewLine} " - + string.Join(Environment.NewLine + " ", result.Errors) - ) - ); - - throw new InvalidOperationException( - $"Pack validation failed for {invalid.Count} of {results.Count} package(s):{Environment.NewLine}{detail}" - ); - } - - return results.ToArray(); - } - - static async Task ValidateNupkgAsync( - string packagePath, - PackValidationSettings settings, - CancellationToken cancellationToken - ) - { - var errors = new List(); - - try - { - using var reader = new PackageArchiveReader(packagePath); - var nuspec = await reader.GetNuspecReaderAsync(cancellationToken); - var id = nuspec.GetId(); - var version = nuspec.GetVersion(); - - ValidateFileName(packagePath, id, version, ".nupkg", errors); - - var files = reader.GetFiles().ToArray(); - ValidateNoPdbFiles(files, errors); - - var required = GetContentRule(settings.RequiredContent, id); - if (required is not null) - { - foreach (var entry in required) - { - if (!files.Contains(entry, StringComparer.OrdinalIgnoreCase)) - errors.Add($"Required content '{entry}' is missing from the package."); - } - } - - var forbidden = GetContentRule(settings.ForbiddenContent, id); - if (forbidden is not null) - { - foreach (var entry in forbidden) - { - if (files.Contains(entry, StringComparer.OrdinalIgnoreCase)) - errors.Add($"Forbidden content '{entry}' must not be in the package."); - } - } - - var result = new PackValidationResult( - Path.GetFileName(packagePath), - "nupkg", - CreatePackageKey(id, version), - id, - version - ); - result.AddErrors(errors); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var result = new PackValidationResult( - Path.GetFileName(packagePath), - "nupkg", - Path.GetFileName(packagePath) + "|unreadable", - "", - new NuGetVersion(0, 0, 0) - ); - result.AddError($"Failed to read package: {ex.Message}"); - return result; - } - } - - static async Task ValidateSnupkgAsync( - string packagePath, - PackValidationSettings settings, - CancellationToken cancellationToken - ) - { - var errors = new List(); - - try - { - using var reader = new PackageArchiveReader(packagePath); - var nuspec = await reader.GetNuspecReaderAsync(cancellationToken); - var id = nuspec.GetId(); - var version = nuspec.GetVersion(); - - ValidateFileName(packagePath, id, version, ".snupkg", errors); - - var files = reader.GetFiles().ToArray(); - var nonSymbolFiles = files.Where(file => !IsPdbFile(file) && !IsSymbolPackageMetadata(file)).ToArray(); - if (nonSymbolFiles.Length > 0) - errors.Add($"Symbol package contains non-symbol file(s): {string.Join(", ", nonSymbolFiles)}."); - - if (settings.RequireSymbolFiles && !files.Any(IsPdbFile)) - errors.Add("Symbol package contains no .pdb files."); - - var result = new PackValidationResult( - Path.GetFileName(packagePath), - "snupkg", - CreatePackageKey(id, version), - id, - version - ); - result.AddErrors(errors); - return result; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var result = new PackValidationResult( - Path.GetFileName(packagePath), - "snupkg", - Path.GetFileName(packagePath) + "|unreadable", - "", - new NuGetVersion(0, 0, 0) - ); - result.AddError($"Failed to read package: {ex.Message}"); - return result; - } - } - - static void ValidateFileName( - string packagePath, - string id, - NuGetVersion version, - string extension, - List errors - ) - { - var expected = $"{id}.{version.ToNormalizedString()}{extension}"; - if (!string.Equals(Path.GetFileName(packagePath), expected, StringComparison.OrdinalIgnoreCase)) - errors.Add( - $"File name '{Path.GetFileName(packagePath)}' does not match the nuspec id/version '{expected}'." - ); - } - - static void ValidateNoPdbFiles(IEnumerable files, List errors) - { - var pdbFiles = files.Where(IsPdbFile).ToArray(); - if (pdbFiles.Length > 0) - errors.Add( - $"Package contains PDB file(s): {string.Join(", ", pdbFiles)}. " - + "PDBs must only be delivered through the .snupkg." - ); - } - - static bool IsPdbFile(string path) => - string.Equals(Path.GetExtension(path), ".pdb", StringComparison.OrdinalIgnoreCase); - - static bool IsSymbolPackageMetadata(string path) => - string.Equals(path, "[Content_Types].xml", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("package/services/metadata/", StringComparison.OrdinalIgnoreCase) - || path.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase); - - static string[]? GetContentRule(Dictionary rules, string packageId) - { - if (rules.TryGetValue(packageId, out var exact)) - return exact; - - foreach (var rule in rules) - { - if (string.Equals(rule.Key, packageId, StringComparison.OrdinalIgnoreCase)) - return rule.Value; - } - - return null; - } - - static string CreatePackageKey(string id, NuGetVersion version) => $"{id}|{version.ToNormalizedString()}"; - - static PackagePair GetOrAddPair(Dictionary pairs, string key) - { - if (!pairs.TryGetValue(key, out var pair)) - { - pair = new PackagePair(); - pairs.Add(key, pair); - } - - return pair; - } -} - -public sealed class PackValidationResult -{ - readonly List _errors = []; - - public PackValidationResult(string fileName, string kind, string packageKey, string packageId, NuGetVersion version) - { - FileName = fileName; - Kind = kind; - PackageKey = packageKey; - PackageId = packageId; - Version = version; - } - - public string FileName { get; } - - public string Kind { get; } - - public string PackageKey { get; } - - public string PackageId { get; } - - public NuGetVersion Version { get; } - - public IReadOnlyList Errors => _errors; - - internal void AddError(string error) => _errors.Add(error); - - internal void AddErrors(IEnumerable errors) => _errors.AddRange(errors); -} - -sealed class PackagePair -{ - public PackValidationResult? Nupkg { get; set; } - - public PackValidationResult? Snupkg { get; set; } -} diff --git a/build/PipelineCLI/Modules/VersionModule.cs b/build/PipelineCLI/Modules/VersionModule.cs deleted file mode 100644 index 83d3631..0000000 --- a/build/PipelineCLI/Modules/VersionModule.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using ModularPipelines.Attributes; -using ModularPipelines.Context; -using ModularPipelines.Modules; -using NuGet.Versioning; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index 10d8460..0000000 --- a/build/PipelineCLI/PipelineCLI.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - PreserveNewest - - - diff --git a/build/PipelineCLI/PipelineProjectDirectory.cs b/build/PipelineCLI/PipelineProjectDirectory.cs deleted file mode 100644 index b89c24e..0000000 --- a/build/PipelineCLI/PipelineProjectDirectory.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index 1997d14..0000000 --- a/build/PipelineCLI/Program.cs +++ /dev/null @@ -1,46 +0,0 @@ -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(PackValidationSettings.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() - .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 deleted file mode 100644 index d5bbebe..0000000 --- a/build/PipelineCLI/Properties/launchSettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "profiles": { - "Run": { - "commandName": "Project" - }, - "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 deleted file mode 100644 index cfb27da..0000000 --- a/build/PipelineCLI/Settings/BuildSettings.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Purview.SourceGeneratorFramework.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; } = "/*/*/*/*/"; - - /// - /// Comma-separated list of test project file names (or glob patterns) to run. - /// Empty or "*" runs every test project under src/tests. - /// - public string TestProjects { get; init; } = "*"; - - public bool RunLint { get; init; } = true; - - public bool RunPack { get; init; } = true; - - public bool ValidatePack { get; init; } = true; -} diff --git a/build/PipelineCLI/Settings/GitHubSettings.cs b/build/PipelineCLI/Settings/GitHubSettings.cs deleted file mode 100644 index 0c1baa1..0000000 --- a/build/PipelineCLI/Settings/GitHubSettings.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ModularPipelines.Attributes; - -namespace Purview.SourceGeneratorFramework.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() - { - if (!string.IsNullOrWhiteSpace(AccessToken)) - return AccessToken; - - if (!string.IsNullOrWhiteSpace(EnvAccessToken)) - return EnvAccessToken; - - // GitHub Actions provisions the automatic GITHUB_TOKEN as a plain environment variable. - // The config binder keys it under the "GitHub" section (GitHub:GITHUB_TOKEN), which the - // standard GITHUB_TOKEN env var does not map to, so read it directly as a fallback. - var processToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - return string.IsNullOrWhiteSpace(processToken) ? null : processToken; - } -} diff --git a/build/PipelineCLI/Settings/NuGetSettings.cs b/build/PipelineCLI/Settings/NuGetSettings.cs deleted file mode 100644 index 1ccac86..0000000 --- a/build/PipelineCLI/Settings/NuGetSettings.cs +++ /dev/null @@ -1,22 +0,0 @@ -using ModularPipelines.Attributes; - -namespace Purview.SourceGeneratorFramework.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/PackValidationSettings.cs b/build/PipelineCLI/Settings/PackValidationSettings.cs deleted file mode 100644 index 7f1b493..0000000 --- a/build/PipelineCLI/Settings/PackValidationSettings.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Purview.SourceGeneratorFramework.PipelineCLI.Settings; - -public sealed record PackValidationSettings -{ - public const string SectionName = "PackValidation"; - - /// - /// Every .nupkg must have a matching .snupkg (same id/version) and vice versa. - /// - public bool RequireSymbolPackage { get; init; } = true; - - /// - /// Every .snupkg must contain at least one .pdb file. - /// - public bool RequireSymbolFiles { get; init; } = true; - - /// - /// Package id (case-insensitive) to entry paths that MUST be present in the .nupkg. - /// Entry paths use forward slashes, e.g. "lib/netstandard2.0/Foo.dll". - /// - public Dictionary RequiredContent { get; init; } = []; - - /// - /// Package id (case-insensitive) to entry paths that MUST NOT be present in the .nupkg. - /// Entry paths use forward slashes, e.g. "lib/netstandard2.0/Foo.dll". - /// - public Dictionary ForbiddenContent { get; init; } = []; -} diff --git a/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs b/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs deleted file mode 100644 index 4d0256f..0000000 --- a/build/PipelineCLI/Settings/PublishLocalNuGetSettings.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Purview.SourceGeneratorFramework.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 deleted file mode 100644 index 7cf2acc..0000000 --- a/build/PipelineCLI/Settings/ReleaseSettings.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Purview.SourceGeneratorFramework.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/purview-build.json similarity index 71% rename from build/PipelineCLI/appsettings.json rename to purview-build.json index 50927c2..286a5c3 100644 --- a/build/PipelineCLI/appsettings.json +++ b/purview-build.json @@ -1,14 +1,9 @@ { "Build": { "Solution": "src/SourceGeneratorFramework.slnx", - "Configuration": "Release", - "ArtifactsFolder": "artifacts", - "RunTests": true, - "TestFilter": "/*/*/*/*/", - "TestProjects": "*", - "RunLint": true, - "RunPack": true, - "ValidatePack": true + "TestRoot": "src/tests", + "TestPatterns": "*Tests.csproj", + "TestFilter": "/*/*/*/*/" }, "PackValidation": { "RequireSymbolPackage": true, @@ -37,20 +32,7 @@ }, "ForbiddenContent": {} }, - "NuGet": { - "FeedUrl": "https://api.nuget.org/v3/index.json" - }, - "PublishLocalNuGet": { - "LocalFeedPath": "", - "OverwriteExistingPackages": true, - "ShutdownDotnetBuilderServer": true, - "ClearPackageCache": true - }, - "GitHub": { - "AccessToken": null, - "ProductHeader": "Purview.SourceGeneratorFramework.Pipeline" - }, "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 9fab643..5595ecf 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -8,6 +8,8 @@ PSGFR12 | Purview.SourceGeneratorFramework | Warning | Use IIncrementalGenerator PSGFR14 | Purview.SourceGeneratorFramework | Warning | Avoid RegisterImplementationSourceOutput PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection lacks sequence equality PSGFR16 | Purview.SourceGeneratorFramework | Info | Prefer the nullable-context overload +PSGFR17 | Purview.SourceGeneratorFramework | Warning | Consume CodeWriter scopes with a using statement +PSGFR18 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWriter declaration API ADM0001 | Target | Error | Target attribute type cannot be resolved ADM0002 | Property | Error | Property type is not supported for attribute extraction ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute diff --git a/src/src/SourceGeneratorFramework.Analyzers/DiscardedCodeWriterScopeAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/DiscardedCodeWriterScopeAnalyzer.cs new file mode 100644 index 0000000..46972e8 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/DiscardedCodeWriterScopeAnalyzer.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags CodeWriter scope-returning methods whose returned scope is discarded, which skips the +/// block's closing token and can unbalance indentation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DiscardedCodeWriterScopeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR17"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Consume CodeWriter scopes with a using statement", + "The scope returned by {0} is discarded; assign it to a using statement so the closing token is written", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Scope-returning CodeWriter methods must be consumed by a using statement so the generated block is closed correctly." + ); + + 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.RegisterSyntaxNodeAction(AnalyzeExpressionStatement, SyntaxKind.ExpressionStatement); + } + + static void AnalyzeExpressionStatement(SyntaxNodeAnalysisContext context) + { + if (context.Node is not ExpressionStatementSyntax { Expression: InvocationExpressionSyntax invocation }) + return; + + if ( + context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol + is not IMethodSymbol method + ) + return; + + if (method.ContainingType?.ToDisplayString() != "Purview.SourceGeneratorFramework.CodeWriter") + return; + + var returnType = method.ReturnType.ToDisplayString(); + if ( + returnType + is not ( + "Purview.SourceGeneratorFramework.CodeWriter.BlockScope" + or "Purview.SourceGeneratorFramework.CodeWriter.IndentScope" + ) + ) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), method.Name)); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs new file mode 100644 index 0000000..013f302 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterApiAnalyzer.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags raw CodeWriter text emission that starts with a C# declaration keyword, suggesting a +/// structured declaration API such as WriteClass or WriteProperty instead. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PreferStructuredCodeWriterApiAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR18"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Prefer a structured CodeWriter declaration API", + "'{0}' with a declaration should use a structured API such as WriteClass, WriteMethod, WriteProperty, or WriteField", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Emitting declaration syntax through raw text bypasses the structured, deterministic declaration APIs on CodeWriter." + ); + + static readonly string[] DeclarationStarts = + [ + "public ", + "internal ", + "private ", + "protected ", + "file ", + "static ", + "sealed ", + "abstract ", + "partial ", + "readonly ", + "ref ", + "required ", + "const ", + "class ", + "struct ", + "interface ", + "enum ", + "record ", + "delegate ", + "namespace ", + "global using ", + "using ", + ]; + + 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.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + if ( + context.Node + is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax member } invocation + ) + return; + + var name = member.Name.Identifier.Text; + if (name is not ("Write" or "WriteLine" or "Append" or "AppendLine" or "MultiLine")) + return; + + if (invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not LiteralExpressionSyntax literal) + return; + + if (!literal.IsKind(SyntaxKind.StringLiteralExpression)) + return; + + if ( + context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol + is not IMethodSymbol method + ) + return; + + if (method.ContainingType?.ToDisplayString() != "Purview.SourceGeneratorFramework.CodeWriter") + return; + + var value = literal.Token.ValueText.TrimStart(); + if (StartsWithDeclaration(value)) + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), name)); + } + + static bool StartsWithDeclaration(string value) + { + foreach (var prefix in DeclarationStarts) + { + if (!value.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + // "using (var x = ...)" is a using statement inside a body, not a directive; don't flag it. + if (prefix == "using " && value.StartsWith("using (", StringComparison.Ordinal)) + return false; + + // "namespace global::" is a valid namespace declaration, but "global::" is not a declaration keyword. + return true; + } + + return false; + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs index bf1b9d0..0392b96 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs @@ -13,7 +13,9 @@ namespace Purview.SourceGeneratorFramework.ExampleGenerator.CodeFixers; [ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(LoggingRefactoringProvider))] public sealed class LoggingRefactoringProvider : CodeRefactoringProvider { - /// The equivalence key of the registered code action. + /// + /// The equivalence key of the registered code action. + /// public const string EquivalenceKey = "AddDebug"; public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs index 87eb4a6..7886c67 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs @@ -5,22 +5,34 @@ namespace Purview.SourceGeneratorFramework.Examples; /// public enum LogLevel { - /// Trace-level detail. + /// + /// Trace-level detail. + /// Trace = 0, - /// Debug-level detail. + /// + /// Debug-level detail. + /// Debug = 1, - /// Informational messages. + /// + /// Informational messages. + /// Information = 2, - /// Warnings. + /// + /// Warnings. + /// Warning = 3, - /// Errors. + /// + /// Errors. + /// Error = 4, - /// Critical failures. + /// + /// Critical failures. + /// Critical = 5, } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs index 146a602..f718b26 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs @@ -15,7 +15,9 @@ public static partial class CodeQueryAssertions // Generated code (source generators) // --------------------------------------------------------------------------------------------- - /// Asserts that the generated code contains a method with the given name, returning it. + /// + /// Asserts that the generated code contains a method with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedMethod( @@ -23,7 +25,9 @@ public static AssertionResult HasGeneratedMethod( string methodName ) => GetMethod(result?.Generated(), methodName, null, "generated code"); - /// Asserts that the generated code contains a method with the given name and parameter types, returning it. + /// + /// Asserts that the generated code contains a method with the given name and parameter types, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedMethod( @@ -32,7 +36,9 @@ public static AssertionResult HasGeneratedMethod( TypeReference[] parameters ) => GetMethod(result?.Generated(), methodName, parameters, "generated code"); - /// Asserts that the generated code contains a method with the given name and return type, returning it. + /// + /// Asserts that the generated code contains a method with the given name and return type, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedMethodReturnType( @@ -58,7 +64,9 @@ TypeReference returnType ); } - /// Asserts that the generated code contains a class with the given name, returning it. + /// + /// Asserts that the generated code contains a class with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedClass( @@ -81,7 +89,9 @@ string className AssertionResult.Failed($"generated code did not contain a class named '{className}'"); } - /// Asserts that the generated code contains a property with the given name, returning it. + /// + /// Asserts that the generated code contains a property with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedProperty( @@ -105,7 +115,9 @@ string propertyName AssertionResult.Failed($"generated code did not contain a property named '{propertyName}'"); } - /// Asserts that the generated code contains a field with the given name, returning it. + /// + /// Asserts that the generated code contains a field with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedField( @@ -128,7 +140,9 @@ string fieldName AssertionResult.Failed($"generated code did not contain a field named '{fieldName}'"); } - /// Asserts that the generated code contains a syntax tree with the given name, returning it. + /// + /// Asserts that the generated code contains a syntax tree with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasGeneratedSyntaxTree(this DriverRunResult result, string treeName) @@ -151,7 +165,9 @@ public static AssertionResult HasGeneratedSyntaxTree(this DriverRunR // Fixed code (code fixes and refactorings) // --------------------------------------------------------------------------------------------- - /// Asserts that the fixed code contains a method with the given name, returning it. + /// + /// Asserts that the fixed code contains a method with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasFixedMethod( @@ -159,7 +175,9 @@ public static AssertionResult HasFixedMethod( string methodName ) => GetMethod(result?.FixedCode(), methodName, null, "fixed code"); - /// Asserts that the fixed code contains a method with the given name and parameter types, returning it. + /// + /// Asserts that the fixed code contains a method with the given name and parameter types, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasFixedMethod( @@ -168,7 +186,9 @@ public static AssertionResult HasFixedMethod( TypeReference[] parameters ) => GetMethod(result?.FixedCode(), methodName, parameters, "fixed code"); - /// Asserts that the fixed code contains a method with the given name, returning it. + /// + /// Asserts that the fixed code contains a method with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasFixedMethod( @@ -176,7 +196,9 @@ public static AssertionResult HasFixedMethod( string methodName ) => GetMethod(result?.FixedCode(), methodName, null, "fixed code"); - /// Asserts that the fixed code contains a method with the given name, returning it. + /// + /// Asserts that the fixed code contains a method with the given name, returning it. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasFixedMethod( diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs index d2ca40b..bd8ab8f 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/DiagnosticAssertions.cs @@ -266,7 +266,9 @@ public static AssertionResult HasNoDiagnostics(this DriverRunResult result) ); } - /// Asserts that an analyzer result contains the expected diagnostic. + /// + /// Asserts that an analyzer result contains the expected diagnostic. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasDiagnostic( @@ -274,7 +276,9 @@ public static AssertionResult HasDiagnostic( DiagnosticDescriptor expected ) => HasDiagnostic(diagnostic?.Diagnostics, expected); - /// Asserts that an analyzer result contains the expected total number of diagnostics. + /// + /// Asserts that an analyzer result contains the expected total number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -282,7 +286,9 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, count); - /// Asserts that an analyzer result contains the expected number of diagnostics. + /// + /// Asserts that an analyzer result contains the expected number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -291,13 +297,17 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); - /// Asserts that an analyzer result contains the expected diagnostic. + /// + /// 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. + /// + /// Asserts that an analyzer result contains the expected number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -306,7 +316,9 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); - /// Asserts that an analyzer result does not contain the expected diagnostic. + /// + /// Asserts that an analyzer result does not contain the expected diagnostic. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult DoesNotHaveDiagnostic( @@ -314,13 +326,17 @@ public static AssertionResult DoesNotHaveDiagnostic( DiagnosticDescriptor expected ) => DoesNotHaveDiagnostic(result?.Diagnostics, expected); - /// Asserts that an analyzer result does not contain the expected diagnostic. + /// + /// 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. + /// + /// Asserts that an analyzer result does not contain a diagnostic with the specified prefix. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( @@ -328,19 +344,25 @@ public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( string startsWithValue ) => DoesNotHaveDiagnosticThatStartsWith(result?.Diagnostics, startsWithValue); - /// Asserts that an analyzer result does not contain error diagnostics. + /// + /// 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. + /// + /// 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. + /// + /// Asserts that a code-fix result contains the expected diagnostic. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasDiagnostic( @@ -348,7 +370,9 @@ public static AssertionResult HasDiagnostic( DiagnosticDescriptor expected ) => HasDiagnostic(diagnostic?.Diagnostics, expected); - /// Asserts that a code-fix result contains the expected total number of diagnostics. + /// + /// Asserts that a code-fix result contains the expected total number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -356,7 +380,9 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, count); - /// Asserts that a code-fix result contains the expected number of diagnostics. + /// + /// Asserts that a code-fix result contains the expected number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -365,13 +391,17 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); - /// Asserts that a code-fix result contains the expected diagnostic. + /// + /// 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. + /// + /// Asserts that a code-fix result contains the expected number of diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult> HasDiagnostics( @@ -380,19 +410,25 @@ public static AssertionResult> HasDiagnostics( int count ) => HasDiagnostics(diagnostic?.Diagnostics, expected, count); - /// Asserts that a code-fix result does not contain the expected diagnostic. + /// + /// 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. + /// + /// 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. + /// + /// Asserts that a code-fix result does not contain a diagnostic with the specified prefix. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( @@ -400,13 +436,17 @@ public static AssertionResult DoesNotHaveDiagnosticThatStartsWith( string startsWithValue ) => DoesNotHaveDiagnosticThatStartsWith(result?.Diagnostics, startsWithValue); - /// Asserts that a code-fix result does not contain error diagnostics. + /// + /// 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. + /// + /// Asserts that a code-fix result does not contain diagnostics. + /// [GenerateAssertion] [EditorBrowsable(EditorBrowsableState.Never)] public static AssertionResult HasNoDiagnostics(this CodeFixTestResult result) => diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs index 85ecc28..29cd7f5 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs @@ -3,13 +3,17 @@ namespace Purview.SourceGeneratorFramework.Testing.TUnit; -/// TUnit-specific base class for analyzer and code fix tests. +/// +/// 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. +/// +/// TUnit-specific base class for analyzer and code fix tests. +/// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Design", "CA1005:Avoid excessive parameters on generic types", @@ -22,25 +26,33 @@ public abstract class TUnitCodeFixTestBase { readonly CodeFixTestRunner _runner = new(); - /// Runs the analyzer and applies the selected code fix. + /// + /// 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. + /// + /// 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); - /// Runs the analyzer and applies the code fix to every diagnostic in the project. + /// + /// Runs the analyzer and applies the code fix to every diagnostic in the project. + /// protected Task ApplyFixAllAsync( IEnumerable sources, TOptions? options = null, CancellationToken cancellationToken = default ) => _runner.RunFixAllAsync(sources, options ?? new(), cancellationToken); - /// Runs the analyzer and applies the code fix to every diagnostic in the project. + /// + /// Runs the analyzer and applies the code fix to every diagnostic in the project. + /// protected Task ApplyFixAllAsync( string source, TOptions? options = null, diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs index 46f4393..6b3ee1c 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitDiagnosticAnalyzerTestBase.cs @@ -2,12 +2,16 @@ namespace Purview.SourceGeneratorFramework.Testing.TUnit; -/// TUnit-specific base class for diagnostic analyzer tests. +/// +/// 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. +/// +/// 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.TUnit/TUnitRefactoringTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs index d915801..4dea1db 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs @@ -2,23 +2,31 @@ namespace Purview.SourceGeneratorFramework.Testing.TUnit; -/// TUnit-specific base class for refactoring tests. +/// +/// TUnit-specific base class for refactoring tests. +/// public abstract class TUnitRefactoringTestBase : TUnitRefactoringTestBase where TRefactoring : CodeRefactoringProvider, new(); -/// TUnit-specific base class for refactoring tests. +/// +/// TUnit-specific base class for refactoring tests. +/// public abstract class TUnitRefactoringTestBase where TRefactoring : CodeRefactoringProvider, new() where TOptions : RefactorTestOptions, new() { readonly RefactoringTestRunner _runner = new(); - /// Runs the refactoring against the supplied source. + /// + /// Runs the refactoring against the supplied source. + /// protected Task RefactorAsync(string source, CancellationToken cancellationToken = default) => RefactorAsync(source, null!, cancellationToken); - /// Runs the refactoring against the supplied source using the supplied options. + /// + /// Runs the refactoring against the supplied source using the supplied options. + /// protected Task RefactorAsync( string source, TOptions options, diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs index 142405b..39b088b 100644 --- a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestBase.cs @@ -2,12 +2,16 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Framework-agnostic base class for diagnostic analyzer tests. +/// +/// 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. +/// +/// Framework-agnostic base class for diagnostic analyzer tests. +/// /// The type of diagnostic analyzer. /// The type of test options. public abstract class AnalyzerTestBase @@ -16,24 +20,32 @@ public abstract class AnalyzerTestBase { readonly DiagnosticAnalyzerTestRunner _runner = new(); - /// Runs the analyzer against the supplied source. + /// + /// 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. + /// + /// 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. + /// + /// 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. + /// + /// Runs the analyzer against the supplied sources and options. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Performance", "CA1849:Call async methods when in an async method" @@ -59,21 +71,27 @@ protected async Task AnalyzeAsync( return result; } - /// Called before the analyzer is run. + /// + /// Called before the analyzer is run. + /// protected virtual TOptions OnBeforeRun( IEnumerable sources, TOptions options, CancellationToken cancellationToken ) => options; - /// Called asynchronously before the analyzer is run. + /// + /// 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. + /// + /// Called after the analyzer is run. + /// protected virtual void OnAfterRun( AnalyzerTestResult result, IEnumerable sources, @@ -84,7 +102,9 @@ CancellationToken cancellationToken // No-op by default. } - /// Called asynchronously after the analyzer is run. + /// + /// Called asynchronously after the analyzer is run. + /// protected virtual Task OnAfterRunAsync( AnalyzerTestResult result, IEnumerable sources, diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs index fdfa9f8..3eecddd 100644 --- a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestOptions.cs @@ -1,15 +1,25 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Options that configure a diagnostic analyzer test run. +/// +/// Options that configure a diagnostic analyzer test run. +/// public record AnalyzerTestOptions : SourceGeneratorTestOptions; -/// Options that configure a code fix test run. +/// +/// Options that configure a code fix test run. +/// public record CodeFixTestOptions : AnalyzerTestOptions { - /// Gets the index of the registered code action to apply. + /// + /// 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 . + /// + /// 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 index 0113df3..27a06a9 100644 --- a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs @@ -4,10 +4,14 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// The result of a diagnostic analyzer test run. +/// +/// The result of a diagnostic analyzer test run. +/// public sealed record AnalyzerTestResult(ImmutableArray Diagnostics, Compilation Compilation); -/// The result of a code fix test run. +/// +/// The result of a code fix test run. +/// public sealed record CodeFixTestResult( ImmutableArray Diagnostics, ImmutableArray CodeActions, @@ -16,7 +20,9 @@ public sealed record CodeFixTestResult( Solution? ChangedSolution = null ); -/// The result of a fix-all code fix test run. +/// +/// The result of a fix-all code fix test run. +/// public sealed record CodeFixFixAllResult( ImmutableArray Diagnostics, ImmutableArray CodeActions, diff --git a/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs index d0e78ca..96a8741 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs @@ -6,12 +6,16 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Executes an analyzer and applies one code fix to a test document. +/// +/// 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. + /// + /// Runs the analyzer and applies a registered code action. + /// public async Task RunAsync( string source, CodeFixTestOptions? options = null, diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs index c4037bf..80e7cae 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs @@ -9,7 +9,9 @@ public sealed partial class CodeQuery // Methods // --------------------------------------------------------------------------------------------- - /// Gets a method declaration by name, optionally matching its parameter types. + /// + /// Gets a method declaration by name, optionally matching its parameter types. + /// /// No method matched. public MethodDeclarationSyntax GetMethod(string name, params TypeReference[]? parameters) => TryGetMethod(name, out var method, parameters) @@ -18,10 +20,14 @@ public MethodDeclarationSyntax GetMethod(string name, params TypeReference[]? pa $"No method named '{name}' was found in the {ScopeDescription()}{(parameters is { Length: > 0 } ? " with the specified parameters" : "")}." ); - /// Determines whether a method declaration with the given name, optionally matching parameter types, exists. + /// + /// Determines whether a method declaration with the given name, optionally matching parameter types, exists. + /// public bool HasMethod(string name, params TypeReference[]? parameters) => TryGetMethod(name, out _, parameters); - /// Attempts to get a method declaration by name, optionally matching its parameter types. + /// + /// Attempts to get a method declaration by name, optionally matching its parameter types. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public bool TryGetMethod(string name, out MethodDeclarationSyntax? method, params TypeReference[]? parameters) { @@ -41,62 +47,92 @@ out method // Type declarations // --------------------------------------------------------------------------------------------- - /// Gets a type declaration (class, struct, interface, record, enum or delegate) by name. + /// + /// Gets a type declaration (class, struct, interface, record, enum or delegate) by name. + /// public MemberDeclarationSyntax GetTypeDeclaration(string name, string? @namespace = null) => Get(node => IsTypeDeclarationMatch(node, name) && NamespaceMatches(node, @namespace)); - /// Determines whether a type declaration with the given name exists. + /// + /// Determines whether a type declaration with the given name exists. + /// public bool HasTypeDeclaration(string name, string? @namespace = null) => Has(node => IsTypeDeclarationMatch(node, name) && NamespaceMatches(node, @namespace)); - /// Gets a class declaration by name, optionally within a namespace. + /// + /// Gets a class declaration by name, optionally within a namespace. + /// public ClassDeclarationSyntax GetClass(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether a class declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether a class declaration with the given name exists, optionally within a namespace. + /// public bool HasClass(string name, string? @namespace = null) => HasByName(name, @namespace); - /// Attempts to get a class declaration by name, optionally within a namespace. + /// + /// Attempts to get a class declaration by name, optionally within a namespace. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public bool TryGetClass(string name, out ClassDeclarationSyntax? declaration, string? @namespace = null) => TryFindByName(name, out declaration, @namespace); - /// Gets a struct declaration by name, optionally within a namespace. + /// + /// Gets a struct declaration by name, optionally within a namespace. + /// public StructDeclarationSyntax GetStruct(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether a struct declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether a struct declaration with the given name exists, optionally within a namespace. + /// public bool HasStruct(string name, string? @namespace = null) => HasByName(name, @namespace); - /// Gets an interface declaration by name, optionally within a namespace. + /// + /// Gets an interface declaration by name, optionally within a namespace. + /// public InterfaceDeclarationSyntax GetInterface(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether an interface declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether an interface declaration with the given name exists, optionally within a namespace. + /// public bool HasInterface(string name, string? @namespace = null) => HasByName(name, @namespace); - /// Gets an enum declaration by name, optionally within a namespace. + /// + /// Gets an enum declaration by name, optionally within a namespace. + /// public EnumDeclarationSyntax GetEnum(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether an enum declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether an enum declaration with the given name exists, optionally within a namespace. + /// public bool HasEnum(string name, string? @namespace = null) => HasByName(name, @namespace); - /// Gets a delegate declaration by name, optionally within a namespace. + /// + /// Gets a delegate declaration by name, optionally within a namespace. + /// public DelegateDeclarationSyntax GetDelegate(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether a delegate declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether a delegate declaration with the given name exists, optionally within a namespace. + /// public bool HasDelegate(string name, string? @namespace = null) => HasByName(name, @namespace); - /// Gets a record declaration by name, optionally within a namespace. + /// + /// Gets a record declaration by name, optionally within a namespace. + /// public RecordDeclarationSyntax GetRecord(string name, string? @namespace = null) => FindByName(name, @namespace); - /// Determines whether a record declaration with the given name exists, optionally within a namespace. + /// + /// Determines whether a record declaration with the given name exists, optionally within a namespace. + /// public bool HasRecord(string name, string? @namespace = null) => HasByName(name, @namespace); @@ -104,30 +140,44 @@ public bool HasRecord(string name, string? @namespace = null) => // Members // --------------------------------------------------------------------------------------------- - /// Gets a property declaration by name. + /// + /// Gets a property declaration by name. + /// public PropertyDeclarationSyntax GetProperty(string name) => TryGetProperty(name, out var property) ? property! : throw new SyntaxNotFoundException($"No property named '{name}' was found in the {ScopeDescription()}."); - /// Determines whether a property declaration with the given name exists. + /// + /// Determines whether a property declaration with the given name exists. + /// public bool HasProperty(string name) => TryGetProperty(name, out _); - /// Attempts to get a property declaration by name. + /// + /// Attempts to get a property declaration by name. + /// public bool TryGetProperty(string name, out PropertyDeclarationSyntax? property) => TryFindByName(name, out property); - /// Gets a field declaration by name. - /// Finds a by identifier and returns its declaring field. + /// + /// Gets a field declaration by name. + /// + /// + /// Finds a by identifier and returns its declaring field. + /// public FieldDeclarationSyntax GetField(string name) => TryGetField(name, out var field) ? field! : throw new SyntaxNotFoundException($"No field named '{name}' was found in the {ScopeDescription()}."); - /// Determines whether a field declaration with the given name exists. + /// + /// Determines whether a field declaration with the given name exists. + /// public bool HasField(string name) => TryGetField(name, out _); - /// Attempts to get a field declaration by name. + /// + /// Attempts to get a field declaration by name. + /// public bool TryGetField(string name, out FieldDeclarationSyntax? field) { if (string.IsNullOrWhiteSpace(name)) @@ -135,7 +185,7 @@ public bool TryGetField(string name, out FieldDeclarationSyntax? field) foreach (var tree in Trees) { - foreach (var declarator in tree.GetRoot().DescendantNodes().OfType()) + foreach (var declarator in RootOf(tree).DescendantNodes().OfType()) { if (declarator.Identifier.ValueText != name) continue; @@ -152,15 +202,21 @@ public bool TryGetField(string name, out FieldDeclarationSyntax? field) return false; } - /// Gets a constructor declaration by the name of its containing type. + /// + /// Gets a constructor declaration by the name of its containing type. + /// public ConstructorDeclarationSyntax GetConstructor(string containingTypeName) => FindByName(containingTypeName); - /// Determines whether a constructor declaration for the given containing type exists. + /// + /// Determines whether a constructor declaration for the given containing type exists. + /// public bool HasConstructor(string containingTypeName) => HasByName(containingTypeName); - /// Gets a namespace declaration (block or file-scoped) by its dotted name. + /// + /// Gets a namespace declaration (block or file-scoped) by its dotted name. + /// public BaseNamespaceDeclarationSyntax GetNamespace(string name) => FindByName( name, @@ -168,7 +224,9 @@ public BaseNamespaceDeclarationSyntax GetNamespace(string name) => namespaceDeclaration => namespaceDeclaration.Name.ToString() ); - /// Determines whether a namespace declaration with the given dotted name exists. + /// + /// Determines whether a namespace declaration with the given dotted name exists. + /// public bool HasNamespace(string name) => HasByName( name, @@ -227,7 +285,7 @@ bool TryFind(string name, Func getName, Func? additional, { foreach (var tree in Trees) { - foreach (var candidate in tree.GetRoot().DescendantNodes().OfType()) + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) { if (getName(candidate) != name) continue; diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Members.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Members.cs new file mode 100644 index 0000000..6c7ba95 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Members.cs @@ -0,0 +1,226 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +public sealed partial class CodeQuery +{ + // --------------------------------------------------------------------------------------------- + // Operators + // --------------------------------------------------------------------------------------------- + + /// + /// Gets the first operator declaration matching the given token, such as == or implicit. + /// + /// No operator matched. + public OperatorDeclarationSyntax GetOperator(string operatorToken) => + TryGetOperator(operatorToken, out var @operator) + ? @operator! + : throw new SyntaxNotFoundException( + $"No operator '{operatorToken}' was found in the {ScopeDescription()}." + ); + + /// + /// Determines whether an operator declaration with the given token exists. + /// + public bool HasOperator(string operatorToken) => TryGetOperator(operatorToken, out _); + + /// + /// Attempts to get the first operator declaration matching the given token. + /// + public bool TryGetOperator(string operatorToken, out OperatorDeclarationSyntax? @operator) + { + if (string.IsNullOrWhiteSpace(operatorToken)) + throw new ArgumentException("The operator token cannot be null or whitespace.", nameof(operatorToken)); + + foreach (var tree in Trees) + { + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) + { + if (candidate.OperatorToken.ValueText == operatorToken) + { + @operator = candidate; + return true; + } + } + } + + @operator = null; + return false; + } + + /// + /// Gets the first conversion operator matching the given keyword, such as implicit or explicit. + /// + /// No conversion operator matched. + public ConversionOperatorDeclarationSyntax GetConversionOperator(string keyword) => + TryGetConversionOperator(keyword, out var conversion) + ? conversion! + : throw new SyntaxNotFoundException( + $"No '{keyword}' conversion operator was found in the {ScopeDescription()}." + ); + + /// + /// Determines whether a conversion operator with the given keyword exists. + /// + public bool HasConversionOperator(string keyword) => TryGetConversionOperator(keyword, out _); + + /// + /// Attempts to get the first conversion operator matching the given keyword. + /// + public bool TryGetConversionOperator(string keyword, out ConversionOperatorDeclarationSyntax? conversion) + { + if (keyword is not ("implicit" or "explicit")) + throw new ArgumentException("The keyword must be 'implicit' or 'explicit'.", nameof(keyword)); + + foreach (var tree in Trees) + { + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) + { + if (candidate.ImplicitOrExplicitKeyword.ValueText == keyword) + { + conversion = candidate; + return true; + } + } + } + + conversion = null; + return false; + } + + // --------------------------------------------------------------------------------------------- + // Indexers + // --------------------------------------------------------------------------------------------- + + /// + /// Gets an indexer declaration whose parameters match the given types. + /// + /// No indexer matched. + public IndexerDeclarationSyntax GetIndexer(params TypeReference[] parameters) => + TryGetIndexer(out var indexer, parameters) + ? indexer! + : throw new SyntaxNotFoundException($"No indexer was found in the {ScopeDescription()}."); + + /// + /// Determines whether an indexer declaration with the given parameter types exists. + /// + public bool HasIndexer(params TypeReference[] parameters) => TryGetIndexer(out _, parameters); + + /// + /// Attempts to get an indexer declaration whose parameters match the given types. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public bool TryGetIndexer(out IndexerDeclarationSyntax? indexer, params TypeReference[] parameters) + { + var expected = parameters ?? []; + foreach (var tree in Trees) + { + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) + { + if (expected.Length == 0 || MatchesParameters(candidate.ParameterList, expected)) + { + indexer = candidate; + return true; + } + } + } + + indexer = null; + return false; + } + + bool MatchesParameters(BracketedParameterListSyntax parameterList, TypeReference[] expected) + { + var parameters = parameterList.Parameters; + if (parameters.Count != expected.Length) + return false; + + for (var index = 0; index < parameters.Count; index++) + { + var typeSyntax = parameters[index].Type; + if (typeSyntax is null || !Matches(typeSyntax, expected[index])) + return false; + } + + return true; + } + + // --------------------------------------------------------------------------------------------- + // Attributes + // --------------------------------------------------------------------------------------------- + + /// + /// Gets all attribute applications on or within the given node. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] + public ImmutableArray GetAttributes(SyntaxNode node) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + + // Note: This intentionally returns attributes on the node itself and any nested nodes, such as parameters. + return [.. node.DescendantNodes().OfType()]; + } + + /// + /// Determines whether the given node has an attribute with the specified name. + /// + /// + /// The name may be supplied with or without the Attribute suffix. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] + public bool HasAttribute(SyntaxNode node, string name) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The attribute name cannot be null or whitespace.", nameof(name)); + + foreach (var attribute in node.DescendantNodes().OfType()) + { + if (MatchesAttributeName(attribute, name)) + return true; + } + + return false; + } + + /// + /// Gets the first attribute with the specified name, or . + /// + /// + /// The name may be supplied with or without the Attribute suffix. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] + public AttributeSyntax? GetAttribute(SyntaxNode node, string name) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The attribute name cannot be null or whitespace.", nameof(name)); + + foreach (var attribute in node.DescendantNodes().OfType()) + { + if (MatchesAttributeName(attribute, name)) + return attribute; + } + + return null; + } + + static bool MatchesAttributeName(AttributeSyntax attribute, string name) + { + var rendered = attribute.Name.ToString(); + if (string.Equals(rendered, name, StringComparison.Ordinal)) + return true; + + // The attribute name may be supplied with or without the "Attribute" suffix, and may be fully qualified. + return name.EndsWith("Attribute", StringComparison.Ordinal) + ? string.Equals(rendered, name, StringComparison.Ordinal) + : string.Equals(rendered, name + "Attribute", StringComparison.Ordinal) + || string.Equals(rendered, $"global::{name}", StringComparison.Ordinal) + || string.Equals(rendered, $"global::{name}Attribute", StringComparison.Ordinal); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs index 01b6f37..7195e5c 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Purview.SourceGeneratorFramework.Testing; @@ -34,7 +35,9 @@ public bool HasParameters(BaseMethodDeclarationSyntax method, params TypeReferen return true; } - /// Determines whether a method declaration's return type matches the given reference. + /// + /// Determines whether a method declaration's return type matches the given reference. + /// public bool HasReturnType(string methodName, TypeReference returnType) { if (string.IsNullOrWhiteSpace(methodName)) @@ -79,6 +82,21 @@ public bool Matches(TypeSyntax typeSyntax, TypeReference reference) [EditorBrowsable(EditorBrowsableState.Never)] public static class CodeQuerySignatureExtensions { + /// + /// Creates a nested query scoped to this node, enabling chained searches such as + /// query.GetClass("C").Query(query).GetMethod("M"). + /// + public static CodeQuery Query(this SyntaxNode node, CodeQuery parent) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + if (parent is null) + throw new ArgumentNullException(nameof(parent)); + + // Delegate to the parent query's In method, which creates a new query scoped to the given node. + return parent.In(node); + } + /// /// Determines whether the method's or constructor's parameters match the given types, resolved through the /// query's compilation. diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Statements.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Statements.cs new file mode 100644 index 0000000..88b6205 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Statements.cs @@ -0,0 +1,139 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +public sealed partial class CodeQuery +{ + // --------------------------------------------------------------------------------------------- + // Statements + // --------------------------------------------------------------------------------------------- + + /// + /// Gets the first foreach statement, optionally matching its iterator text. + /// + /// No statement matched. + public ForEachStatementSyntax GetForeach(string? iterator = null) => + Get(statement => iterator is null || statement.Expression.ToString() == iterator); + + /// + /// Determines whether a foreach statement exists, optionally matching its iterator text. + /// + public bool HasForeach(string? iterator = null) => + Has(statement => iterator is null || statement.Expression.ToString() == iterator); + + /// + /// Gets the first for statement. + /// + /// No statement matched. + public ForStatementSyntax GetFor() => Get(); + + /// + /// Determines whether a for statement exists. + /// + public bool HasFor() => Has(); + + /// + /// Gets the first while statement, optionally matching its condition. + /// + /// No statement matched. + public WhileStatementSyntax GetWhile(string? condition = null) => + Get(statement => condition is null || statement.Condition.ToString() == condition); + + /// + /// Determines whether a while statement exists, optionally matching its condition. + /// + public bool HasWhile(string? condition = null) => + Has(statement => condition is null || statement.Condition.ToString() == condition); + + /// + /// Gets the first if statement. + /// + /// No statement matched. + public IfStatementSyntax GetIf() => Get(); + + /// + /// Determines whether an if statement exists. + /// + public bool HasIf() => Has(); + + /// + /// Gets the first try statement. + /// + /// No statement matched. + public TryStatementSyntax GetTry() => Get(); + + /// + /// Determines whether a try statement exists. + /// + public bool HasTry() => Has(); + + /// + /// Gets the first invocation of a method with the given simple name. + /// + /// No invocation matched. + public InvocationExpressionSyntax GetInvocation(string methodName) => + TryGetInvocation(methodName, out var invocation) + ? invocation! + : throw new SyntaxNotFoundException( + $"No invocation of '{methodName}' was found in the {ScopeDescription()}." + ); + + /// + /// Determines whether an invocation of a method with the given simple name exists. + /// + public bool HasInvocation(string methodName) => TryGetInvocation(methodName, out _); + + /// + /// Attempts to get the first invocation of a method with the given simple name. + /// + public bool TryGetInvocation(string methodName, out InvocationExpressionSyntax? invocation) + { + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("The method name cannot be null or whitespace.", nameof(methodName)); + + foreach (var tree in Trees) + { + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) + { + if (GetInvokedMethodName(candidate) == methodName) + { + invocation = candidate; + return true; + } + } + } + + invocation = null; + return false; + } + + /// + /// Determines whether an object-creation expression of the given type exists. + /// + public bool HasObjectCreation(TypeReference type) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + + foreach (var tree in Trees) + { + foreach (var candidate in RootOf(tree).DescendantNodes().OfType()) + { + if (Matches(candidate.Type, type)) + return true; + } + } + + return false; + } + + static string? GetInvokedMethodName(InvocationExpressionSyntax invocation) => + invocation.Expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + GenericNameSyntax generic => generic.Identifier.ValueText, + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText, + MemberBindingExpressionSyntax binding => binding.Name.Identifier.ValueText, + _ => null, + }; +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs index 698f5a2..3c6c0b6 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs @@ -12,33 +12,70 @@ namespace Purview.SourceGeneratorFramework.Testing; /// Every operation is synchronous: SyntaxTree.GetRoot() and /// Compilation.GetSemanticModel are lazy, so repeated queries over test-sized payloads are cheap. /// -/// Initializes a new query over the given trees. +/// +/// Initializes a new query over the given trees. +/// /// The trees to search. /// /// The compilation backing , used to resolve symbols for type matching. May be /// when only syntactic matching is required. /// /// Whether the trees represent generated code, used in error messages. +/// +/// An optional node that scopes every node search to its subtree, enabling nested queries such as +/// query.In(query.GetClass("C")).GetMethod("M"). +/// public sealed partial class CodeQuery( ImmutableArray trees, Compilation? compilation = null, - bool isGenerated = false + bool isGenerated = false, + SyntaxNode? root = null ) { - /// Gets the trees being searched. + /// + /// Gets the trees being searched. + /// public ImmutableArray Trees { get; } = trees.IsDefault ? [] : trees; - /// Gets the compilation backing the trees, when one is available. + /// + /// Gets the compilation backing the trees, when one is available. + /// public Compilation? Compilation { get; } = compilation; - /// Gets whether the trees represent generated code, used in error messages. + /// + /// Gets whether the trees represent generated code, used in error messages. + /// public bool IsGenerated { get; } = isGenerated; + /// + /// Gets the node that scopes node searches, when this is a nested query. + /// + public SyntaxNode? Root { get; } = root; + + /// + /// Creates a nested query scoped to , sharing this query's trees and compilation. + /// + /// The node whose subtree is searched. + /// A child query scoped to the node. + /// query.In(query.GetClass("OrderAggregate")).HasMethod("Handle", orderCreated); + public CodeQuery In(SyntaxNode root) + { + if (root is null) + throw new ArgumentNullException(nameof(root)); + + // Delegate to the constructor, which validates that the root is in one of the query's trees. + return new(Trees, Compilation, IsGenerated, root); + } + + SyntaxNode RootOf(SyntaxTree tree) => Root ?? tree.GetRoot(); + // --------------------------------------------------------------------------------------------- // Syntax trees // --------------------------------------------------------------------------------------------- - /// Gets the tree whose file path ends with or equals the given name. + /// + /// Gets the tree whose file path ends with or equals the given name. + /// /// No tree matched. public SyntaxTree GetSyntaxTree(string name) => TryGetSyntaxTree(name, out var tree) @@ -47,10 +84,14 @@ public SyntaxTree GetSyntaxTree(string name) => $"No syntax tree named '{name}' was found in the {ScopeDescription()}." ); - /// Determines whether a tree whose file path ends with or equals the given name exists. + /// + /// Determines whether a tree whose file path ends with or equals the given name exists. + /// public bool HasSyntaxTree(string name) => TryGetSyntaxTree(name, out _); - /// Attempts to get the tree whose file path ends with or equals the given name. + /// + /// Attempts to get the tree whose file path ends with or equals the given name. + /// public bool TryGetSyntaxTree(string name, out SyntaxTree? tree) { if (string.IsNullOrWhiteSpace(name)) @@ -76,23 +117,71 @@ public bool TryGetSyntaxTree(string name, out SyntaxTree? tree) // Generic // --------------------------------------------------------------------------------------------- - /// Gets the first syntax node of the specified type, optionally matching a predicate. + /// + /// Gets the first syntax node of the specified type, optionally matching a predicate. + /// /// No node matched. public T Get(Func? predicate = null) where T : SyntaxNode => TryGet(out var node, predicate) ? node! : throw NotFound(); - /// Determines whether a syntax node of the specified type exists, optionally matching a predicate. + /// + /// Determines whether a syntax node of the specified type exists, optionally matching a predicate. + /// public bool Has(Func? predicate = null) where T : SyntaxNode => TryGet(out _, predicate); - /// Attempts to get the first syntax node of the specified type, optionally matching a predicate. + /// + /// Gets all syntax nodes of the specified type, optionally matching a predicate. + /// + public ImmutableArray GetAll(Func? predicate = null) + where T : SyntaxNode + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var tree in Trees) + { + var root = RootOf(tree); + if (root is T rootNode && (predicate is null || predicate(rootNode))) + builder.Add(rootNode); + + foreach (var candidate in root.DescendantNodes().OfType()) + { + if (predicate is null || predicate(candidate)) + builder.Add(candidate); + } + } + + return builder.ToImmutable(); + } + + /// + /// Counts the syntax nodes of the specified type, optionally matching a predicate. + /// + public int Count(Func? predicate = null) + where T : SyntaxNode + { + var count = 0; + foreach (var tree in Trees) + { + var root = RootOf(tree); + if (root is T rootNode && (predicate is null || predicate(rootNode))) + count++; + + count += root.DescendantNodes().OfType().Count(candidate => predicate is null || predicate(candidate)); + } + + return count; + } + + /// + /// Attempts to get the first syntax node of the specified type, optionally matching a predicate. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public bool TryGet(out T? node, Func? predicate = null) where T : SyntaxNode { foreach (var tree in Trees) { - var root = tree.GetRoot(); + var root = RootOf(tree); if (root is T rootNode && (predicate is null || predicate(rootNode))) { node = rootNode; diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs b/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs index 80c01dd..04e0033 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs @@ -11,7 +11,9 @@ namespace Purview.SourceGeneratorFramework.Testing; /// public static class CodeQueryResultExtensions { - /// Gets a query over the generated trees of a source-generator run, with the output compilation. + /// + /// Gets a query over the generated trees of a source-generator run, with the output compilation. + /// public static CodeQuery Generated(this DriverRunResult result) { if (result is null) @@ -21,7 +23,9 @@ public static CodeQuery Generated(this DriverRunResult result) return new(result.AllSyntaxTrees, compilation, isGenerated: true); } - /// Gets a query over the entire output compilation (user and generated trees) of a source-generator run. + /// + /// Gets a query over the entire output compilation (user and generated trees) of a source-generator run. + /// public static CodeQuery Output(this DriverRunResult result) { if (result is null) @@ -31,7 +35,9 @@ public static CodeQuery Output(this DriverRunResult result) return new([.. compilation.SyntaxTrees], compilation); } - /// Gets a query over the trees of an analyzer test compilation. + /// + /// Gets a query over the trees of an analyzer test compilation. + /// public static CodeQuery Code(this AnalyzerTestResult result) { if (result is null) @@ -41,7 +47,9 @@ public static CodeQuery Code(this AnalyzerTestResult result) return new([.. result.Compilation.SyntaxTrees], result.Compilation); } - /// Gets a query over the input compilation of a code-fix test. + /// + /// Gets a query over the input compilation of a code-fix test. + /// public static CodeQuery Code(this CodeFixTestResult result) { if (result is null) @@ -51,7 +59,9 @@ public static CodeQuery Code(this CodeFixTestResult result) return new([.. result.Compilation.SyntaxTrees], result.Compilation); } - /// Gets a query over the fixed source produced by a code-fix test. + /// + /// Gets a query over the fixed source produced by a code-fix test. + /// public static CodeQuery FixedCode(this CodeFixTestResult result) { if (result is null) @@ -67,7 +77,9 @@ public static CodeQuery FixedCode(this CodeFixTestResult result) return new(ParseSource(result.FixedSource), null); } - /// Gets a query over the fixed sources produced by a fix-all code-fix test. + /// + /// Gets a query over the fixed sources produced by a fix-all code-fix test. + /// public static CodeQuery FixedCode(this CodeFixFixAllResult result) { if (result is null) @@ -78,7 +90,9 @@ public static CodeQuery FixedCode(this CodeFixFixAllResult result) return compilation is null ? new([], null) : new([.. compilation.SyntaxTrees], compilation); } - /// Gets a query over the refactored sources produced by a refactoring test. + /// + /// Gets a query over the refactored sources produced by a refactoring test. + /// public static CodeQuery FixedCode(this RefactorTestResult result) { if (result is null) diff --git a/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs index 80f075d..c153c42 100644 --- a/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/DiagnosticAnalyzerTestRunner.cs @@ -2,18 +2,24 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Executes a diagnostic analyzer against a test compilation. +/// +/// Executes a diagnostic analyzer against a test compilation. +/// public sealed class DiagnosticAnalyzerTestRunner : RoslynTestRunner where TAnalyzer : DiagnosticAnalyzer, new() { - /// Runs the analyzer against one source file. + /// + /// 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. + /// + /// Runs the analyzer against the supplied source files. + /// public async Task RunAsync( IEnumerable sources, AnalyzerTestOptions? options = null, diff --git a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs index c257eb8..8ff0a4b 100644 --- a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs @@ -85,7 +85,9 @@ static string GetDiagnosticKey(Diagnostic diagnostic) => /// The mode to use when matching the hint name. /// The source text of the generated tree, or if not found. /// Thrown if is or whitespace. - /// The is matched using . + /// + /// The is matched using . + /// public string? GetSource(string hintName, HintNameMatchMode matchMode = HintNameMatchMode.Suffix) { if (string.IsNullOrWhiteSpace(hintName)) @@ -185,7 +187,9 @@ public enum HintNameMatchMode /// /// Match the hint name by suffix. /// - /// Note this will automatically check for .cs if it's excluded. + /// + /// Note this will automatically check for .cs if it's excluded. + /// Suffix, /// diff --git a/src/src/SourceGeneratorFramework.Testing/DriverRunValidationException.cs b/src/src/SourceGeneratorFramework.Testing/DriverRunValidationException.cs index 0e9cb14..12aa3f5 100644 --- a/src/src/SourceGeneratorFramework.Testing/DriverRunValidationException.cs +++ b/src/src/SourceGeneratorFramework.Testing/DriverRunValidationException.cs @@ -61,19 +61,29 @@ ImmutableArray compilationTrees LogErrors = logErrors; } - /// Gets the result of the source generator test run that failed validation. + /// + /// Gets the result of the source generator test run that failed validation. + /// public DriverRunResult RunResult { get; } - /// Gets exceptions thrown by generators. + /// + /// Gets exceptions thrown by generators. + /// public IReadOnlyList GeneratorFailures { get; } - /// Gets errors reported by the output compilation. + /// + /// Gets errors reported by the output compilation. + /// public IReadOnlyList CompilationErrors { get; } - /// Gets additional errors reported while emitting the output assembly. + /// + /// Gets additional errors reported while emitting the output assembly. + /// public IReadOnlyList EmitErrors { get; } - /// Gets error-level entries written through generator logging. + /// + /// Gets error-level entries written through generator logging. + /// public IReadOnlyList LogErrors { get; } static string BuildMessage( diff --git a/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs index 2de32fc..d17d83e 100644 --- a/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs +++ b/src/src/SourceGeneratorFramework.Testing/Extensions/Purview/SourceGeneratorFramework/SourceGeneratorTestOptionsExtensions.cs @@ -106,7 +106,9 @@ options with } ); - /// Creates a new options snapshot with additional namespaces appended. + /// + /// Creates a new options snapshot with additional namespaces appended. + /// public TOptions WithExcludeGeneratedSourceHintNames(IEnumerable sourceHintNames) => sourceHintNames is null ? options @@ -119,19 +121,25 @@ options with } ); - /// Creates a new options snapshot with additional namespaces appended. + /// + /// 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. + /// + /// 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. + /// + /// Creates a new options snapshot with additional namespaces appended. + /// public TOptions WithAdditionalNamespaces(params TypeIdentity[] identities) => identities is null || identities.Length == 0 ? options @@ -144,7 +152,9 @@ options with } ); - /// Creates a new options snapshot with additional namespaces appended. + /// + /// Creates a new options snapshot with additional namespaces appended. + /// public TOptions WithAdditionalNamespaces(IEnumerable identities) => identities is null ? options @@ -157,31 +167,41 @@ options with } ); - /// Creates a new options snapshot with additional metadata references appended. + /// + /// 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. + /// + /// 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. + /// + /// 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. + /// + /// 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. + /// + /// Creates a new options snapshot with additional source files appended. + /// public TOptions WithAdditionalSources(params SourceText[] additionalSources) => additionalSources is null || additionalSources.Length == 0 ? options @@ -194,7 +214,9 @@ options with } ); - /// Creates a new options snapshot with additional source files appended. + /// + /// Creates a new options snapshot with additional source files appended. + /// public TOptions WithAdditionalSources(IEnumerable additionalSources) => additionalSources is null ? options @@ -207,19 +229,25 @@ options with } ); - /// Creates a new options snapshot with additional text files appended. + /// + /// 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. + /// + /// 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. + /// + /// Creates a new options snapshot with analyzer types appended. + /// public TOptions WithAnalyzers(params Type[] analyzerTypes) { if (analyzerTypes is null || analyzerTypes.Length == 0) @@ -242,7 +270,9 @@ public TOptions WithAnalyzers(params Type[] analyzerTypes) }; } - /// Creates a new options snapshot with analyzer types appended. + /// + /// Creates a new options snapshot with analyzer types appended. + /// public TOptions WithAnalyzers(IEnumerable analyzerTypes) { if (analyzerTypes is null) @@ -265,8 +295,12 @@ public TOptions WithAnalyzers(IEnumerable analyzerTypes) }; } - /// Creates a new options snapshot using the specified analyzer options. - /// This clears . + /// + /// Creates a new options snapshot using the specified analyzer options. + /// + /// + /// This clears . + /// public TOptions WithAnalyzerOptions(AnalyzerOptions? analyzerOptions) => options with { @@ -274,8 +308,12 @@ options with CompilationWithAnalyzersOptions = null, }; - /// Creates a new options snapshot using the specified compilation-with-analyzers options. - /// This clears . + /// + /// 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 }; @@ -284,8 +322,12 @@ public TOptions WithCompilationWithAnalyzersOptions( extension(TOptions options) where TOptions : CodeFixTestOptions { - /// Creates a new code-fix options snapshot selecting a code action by index. - /// This clears . + /// + /// 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", @@ -296,7 +338,9 @@ public TOptions WithCodeActionIndex(int codeActionIndex) => ? 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. + /// + /// 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)) diff --git a/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs b/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs index a79aa75..893da63 100644 --- a/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs +++ b/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs @@ -13,7 +13,9 @@ public static class MemberQueryExtensions // Properties // --------------------------------------------------------------------------------------------- - /// Gets a property declared on the type, optionally matching its type. + /// + /// Gets a property declared on the type, optionally matching its type. + /// public static PropertyDeclarationSyntax GetProperty( this TypeDeclarationSyntax type, CodeQuery query, @@ -26,7 +28,9 @@ public static PropertyDeclarationSyntax GetProperty( $"No property named '{name}' was found on '{type.Identifier.ValueText}'." ); - /// Determines whether the type declares a property with the given name, optionally matching its type. + /// + /// Determines whether the type declares a property with the given name, optionally matching its type. + /// public static bool HasProperty( this TypeDeclarationSyntax type, CodeQuery query, @@ -34,7 +38,9 @@ public static bool HasProperty( TypeReference? propertyType = null ) => type.TryGetProperty(query, name, out _, propertyType); - /// Attempts to get a property declared on the type, optionally matching its type. + /// + /// Attempts to get a property declared on the type, optionally matching its type. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public static bool TryGetProperty( this TypeDeclarationSyntax type, @@ -71,7 +77,9 @@ public static bool TryGetProperty( // Indexers // --------------------------------------------------------------------------------------------- - /// Gets an indexer declared on the type, optionally matching its type and index parameters. + /// + /// Gets an indexer declared on the type, optionally matching its type and index parameters. + /// public static IndexerDeclarationSyntax GetIndexer( this TypeDeclarationSyntax type, CodeQuery query, @@ -82,7 +90,9 @@ params TypeReference[]? indexParameters ? indexer! : throw new SyntaxNotFoundException($"No matching indexer was found on '{type.Identifier.ValueText}'."); - /// Determines whether the type declares an indexer, optionally matching its type and index parameters. + /// + /// Determines whether the type declares an indexer, optionally matching its type and index parameters. + /// public static bool HasIndexer( this TypeDeclarationSyntax type, CodeQuery query, @@ -90,7 +100,9 @@ public static bool HasIndexer( params TypeReference[]? indexParameters ) => type.TryGetIndexer(query, out _, indexerType, indexParameters); - /// Attempts to get an indexer declared on the type, optionally matching its type and index parameters. + /// + /// Attempts to get an indexer declared on the type, optionally matching its type and index parameters. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public static bool TryGetIndexer( this TypeDeclarationSyntax type, @@ -126,7 +138,9 @@ params TypeReference[]? indexParameters // Methods // --------------------------------------------------------------------------------------------- - /// Gets a method declared on the type, optionally matching its parameter types. + /// + /// Gets a method declared on the type, optionally matching its parameter types. + /// public static MethodDeclarationSyntax GetMethod( this TypeDeclarationSyntax type, CodeQuery query, @@ -139,7 +153,9 @@ params TypeReference[]? parameters $"No method named '{name}' was found on '{type.Identifier.ValueText}'." ); - /// Determines whether the type declares a method with the given name, optionally matching its parameter types. + /// + /// Determines whether the type declares a method with the given name, optionally matching its parameter types. + /// public static bool HasMethod( this TypeDeclarationSyntax type, CodeQuery query, @@ -147,7 +163,9 @@ public static bool HasMethod( params TypeReference[]? parameters ) => type.TryGetMethod(query, name, out _, parameters); - /// Attempts to get a method declared on the type, optionally matching its parameter types. + /// + /// Attempts to get a method declared on the type, optionally matching its parameter types. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public static bool TryGetMethod( this TypeDeclarationSyntax type, @@ -216,7 +234,9 @@ TypeReference returnType // Constructors // --------------------------------------------------------------------------------------------- - /// Gets a constructor declared on the type, optionally matching its parameter types. + /// + /// Gets a constructor declared on the type, optionally matching its parameter types. + /// public static ConstructorDeclarationSyntax GetConstructor( this TypeDeclarationSyntax type, CodeQuery query, @@ -226,14 +246,18 @@ params TypeReference[]? parameters ? constructor! : throw new SyntaxNotFoundException($"No constructor was found on '{type.Identifier.ValueText}'."); - /// Determines whether the type declares a constructor, optionally matching its parameter types. + /// + /// Determines whether the type declares a constructor, optionally matching its parameter types. + /// public static bool HasConstructor( this TypeDeclarationSyntax type, CodeQuery query, params TypeReference[]? parameters ) => type.TryGetConstructor(query, out _, parameters); - /// Attempts to get a constructor declared on the type, optionally matching its parameter types. + /// + /// Attempts to get a constructor declared on the type, optionally matching its parameter types. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] public static bool TryGetConstructor( this TypeDeclarationSyntax type, diff --git a/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs index c89ebd9..7e8f56a 100644 --- a/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs @@ -3,24 +3,38 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Options that configure a refactoring test run. +/// +/// Options that configure a refactoring test run. +/// public record RefactorTestOptions : SourceGeneratorTestOptions { - /// Gets the index of the registered code action to apply. + /// + /// 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 . + /// + /// Gets the equivalence key used to select a registered code action. + /// + /// + /// When specified, this takes precedence over . + /// public string? EquivalenceKey { get; init; } - /// Gets the span the refactoring is triggered on. - /// Either or must be provided. + /// + /// Gets the span the refactoring is triggered on. + /// + /// + /// Either or must be provided. + /// public TextSpan? Span { get; init; } /// /// Gets a selector that locates the node the refactoring is triggered on, using the input compilation's /// . For example, query => query.GetMethod("M"). /// - /// Either or must be provided. + /// + /// Either or must be provided. + /// public Func? NodeSelector { get; init; } } diff --git a/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs b/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs index c0b7255..d2ce4d8 100644 --- a/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs @@ -4,7 +4,9 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// The result of a refactoring test run. +/// +/// The result of a refactoring test run. +/// /// The code actions registered by the refactoring provider. /// The refactored source of each document, keyed by document name. /// The solution after applying the selected refactoring. diff --git a/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs index 485cb4f..5118d54 100644 --- a/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs @@ -6,18 +6,24 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Executes a code refactoring against a test document and returns the refactored source. +/// +/// Executes a code refactoring against a test document and returns the refactored source. +/// public sealed class RefactoringTestRunner : RoslynTestRunner where TRefactoring : CodeRefactoringProvider, new() { - /// Runs the refactoring against one source file. + /// + /// Runs the refactoring against one source file. + /// public Task RunAsync( string source, RefactorTestOptions? options = null, CancellationToken cancellationToken = default ) => RunAsync([source], options, cancellationToken); - /// Runs the refactoring against the supplied source files. + /// + /// Runs the refactoring against the supplied source files. + /// public async Task RunAsync( IEnumerable sources, RefactorTestOptions? options = null, diff --git a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs index 59d890c..e638164 100644 --- a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs @@ -7,10 +7,14 @@ namespace Purview.SourceGeneratorFramework.Testing; -/// Provides shared Roslyn project creation for analyzer and code fix test runners. +/// +/// 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. + /// + /// Creates a compilation-with-analyzers using the configured analyzer options. + /// protected static CompilationWithAnalyzers WithAnalyzers( Compilation compilation, ImmutableArray analyzers, @@ -36,7 +40,9 @@ SourceGeneratorTestOptions options : compilation.WithAnalyzers(analyzers, options.AnalyzerOptions); } - /// Creates a project containing the supplied sources. + /// + /// Creates a project containing the supplied sources. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "CA1506:Avoid excessive class coupling", @@ -99,17 +105,23 @@ Assembly componentAssembly static string PrepareSource(string source, SourceGeneratorTestOptions options) => SourceGeneratorHelpers.PrepareSource(source, options); - /// Owns the workspace and project created for a test run. + /// + /// 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. + /// + /// Gets the test project. + /// public Project Project { get; } = project; - /// Gets the source document identifiers in input order. + /// + /// Gets the source document identifiers in input order. + /// public ImmutableArray DocumentIds { get; } = documentIds; /// diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs index 70f5fdc..8375db6 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs @@ -40,7 +40,9 @@ public static SourceGeneratorTestOptions Default /// /// Initializes a new instance by copying the current options. /// - /// Derived records implicitly call this constructor unless they select another base constructor. + /// + /// Derived records implicitly call this constructor unless they select another base constructor. + /// public SourceGeneratorTestOptions() : this(Default) { } @@ -143,7 +145,9 @@ public SourceGeneratorTestOptions() /// public bool ValidateCodeWriterScopes { get; init; } = true; - /// Gets whether framework source-generator logging is captured for this test run. + /// + /// Gets whether framework source-generator logging is captured for this test run. + /// public bool EnableLogging { get; init; } = true; /// @@ -210,7 +214,9 @@ public SourceGeneratorTestOptions() /// /// 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. + /// + /// This will be added to the compilation as additional source files, along with the source provided. + /// public ImmutableArray AdditionalSources { get; init; } = []; /// @@ -224,12 +230,16 @@ public SourceGeneratorTestOptions() /// /// Gets the options to use when running the compilation with analyzers. /// - /// This is mutually exclusive with . + /// + /// 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 . + /// + /// This is mutually exclusive with . + /// public CompilationWithAnalyzersOptions? CompilationWithAnalyzersOptions { get; init; } } diff --git a/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs b/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs index 2b494b9..10ef262 100644 --- a/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs +++ b/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs @@ -6,11 +6,15 @@ namespace Purview.SourceGeneratorFramework.Testing; [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1032:Implement standard exception constructors")] public sealed class SyntaxNotFoundException : InvalidOperationException { - /// Initializes a new instance of the class. + /// + /// Initializes a new instance of the class. + /// public SyntaxNotFoundException(string message) : base(message) { } - /// Initializes a new instance of the class. + /// + /// Initializes a new instance of the class. + /// public SyntaxNotFoundException(string message, Exception innerException) : base(message, innerException) { } } diff --git a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props index 3ad2cb5..388cc0d 100644 --- a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props +++ b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props @@ -18,6 +18,9 @@ Identifies the registered sink for an isolated source-generator logging session. + + Carries the consuming project's LangVersion so generators can gate emitted features. + diff --git a/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs b/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs index 7093352..cca363b 100644 --- a/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs +++ b/src/src/SourceGeneratorShared/AttributeArgumentOptions.cs @@ -1,22 +1,34 @@ namespace Purview.SourceGeneratorFramework; -/// Describes one positional or named attribute argument. +/// +/// Describes one positional or named attribute argument. +/// public readonly record struct AttributeArgumentOptions { - /// Creates a positional attribute argument. + /// + /// 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. + /// + /// 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. + /// + /// Gets the argument expression. + /// public string Value { get; } - /// Gets an optional constructor parameter or property name. + /// + /// 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 (:). + /// + /// Gets whether a named argument uses property assignment (=) instead of constructor naming (:). + /// public bool IsPropertyAssignment { get; init; } } diff --git a/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs b/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs index db6cdf5..ae40663 100644 --- a/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/AttributeDeclarationOptions.cs @@ -2,22 +2,34 @@ namespace Purview.SourceGeneratorFramework; -/// Describes an attribute applied to a generated declaration. +/// +/// Describes an attribute applied to a generated declaration. +/// public readonly record struct AttributeDeclarationOptions { - /// Creates an attribute declaration from a structured type reference. + /// + /// Creates an attribute declaration from a structured type reference. + /// public AttributeDeclarationOptions(TypeReference reference) => Reference = reference; - /// Creates an attribute declaration from a structured type value. + /// + /// Creates an attribute declaration from a structured type value. + /// public AttributeDeclarationOptions(TypeIdentity type) : this(type.AsTypeReference()) { } - /// Gets the structured attribute type. + /// + /// Gets the structured attribute type. + /// public TypeReference Reference { get; } - /// Gets an optional target such as return, field, or property. + /// + /// Gets an optional target such as return, field, or property. + /// public string? Target { get; init; } - /// Gets structured attribute arguments. + /// + /// Gets structured attribute arguments. + /// public ImmutableArray Arguments { get; init; } } diff --git a/src/src/SourceGeneratorShared/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs index 4633ae5..26e9252 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -16,10 +16,10 @@ namespace Purview.SourceGeneratorFramework; [SuppressMessage("Design", "CA1034:Nested types should not be visible")] public sealed partial class CodeWriter { - const char IndentCharacter = '\t'; const char NewLineCharacter = '\n'; const int DefaultCapacity = 4096; - const int IndentDisplayWidth = 4; + const int DefaultExpressionCapacity = 128; + const int DefaultIndentationSize = 4; const int DefaultMaximumLineLength = 100; int _indentLevel; @@ -32,6 +32,16 @@ public sealed partial class CodeWriter readonly StringBuilder _builder; readonly Dictionary? _openScopes; + readonly char _indentCharacter; + readonly int _indentationSize; + readonly int _maximumLineLength; + + /// + /// Gets whether opened scopes are tracked for validation. When , expensive + /// opening-stack-trace capture is skipped because undisposed scopes will not be reported. + /// + bool TracksOpenScopes => _openScopes is not null; + /// /// Initializes a new writer with required generator identity. /// @@ -69,10 +79,37 @@ public CodeWriter( IsNullableContextEnabled = settings.IsNullableContextEnabled; ThrowOnUnclosedScopes = throwOnUnclosedScopes; + _indentationSize = settings.IndentationSize > 0 ? settings.IndentationSize : DefaultIndentationSize; + _maximumLineLength = settings.MaximumLineLength > 0 ? settings.MaximumLineLength : DefaultMaximumLineLength; + _indentCharacter = settings.IndentationStyle == IndentationStyle.Spaces ? ' ' : '\t'; + if (throwOnUnclosedScopes) _openScopes = []; } + /// + /// Initializes an independent scratch writer that inherits this writer's current configuration + /// without allocating a fresh . + /// + /// The writer whose configuration is inherited. + /// The initial buffer capacity. + CodeWriter(CodeWriter source, int initialCapacity) + { + _builder = new(initialCapacity); + GeneratorName = source.GeneratorName; + GeneratorVersion = source.GeneratorVersion; + NullableDirectiveMode = source.NullableDirectiveMode; + IsNullableContextEnabled = source.IsNullableContextEnabled; + ThrowOnUnclosedScopes = source.ThrowOnUnclosedScopes; + DefaultIncludeGeneratedAttributes = source.DefaultIncludeGeneratedAttributes; + _indentCharacter = source._indentCharacter; + _indentationSize = source._indentationSize; + _maximumLineLength = source._maximumLineLength; + + if (ThrowOnUnclosedScopes) + _openScopes = []; + } + /// /// Gets the number of characters currently written. /// @@ -95,10 +132,14 @@ public CodeWriter( /// public bool ThrowOnUnclosedScopes { get; } - /// Gets the source generator name used by generated headers and attributes. + /// + /// Gets the source generator name used by generated headers and attributes. + /// public string GeneratorName { get; } - /// Gets the source generator version used by generated headers and attributes. + /// + /// Gets the source generator version used by generated headers and attributes. + /// public string GeneratorVersion { get; } /// @@ -246,7 +287,7 @@ public CodeWriter Comment(params string[] comments) public CodeWriter WriteIndent() { if (_indentLevel != 0) - _builder.Append(IndentCharacter, _indentLevel); + AppendIndentation(); _atLineStart = false; return this; @@ -320,7 +361,9 @@ public CodeWriter Write(char value) /// /// The value to quote. /// The current writer. - /// This method does not escape characters contained in . + /// + /// This method does not escape characters contained in . + /// /// writer.Quote("value"); // "value" public CodeWriter Quote(string? value = null) { @@ -349,7 +392,9 @@ public CodeWriter Quote(string? value = null) /// // } public BlockScope OpenBlockScope(string? header = null) => OpenDelimitedBlockScope(header, "{", "}"); - /// Writes a complete block and invokes a callback for its body. + /// + /// Writes a complete block and invokes a callback for its body. + /// /// writer.OpenBlock("if (enabled)", body => body.WriteLine("Run();")); public CodeWriter OpenBlock(string? header, Action bodyWriter) { @@ -389,7 +434,9 @@ public BlockScope OpenDelimitedBlockScope(string? header, string? openingToken, return TrackOpenBlockScope(header, closingToken); } - /// Writes a complete explicitly delimited block and invokes a callback for its body. + /// + /// Writes a complete explicitly delimited block and invokes a callback for its body. + /// /// writer.OpenDelimitedBlock("items", "(", ");", body => body.WriteLine("value")); public CodeWriter OpenDelimitedBlock( string? header, @@ -438,7 +485,9 @@ public BlockScope OpenDelimitedBlockWithHeaderScope( return TrackOpenBlockScope(header, closingToken); } - /// Writes a complete delimited block with a callback-completed header and body. + /// + /// Writes a complete delimited block with a callback-completed header and body. + /// /// writer.OpenDelimitedBlockWithHeader("Call", w => w.Write("(value)"), "{", "}", body => body.WriteLine("Run();")); public CodeWriter OpenDelimitedBlockWithHeader( string? header, @@ -490,7 +539,9 @@ Action body return this; } - /// Writes a structured method declaration and returns its body scope. + /// + /// Writes a structured method declaration and returns its body scope. + /// /// The method declaration. /// /// The method body scope, or an empty scope when an abstract or expression-bodied method was @@ -552,7 +603,8 @@ void WriteMethodHeader(MethodDeclarationOptions declaration) declaration.IsAbstract, declaration.IsVirtual, declaration.IsOverride, - declaration.IsSealed + declaration.IsSealed, + isReadOnly: declaration.IsReadOnly ); WriteIf(declaration.IsAsync, "async ").WriteIf(declaration.IsUnsafe, "unsafe "); @@ -569,7 +621,9 @@ void WriteMethodHeader(MethodDeclarationOptions declaration) WriteMethodGenericConstraints(declaration.GenericTypes); } - /// Writes a structured partial method declaration. + /// + /// Writes a structured partial method declaration. + /// /// writer.WritePartialMethod(new MethodDeclarationOptions("OnChanged")); public CodeWriter WritePartialMethod(MethodDeclarationOptions declaration) { @@ -577,7 +631,9 @@ public CodeWriter WritePartialMethod(MethodDeclarationOptions declaration) return this; } - /// Writes a structured partial method declaration. + /// + /// Writes a structured partial method declaration. + /// /// writer.WriteMethodExpression(new MethodDeclarationOptions("Count", "int") { ExpressionBody = "items.Count" }); public CodeWriter WriteMethodExpression(MethodDeclarationOptions declaration) { @@ -593,7 +649,9 @@ public CodeWriter WriteMethodExpression(MethodDeclarationOptions declaration) return WriteMethod(declaration, _ => { }); } - /// Writes an expression-bodied method using a callback for the expression. + /// + /// Writes an expression-bodied method using a callback for the expression. + /// /// writer.WriteMethodExpression(new MethodDeclarationOptions("Count", "int"), expression => expression.Write("items.Count")); public CodeWriter WriteMethodExpression(MethodDeclarationOptions declaration, Action writeExpression) { @@ -613,7 +671,9 @@ public CodeWriter WriteMethodExpression(MethodDeclarationOptions declaration, Ac return this; } - /// Writes a structured method and invokes a callback for its body. + /// + /// Writes a structured method and invokes a callback for its body. + /// /// writer.WriteMethod(new MethodDeclarationOptions("Run"), body => body.WriteLine("return;")); public CodeWriter WriteMethod(MethodDeclarationOptions declaration, Action writeBody) { @@ -647,7 +707,94 @@ public CodeWriter WriteMethod(MethodDeclarationOptions declaration, ActionWrites an auto-property or expression-bodied property. + /// + /// Writes a structured operator declaration and returns its body scope. + /// + /// The operator declaration. + /// + /// The operator body scope, or an empty scope when an expression-bodied operator was emitted. + /// + /// using (writer.WriteOperatorScope(new OperatorDeclarationOptions("==", TypeLibrary.System.Boolean, left, right))) writer.WriteLine("return left.Equals(right);"); + public BlockScope WriteOperatorScope(OperatorDeclarationOptions declaration) + { + if (declaration.ReturnType.IsEmpty) + return default; + + ValidateOperatorDeclaration(declaration); + BeginWrittenItem(WrittenItemKind.Method); + + if (declaration.IncludeGeneratedAttributes ?? DefaultIncludeGeneratedAttributes) + WriteGeneratedAttributes(includeCoverageExclusion: true, includeEmbeddedAttribute: false); + + WriteAttributes(declaration.Attributes); + + if (declaration.Accessibility is { } accessibility) + WriteAccessibility(accessibility).Write(' '); + + WriteIf(declaration.IsStatic, "static "); + switch (declaration.Kind) + { + case OperatorDeclarationKind.ImplicitConversion: + case OperatorDeclarationKind.ExplicitConversion: + Write(declaration.Kind == OperatorDeclarationKind.ImplicitConversion ? "implicit " : "explicit "); + Write("operator ").WriteTypeReference(declaration.ReturnType); + WriteParametersWithHeuristic([declaration.Left]); + break; + + case OperatorDeclarationKind.Unary: + WriteTypeReference(declaration.ReturnType).Write(" operator ").Write(declaration.OperatorToken); + WriteParametersWithHeuristic([declaration.Left]); + break; + + case OperatorDeclarationKind.Binary: + WriteTypeReference(declaration.ReturnType).Write(" operator ").Write(declaration.OperatorToken); + WriteParametersWithHeuristic([declaration.Left, declaration.Right]); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(declaration)); + } + + if (declaration.ExpressionBody is not null) + { + Write(" => "); + WriteExpression(declaration.ExpressionBody, expressionWriter: null); + WriteLine(";"); + CompleteWrittenItem(WrittenItemKind.Method, _indentLevel); + return default; + } + + NewLine(); + return OpenBlockScope(WrittenItemKind.Method); + } + + /// + /// Writes a structured operator declaration and invokes a callback for its body. + /// + /// The operator declaration. + /// The action that writes the operator body. + /// The current writer. + /// The operator has an expression body. + /// writer.WriteOperator(new OperatorDeclarationOptions("==", TypeLibrary.System.Boolean, left, right), body => body.WriteLine("return left.Equals(right);")); + public CodeWriter WriteOperator(OperatorDeclarationOptions declaration, Action writeBody) + { + if (writeBody is null) + throw new ArgumentNullException(nameof(writeBody)); + if (declaration.ExpressionBody is not null) + throw new ArgumentException( + "A callback body cannot be supplied for an expression-bodied operator.", + nameof(declaration) + ); + + using (WriteOperatorScope(declaration)) + writeBody(this); + + return this; + } + + /// + /// Writes an auto-property or expression-bodied property. + /// /// writer.WriteProperty(new PropertyDeclarationOptions("Name", "string")); public CodeWriter WriteProperty(PropertyDeclarationOptions declaration) { @@ -669,10 +816,22 @@ public CodeWriter WriteProperty(PropertyDeclarationOptions declaration) } Write(" { "); - if (declaration.HasGetter) - WriteAccessor(declaration.GetterAccessibility, "get;"); - if (declaration.HasSetter || declaration.IsInitOnly) - WriteAccessor(declaration.SetterAccessibility, declaration.IsInitOnly ? "init;" : "set;"); + if (declaration.IsFieldBacked) + { + // C# 14 field-keyword semi-auto property: accessors reference the implicit backing field. + if (declaration.HasGetter) + Write("get => field; "); + if (declaration.HasSetter || declaration.IsInitOnly) + Write(declaration.IsInitOnly ? "init => field = value; " : "set => field = value; "); + } + else + { + if (declaration.HasGetter) + WriteAccessor(declaration.GetterAccessibility, "get;"); + if (declaration.HasSetter || declaration.IsInitOnly) + WriteAccessor(declaration.SetterAccessibility, declaration.IsInitOnly ? "init;" : "set;"); + } + Write("}"); if (declaration.Initializer is not null) { @@ -685,7 +844,9 @@ public CodeWriter WriteProperty(PropertyDeclarationOptions declaration) return this; } - /// Writes an expression-bodied property using a callback for the expression. + /// + /// Writes an expression-bodied property using a callback for the expression. + /// /// writer.WritePropertyExpression(new PropertyDeclarationOptions("Count", "int"), expression => expression.Write("items.Count")); public CodeWriter WritePropertyExpression( PropertyDeclarationOptions declaration, @@ -717,7 +878,9 @@ Action writeExpression return this; } - /// Writes a property with callback-generated accessor bodies. + /// + /// Writes a property with callback-generated accessor bodies. + /// /// writer.WriteProperty(new PropertyDeclarationOptions("Value", "int"), get => get.WriteLine("return _value;"), null); public CodeWriter WriteProperty( PropertyDeclarationOptions declaration, @@ -726,9 +889,9 @@ public CodeWriter WriteProperty( ) { ValidatePropertyDeclaration(declaration); - if (declaration.ExpressionBody is not null || declaration.Initializer is not null) + if (declaration.ExpressionBody is not null || declaration.Initializer is not null || declaration.IsFieldBacked) throw new ArgumentException( - "A property with accessor bodies cannot specify an expression body or initializer.", + "A property with accessor bodies cannot specify an expression body, initializer, or the field keyword.", nameof(declaration) ); if (declaration.IsAbstract) @@ -757,7 +920,111 @@ public CodeWriter WriteProperty( return this; } - /// Writes a field declaration. + /// + /// Writes an indexer declaration with auto accessors or an expression body. + /// + /// The indexer declaration. + /// The current writer. + /// writer.WriteIndexer(new IndexerDeclarationOptions(Type("string"), new("index", Type("int")))); + public CodeWriter WriteIndexer(IndexerDeclarationOptions declaration) + { + if (declaration.Type.IsEmpty) + return this; + ValidateIndexerDeclaration(declaration); + BeginWrittenItem(WrittenItemKind.Property); + if (declaration.IncludeGeneratedAttributes ?? DefaultIncludeGeneratedAttributes) + WriteGeneratedAttributes(includeCoverageExclusion: true, includeEmbeddedAttribute: false); + WriteAttributes(declaration.Attributes); + WriteIndexerHeader(declaration); + if (declaration.ExpressionBody is not null) + { + Write(" => "); + WriteExpression(declaration.ExpressionBody, expressionWriter: null); + WriteLine(";"); + CompleteWrittenItem(WrittenItemKind.Property, _indentLevel); + return this; + } + + Write(" { "); + if (declaration.HasGetter) + WriteAccessor(declaration.GetterAccessibility, "get;"); + if (declaration.HasSetter || declaration.IsInitOnly) + WriteAccessor(declaration.SetterAccessibility, declaration.IsInitOnly ? "init;" : "set;"); + Write("}"); + NewLine(); + CompleteWrittenItem(WrittenItemKind.Property, _indentLevel); + return this; + } + + /// + /// Writes an indexer with callback-generated accessor bodies. + /// + /// The indexer declaration. + /// The action that writes the getter body, or for an auto getter. + /// The action that writes the setter body, or for an auto setter. + /// The current writer. + /// writer.WriteIndexer(new IndexerDeclarationOptions(Type("string"), new("index", Type("int"))), get => get.WriteLine("return _items[index];"), null); + public CodeWriter WriteIndexer( + IndexerDeclarationOptions declaration, + Action? writeGetterBody, + Action? writeSetterBody + ) + { + ValidateIndexerDeclaration(declaration); + if (declaration.ExpressionBody is not null) + throw new ArgumentException( + "An indexer with accessor bodies cannot specify an expression body.", + nameof(declaration) + ); + if (declaration.IsAbstract) + throw new ArgumentException( + "Accessor bodies cannot be supplied for an abstract indexer.", + nameof(declaration) + ); + + BeginWrittenItem(WrittenItemKind.Property); + if (declaration.IncludeGeneratedAttributes ?? DefaultIncludeGeneratedAttributes) + WriteGeneratedAttributes(includeCoverageExclusion: true, includeEmbeddedAttribute: false); + WriteAttributes(declaration.Attributes); + WriteIndexerHeader(declaration).NewLine(); + using (OpenBlockScope()) + { + if (declaration.HasGetter) + WriteAccessorBody(declaration.GetterAccessibility, "get", writeGetterBody); + if (declaration.HasSetter || declaration.IsInitOnly) + WriteAccessorBody( + declaration.SetterAccessibility, + declaration.IsInitOnly ? "init" : "set", + writeSetterBody + ); + } + CompleteWrittenItem(WrittenItemKind.Property, _indentLevel); + return this; + } + + CodeWriter WriteIndexerHeader(IndexerDeclarationOptions declaration) + { + WriteMemberModifiers( + declaration.Accessibility, + declaration.IsStatic, + declaration.IsAbstract, + declaration.IsVirtual, + declaration.IsOverride, + declaration.IsSealed + ); + WriteTypeReference(declaration.Type).Write(" this["); + for (var index = 0; index < declaration.Parameters.Length; index++) + { + if (index != 0) + Write(", "); + WriteParameter(declaration.Parameters[index]); + } + return Write(']'); + } + + /// + /// Writes a field declaration. + /// /// writer.WriteField(new FieldDeclarationOptions("_value", "int")); public CodeWriter WriteField(FieldDeclarationOptions declaration) { @@ -770,10 +1037,12 @@ public CodeWriter WriteField(FieldDeclarationOptions declaration) WriteAttributes(declaration.Attributes); if (declaration.Accessibility is { } accessibility) WriteAccessibility(accessibility).Write(' '); - WriteIf(declaration.IsConst, "const ") + WriteIf(declaration.IsRequired, "required ") + .WriteIf(declaration.IsConst, "const ") .WriteIf(declaration.IsStatic && !declaration.IsConst, "static ") .WriteIf(declaration.IsReadOnly, "readonly ") .WriteIf(declaration.IsVolatile, "volatile ") + .WriteIf(declaration.IsRefField, "ref ") .WriteTypeReference(declaration.Type) .Write(' ') .Write(declaration.Name); @@ -791,13 +1060,66 @@ public CodeWriter WriteField(FieldDeclarationOptions declaration) /// Writes a C# using directive. /// /// The namespace to import. + /// Whether the directive is emitted as a global using. /// The current writer. /// writer.WriteUsing("System"); // using System; - public CodeWriter WriteUsing(string namespaceName) + public CodeWriter WriteUsing(string namespaceName, bool isGlobal = false) { return string.IsNullOrWhiteSpace(namespaceName) ? throw new ArgumentException("Namespace cannot be null or whitespace.", nameof(namespaceName)) - : Write("using ").Write(namespaceName).WriteLine(";"); + : Write(isGlobal ? "global using " : "using ").Write(namespaceName).WriteLine(";"); + } + + /// + /// Writes a C# using alias directive. + /// + /// The alias name. + /// The aliased namespace or type. + /// The current writer. + /// writer.WriteUsingAlias("Events", "global::Purview.Events"); // using Events = global::Purview.Events; + public CodeWriter WriteUsingAlias(string alias, string target) + { + if (string.IsNullOrWhiteSpace(alias)) + throw new ArgumentException("Alias cannot be null or whitespace.", nameof(alias)); + if (string.IsNullOrWhiteSpace(target)) + throw new ArgumentException("Alias target cannot be null or whitespace.", nameof(target)); + + // The alias directive is not indented, so we don't call WriteIndentIfRequired(). + return Write("using ").Write(alias).Write(" = ").Write(target).WriteLine(";"); + } + + /// + /// Writes a #region directive and returns a scope that restores indentation and emits + /// #endregion when disposed. + /// + /// The region name. + /// The region scope. + /// using (writer.OpenRegionScope("Generated members")) writer.WriteLine("public int Value { get; }"); + public BlockScope OpenRegionScope(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Region name cannot be null or whitespace.", nameof(name)); + + EnsureBlankLine(); + Write("#region ").WriteLine(name); + Indent(); + return TrackOpenBlockScope(header: null, closingSeparator: "#endregion"); + } + + /// + /// Writes a #region and invokes a callback for its body. + /// + /// The region name. + /// The action that writes the region body. + /// The current writer. + /// writer.OpenRegion("Generated members", body => body.WriteProperty(new PropertyDeclarationOptions("Value", "int"))); + public CodeWriter OpenRegion(string name, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (OpenRegionScope(name)) + body(this); + return this; } /// @@ -843,7 +1165,9 @@ public CodeWriter WriteBlockNamespace(TypeReference typeReference, Action typeReference is null ? NoOpScope.Instance : WriteBlockNamespaceScope(typeReference.Identity.Namespace); - /// Writes a block-scoped namespace and invokes a callback for its body. + /// + /// Writes a block-scoped namespace and invokes a callback for its body. + /// /// The namespace, or to omit the wrapper. /// The action that writes the namespace body. /// The current writer. @@ -1069,14 +1393,18 @@ public CodeWriter WriteRecordStruct(TypeDeclarationOptions declaration, ActionWrites an interface declaration and returns its body scope. + /// + /// Writes an interface declaration and returns its body scope. + /// /// using (writer.WriteInterfaceScope(new TypeDeclarationOptions("IService"))) { } public BlockScope WriteInterfaceScope(TypeDeclarationOptions declaration) => declaration is null ? throw new ArgumentNullException(nameof(declaration)) : WriteTypeScope(declaration with { Kind = TypeDeclarationKind.Interface }); - /// Writes an interface declaration and invokes a callback for its body. + /// + /// Writes an interface declaration and invokes a callback for its body. + /// /// writer.WriteInterface(new TypeDeclarationOptions("IService"), _ => { }); public CodeWriter WriteInterface(TypeDeclarationOptions declaration, Action bodyWriter) { @@ -1087,14 +1415,18 @@ public CodeWriter WriteInterface(TypeDeclarationOptions declaration, ActionWrites an enum declaration and returns its body scope. + /// + /// Writes an enum declaration and returns its body scope. + /// /// using (writer.WriteEnumScope(new TypeDeclarationOptions("Status"))) { } public BlockScope WriteEnumScope(TypeDeclarationOptions declaration) => declaration is null ? throw new ArgumentNullException(nameof(declaration)) : WriteTypeScope(declaration with { Kind = TypeDeclarationKind.Enum }); - /// Writes an enum declaration and invokes a callback for its body. + /// + /// Writes an enum declaration and invokes a callback for its body. + /// /// writer.WriteEnum(new TypeDeclarationOptions("Status"), _ => { }); public CodeWriter WriteEnum(TypeDeclarationOptions declaration, Action bodyWriter) { @@ -1107,7 +1439,9 @@ public CodeWriter WriteEnum(TypeDeclarationOptions declaration, ActionWrites an enum declaration with structured field declarations. + /// + /// Writes an enum declaration with structured field declarations. + /// /// The enum declaration options. /// The fields to write in declaration order. /// The current writer. @@ -1131,7 +1465,9 @@ public CodeWriter WriteEnum(TypeDeclarationOptions declaration, params EnumField ); } - /// Writes a field in an enum declaration. + /// + /// Writes a field in an enum declaration. + /// /// The enum field declaration options. /// The current writer. /// writer.WriteEnumField(new EnumFieldDeclarationOptions("Ready", 1)); @@ -1167,7 +1503,9 @@ void WriteXmlSummary(ImmutableArray summary) WriteLine("/// "); } - /// Writes a complete delegate declaration. + /// + /// Writes a complete delegate declaration. + /// /// writer.WriteDelegate(new TypeDeclarationOptions("Handler") { DelegateReturnType = "void" }); public CodeWriter WriteDelegate(TypeDeclarationOptions declaration) { @@ -1220,6 +1558,9 @@ or TypeDeclarationKind.RecordClass else if (isClass && declaration.IsSealed) Write("sealed "); + if (isStruct && declaration.IsRefStruct) + Write("ref "); + if ( declaration.IsPartial && declaration.Kind is not TypeDeclarationKind.Enum and not TypeDeclarationKind.Delegate @@ -1272,7 +1613,9 @@ or TypeDeclarationKind.RecordClass return OpenBlockScope(WrittenItemKind.Type); } - /// Writes a structured type declaration and invokes a callback for its body. + /// + /// Writes a structured type declaration and invokes a callback for its body. + /// /// The structured type declaration options. /// The action that writes the type body. /// The current writer. @@ -1325,7 +1668,9 @@ public BlockScope WriteConstructorScope(ConstructorDeclarationOptions declaratio return OpenBlockScope(WrittenItemKind.Constructor); } - /// Writes a structured constructor and invokes a callback for its body. + /// + /// Writes a structured constructor and invokes a callback for its body. + /// /// writer.WriteConstructor(new ConstructorDeclarationOptions("C"), _ => { }); public CodeWriter WriteConstructor(ConstructorDeclarationOptions declaration, Action writeBody) { @@ -1429,7 +1774,9 @@ public CodeWriter WriteGeneratedCodeAttribute(string generatorName, string? vers .WriteLine("\")]"); } - /// Writes the standard marker attributes for a generated declaration. + /// + /// Writes the standard marker attributes for a generated declaration. + /// /// /// Whether to emit . /// This must be enabled only for declaration targets supported by that attribute. @@ -1522,7 +1869,9 @@ public CodeWriter MultiLineParameters(params string[] parameters) return Unindent(); } - /// Writes a method invocation statement. + /// + /// Writes a method invocation statement. + /// /// The method name, optionally including a receiver. /// The argument expressions. /// The current writer. @@ -1530,7 +1879,9 @@ public CodeWriter MultiLineParameters(params string[] parameters) public CodeWriter WriteMethodCall(string methodName, params string[] arguments) => WriteMethodCallCore(methodName, arguments, receiver: null, genericArguments: null, false, false); - /// Writes an awaited method invocation statement. + /// + /// Writes an awaited method invocation statement. + /// /// The method name, optionally including a receiver. /// The argument expressions. /// The current writer. @@ -1570,7 +1921,9 @@ public CodeWriter WriteMethodCall( writeArgumentsOnSeparateLines ); - /// Writes an awaited method invocation from structured argument declarations. + /// + /// Writes an awaited method invocation from structured argument declarations. + /// /// The method name without a receiver or generic argument list. /// The structured arguments to invoke the method with. /// An optional receiver such as service. @@ -1658,7 +2011,7 @@ bool isAwaited return WriteLine(";"); } - bool WriteMethodCallArguments(string?[] arguments, bool writeOnSeparateLines, string multilineClosingToken = ");") + bool WriteMethodCallArguments(string?[] arguments, bool writeOnSeparateLines, string multilineClosingSuffix = ";") { var inlineLength = CurrentLineLength + 2; for (var index = 0; index < arguments.Length; index++) @@ -1666,7 +2019,7 @@ bool isAwaited var canWriteInline = !writeOnSeparateLines - && inlineLength <= DefaultMaximumLineLength + && inlineLength <= _maximumLineLength && arguments.All(static argument => argument is not null && !argument.Contains('\n')); if (arguments.Length == 0) { @@ -1690,13 +2043,25 @@ bool isAwaited for (var index = 0; index < arguments.Length; index++) { WriteExpression(arguments[index], expressionWriter: null); - WriteLine(index == arguments.Length - 1 ? multilineClosingToken : ","); + if (index != arguments.Length - 1) + WriteLine(","); + else + NewLine(); } Unindent(); + Write(')'); + if (multilineClosingSuffix.Length > 0) + { + Write(multilineClosingSuffix); + NewLine(); + } + return true; } - /// Writes an assignment statement. + /// + /// Writes an assignment statement. + /// /// The target, such as value or var result. /// The assigned expression. /// Whether to force the value to be not null, by appending the null-forgiving operator (!). @@ -1713,7 +2078,9 @@ public CodeWriter WriteAssignment(string target, string value, bool forceNotNull return WriteLine(";"); } - /// Writes an assignment statement using a callback for a multiline expression. + /// + /// Writes an assignment statement using a callback for a multiline expression. + /// /// writer.WriteAssignment("value", expression => expression.Write("new Value()")); public CodeWriter WriteAssignment(string target, Action writeValue) { @@ -1725,7 +2092,9 @@ public CodeWriter WriteAssignment(string target, Action writeValue) return WriteLine(";"); } - /// Writes an assignment whose value is a structured object-creation expression. + /// + /// Writes an assignment whose value is a structured object-creation expression. + /// /// writer.WriteAssignment("@event", new ObjectCreationOptions(eventType, "propVal1", "propVal2")); public CodeWriter WriteAssignment(string target, ObjectCreationOptions value, bool forceNotNull = false) { @@ -1739,7 +2108,9 @@ public CodeWriter WriteAssignment(string target, ObjectCreationOptions value, bo return WriteLine(";"); } - /// Writes a typed local or declaration assignment. + /// + /// Writes a typed local or declaration assignment. + /// /// writer.WriteAssignment("var", "value", "CreateValue()"); public CodeWriter WriteAssignment(string type, string name, string value, bool forceNotNull = false) { @@ -1748,7 +2119,9 @@ public CodeWriter WriteAssignment(string type, string name, string value, bool f return WriteAssignment($"{type} {name}", value, forceNotNull); } - /// Writes a typed local or declaration assignment with a multiline expression. + /// + /// Writes a typed local or declaration assignment with a multiline expression. + /// /// writer.WriteAssignment("Value", "value", expression => expression.Write("CreateValue()")); public CodeWriter WriteAssignment(string type, string name, Action writeValue) { @@ -1757,7 +2130,9 @@ public CodeWriter WriteAssignment(string type, string name, Action w return WriteAssignment($"{type} {name}", writeValue); } - /// Writes a typed local assignment whose value is a structured object creation. + /// + /// Writes a typed local assignment whose value is a structured object creation. + /// /// writer.WriteAssignment("var", "@event", new ObjectCreationOptions(eventType, "propVal1", "propVal2")); public CodeWriter WriteAssignment(string type, string name, ObjectCreationOptions value, bool forceNotNull = false) { @@ -1768,17 +2143,104 @@ public CodeWriter WriteAssignment(string type, string name, ObjectCreationOption bool WriteObjectCreationExpression(ObjectCreationOptions value, bool forceNotNull) { - Write("new ").WriteTypeReference(value.Reference).Write('('); + ValidateInitializerMembers(value); + Write("new ").WriteTypeReference(value.Reference); + + var hasInitializer = !value.InitializerMembers.IsDefaultOrEmpty; string[] arguments = value.Arguments.IsDefault ? [] : [.. value.Arguments.Select(RenderCallArgument)]; - if (WriteMethodCallArguments(arguments, value.WriteArgumentsOnSeparateLines, forceNotNull ? ")!;" : ");")) - return true; - Write(')'); + if (arguments.Length > 0 || !hasInitializer) + { + // WriteMethodCallArguments writes the closing parenthesis itself: inline or for empty + // arguments it emits ')' and returns false, while a multiline layout emits the closing + // token and returns true. + Write('('); + if ( + WriteMethodCallArguments( + arguments, + value.WriteArgumentsOnSeparateLines, + hasInitializer ? string.Empty + : forceNotNull ? "!;" + : ";" + ) + ) + { + // Multiline arguments: the closing token was already written. When an initializer + // follows, the initializer supplies the terminating semicolon via the caller. + if (hasInitializer) + { + WriteObjectInitializer(value, forceNotNull); + return false; + } + + return true; + } + } + + if (hasInitializer) + { + WriteObjectInitializer(value, forceNotNull); + return false; + } + if (forceNotNull) Write('!'); + return false; } - /// Writes a return statement. + bool WriteObjectInitializer(ObjectCreationOptions value, bool forceNotNull) + { + if (value.WriteInitializerMembersOnSeparateLines) + { + EnsureNewLine(); + WriteLine("{"); + Indent(); + for (var index = 0; index < value.InitializerMembers.Length; index++) + { + var member = value.InitializerMembers[index]; + Write(member.Name).Write(" = ").Write(member.Value).WriteLine(","); + } + + Unindent(); + Write("}"); + } + else + { + Write(" { "); + for (var index = 0; index < value.InitializerMembers.Length; index++) + { + if (index != 0) + Write(" "); + + var member = value.InitializerMembers[index]; + Write(member.Name).Write(" = ").Write(member.Value).Write(","); + } + + Write(" }"); + } + + if (forceNotNull) + Write('!'); + + return false; + } + + static void ValidateInitializerMembers(ObjectCreationOptions value) + { + for ( + var index = 0; + !value.InitializerMembers.IsDefaultOrEmpty && index < value.InitializerMembers.Length; + index++ + ) + { + ValidateRequired(value.InitializerMembers[index].Name, "Initializer member name", nameof(value)); + ValidateRequired(value.InitializerMembers[index].Value, "Initializer member value", nameof(value)); + } + } + + /// + /// Writes a return statement. + /// /// writer.WriteReturn("value"); // return value; public CodeWriter WriteReturn(string? expression = null) { @@ -1789,7 +2251,9 @@ public CodeWriter WriteReturn(string? expression = null) return WriteLine(";"); } - /// Writes a return statement using a callback for a multiline expression. + /// + /// Writes a return statement using a callback for a multiline expression. + /// /// writer.WriteReturn(expression => expression.Write("value")); public CodeWriter WriteReturn(Action writeExpression) { @@ -1800,7 +2264,9 @@ public CodeWriter WriteReturn(Action writeExpression) return WriteLine(";"); } - /// Writes a throw statement. + /// + /// Writes a throw statement. + /// /// writer.WriteThrow("new InvalidOperationException()"); public CodeWriter WriteThrow(string expression) { @@ -1810,23 +2276,38 @@ public CodeWriter WriteThrow(string expression) return WriteLine(";"); } - /// Writes a throw statement. - /// writer.WriteThrow("new InvalidOperationException()"); + /// + /// Writes a throw statement using a structured exception type and an optional message. + /// + /// The exception type to throw. + /// + /// The exception message written as a string literal, or to throw the + /// exception without a message. Backslashes and double quotes are escaped so raw literal text + /// can be supplied. + /// + /// writer.WriteThrow(TypeLibrary.System.InvalidOperationException, "Cannot be null."); public CodeWriter WriteThrow(TypeReference exceptionType, string? message = null) { if (exceptionType.IsNullOrEmpty()) throw new ArgumentException("Exception type cannot be null or empty.", nameof(exceptionType)); Write("throw new "); - WriteExpression( - $"{exceptionType}{(message is null ? string.Empty : $"(\"{message}\")")}", - expressionWriter: null - ); + if (message is null) + WriteExpression($"{exceptionType}()", expressionWriter: null); + else + { + WriteExpression( + $"{exceptionType}(\"{message.Replace("\\", "\\\\").Replace("\"", "\\\"")}\")", + expressionWriter: null + ); + } return WriteLine(";"); } - /// Writes a throw statement using a callback for a multiline expression. + /// + /// Writes a throw statement using a callback for a multiline expression. + /// /// writer.WriteThrow(expression => expression.Write("new InvalidOperationException()")); public CodeWriter WriteThrow(Action writeExpression) { @@ -1857,7 +2338,9 @@ public CodeWriter WriteIfBlock(string condition, Action bodyWriter) return this; } - /// Writes an if statement and returns its body scope. + /// + /// Writes an if statement and returns its body scope. + /// /// using (writer.WriteIfBlockScope("enabled")) writer.WriteReturn(); public BlockScope WriteIfBlockScope(string condition) { @@ -1868,6 +2351,332 @@ public BlockScope WriteIfBlockScope(string condition) return OpenBlockScope(); } + /// + /// Writes an if statement and an optional else block. + /// + /// The if condition. + /// The action that writes the if body. + /// The action that writes the else body, or to omit the else. + /// The current writer. + /// writer.WriteIfElse("enabled", body => body.WriteReturn("value"), null); + public CodeWriter WriteIfElse(string condition, Action ifBody, Action? elseBody) + { + if (ifBody is null) + throw new ArgumentNullException(nameof(ifBody)); + using (WriteIfBlockScope(condition)) + ifBody(this); + if (elseBody is not null) + WriteElse(elseBody); + return this; + } + + /// + /// Writes an else block following an if and invokes a callback for its body. + /// + /// The action that writes the else body. + /// The current writer. + /// writer.WriteIfBlock("enabled", body => body.WriteReturn("value")).WriteElse(body => body.WriteReturn("null")); + public CodeWriter WriteElse(Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteElseScope()) + body(this); + return this; + } + + /// + /// Writes an else block and returns its body scope. + /// + /// The else body scope. + /// using (writer.WriteIfBlockScope("enabled")) writer.WriteReturn("value"); using (writer.WriteElseScope()) writer.WriteReturn("null"); + public BlockScope WriteElseScope() + { + EnsureNewLine(); + WriteLine("else"); + return OpenBlockScope(); + } + + /// + /// Writes a foreach statement and invokes a callback for its body. + /// + /// The iterator declaration, such as var item in items. + /// The action that writes the loop body. + /// The current writer. + /// writer.WriteForeach("var item in items", body => body.WriteMethodCall("Process", "item")); + public CodeWriter WriteForeach(string iterator, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteForeachScope(iterator)) + body(this); + return this; + } + + /// + /// Writes a foreach statement and returns its body scope. + /// + /// The iterator declaration, such as var item in items. + /// The loop body scope. + /// using (writer.WriteForeachScope("var item in items")) writer.WriteMethodCall("Process", "item"); + public BlockScope WriteForeachScope(string iterator) + { + ValidateStatementPart(iterator, nameof(iterator)); + Write("foreach (").Write(iterator).WriteLine(")"); + return OpenBlockScope(); + } + + /// + /// Writes a for statement and invokes a callback for its body. + /// + /// The initializer expression, or for none. + /// The condition expression, or for none. + /// The iterator expression, or for none. + /// The action that writes the loop body. + /// The current writer. + /// writer.WriteFor("int i = 0", "i < count", "i++", body => body.WriteMethodCall("Process", "items[i]")); + public CodeWriter WriteFor(string? initializer, string? condition, string? iterator, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteForScope(initializer, condition, iterator)) + body(this); + return this; + } + + /// + /// Writes a for statement and returns its body scope. + /// + /// The initializer expression, or for none. + /// The condition expression, or for none. + /// The iterator expression, or for none. + /// The loop body scope. + /// using (writer.WriteForScope("int i = 0", "i < count", "i++")) writer.WriteMethodCall("Process", "items[i]"); + public BlockScope WriteForScope(string? initializer, string? condition, string? iterator) + { + Write("for ("); + Write(initializer).Write("; "); + Write(condition).Write("; "); + Write(iterator).WriteLine(")"); + return OpenBlockScope(); + } + + /// + /// Writes a while statement and invokes a callback for its body. + /// + /// The loop condition. + /// The action that writes the loop body. + /// The current writer. + /// writer.WriteWhile("queue.Count > 0", body => body.WriteMethodCall("Process", "queue.Dequeue()")); + public CodeWriter WriteWhile(string condition, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteWhileScope(condition)) + body(this); + return this; + } + + /// + /// Writes a while statement and returns its body scope. + /// + /// The loop condition. + /// The loop body scope. + /// using (writer.WriteWhileScope("queue.Count > 0")) writer.WriteMethodCall("Process", "queue.Dequeue()"); + public BlockScope WriteWhileScope(string condition) + { + ValidateStatementPart(condition, nameof(condition)); + Write("while (").Write(condition).WriteLine(")"); + return OpenBlockScope(); + } + + /// + /// Writes a do-while statement and invokes a callback for its body. + /// + /// The trailing loop condition. + /// The action that writes the loop body. + /// The current writer. + /// writer.WriteDoWhile("!finished", body => body.WriteMethodCall("Advance")); + public CodeWriter WriteDoWhile(string condition, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteDoWhileScope(condition)) + body(this); + return this; + } + + /// + /// Writes a do-while statement and returns its body scope. + /// + /// The trailing loop condition. + /// The loop body scope, which writes } while (condition); when disposed. + /// using (writer.WriteDoWhileScope("!finished")) writer.WriteMethodCall("Advance"); + public BlockScope WriteDoWhileScope(string condition) + { + ValidateStatementPart(condition, nameof(condition)); + return OpenDelimitedBlockScope("do", "{", "} while (" + condition + ");"); + } + + /// + /// Writes a try block and invokes a callback for its body. + /// + /// The action that writes the try body. + /// The current writer. + /// writer.WriteTry(body => body.WriteMethodCall("Run")); + public CodeWriter WriteTry(Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteTryScope()) + body(this); + return this; + } + + /// + /// Writes a try block and returns its body scope. + /// + /// The try body scope. + /// using (writer.WriteTryScope()) writer.WriteMethodCall("Run"); + public BlockScope WriteTryScope() => OpenDelimitedBlockScope("try", "{", "}"); + + /// + /// Writes a catch block and invokes a callback for its body. + /// + /// The action that writes the catch body. + /// The current writer. + /// writer.WriteCatch(body => body.WriteThrow(TypeLibrary.System.InvalidOperationException, "Failed")); + public CodeWriter WriteCatch(Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteCatchScope()) + body(this); + return this; + } + + /// + /// Writes a typed catch block and invokes a callback for its body. + /// + /// The caught exception type, or for a bare catch. + /// The exception variable name, or to omit it. + /// The action that writes the catch body. + /// The current writer. + /// writer.WriteCatch(TypeLibrary.System.Exception, "ex", body => body.WriteMethodCall("Log", "ex")); + public CodeWriter WriteCatch(TypeReference? exceptionType, string? name, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteCatchScope(exceptionType, name)) + body(this); + return this; + } + + /// + /// Writes a catch block and returns its body scope. + /// + /// The caught exception type, or for a bare catch. + /// The exception variable name, or to omit it. + /// The catch body scope. + /// using (writer.WriteCatchScope(TypeLibrary.System.Exception, "ex")) writer.WriteMethodCall("Log", "ex"); + public BlockScope WriteCatchScope(TypeReference? exceptionType = null, string? name = null) + { + Write("catch"); + if (exceptionType is not null) + { + Write(" (").WriteTypeReference(exceptionType); + if (!string.IsNullOrWhiteSpace(name)) + Write(' ').Write(name); + Write(')'); + } + WriteLine(); + return OpenBlockScope(); + } + + /// + /// Writes a finally block and invokes a callback for its body. + /// + /// The action that writes the finally body. + /// The current writer. + /// writer.WriteFinally(body => body.WriteMethodCall("Dispose")); + public CodeWriter WriteFinally(Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteFinallyScope()) + body(this); + return this; + } + + /// + /// Writes a finally block and returns its body scope. + /// + /// The finally body scope. + /// using (writer.WriteFinallyScope()) writer.WriteMethodCall("Dispose"); + public BlockScope WriteFinallyScope() + { + WriteLine("finally"); + return OpenBlockScope(); + } + + /// + /// Writes a using statement and invokes a callback for its body. + /// + /// The resource declaration, such as var stream = Open(). + /// The action that writes the using body. + /// The current writer. + /// writer.WriteUsingStatement("var stream = Open()", body => body.WriteMethodCall("Read", "stream")); + public CodeWriter WriteUsingStatement(string declaration, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteUsingStatementScope(declaration)) + body(this); + return this; + } + + /// + /// Writes a using statement and returns its body scope. + /// + /// The resource declaration, such as var stream = Open(). + /// The using body scope. + /// using (writer.WriteUsingStatementScope("var stream = Open()")) writer.WriteMethodCall("Read", "stream"); + public BlockScope WriteUsingStatementScope(string declaration) + { + ValidateStatementPart(declaration, nameof(declaration)); + Write("using (").Write(declaration).WriteLine(")"); + return OpenBlockScope(); + } + + /// + /// Writes a lock statement and invokes a callback for its body. + /// + /// The lock expression. + /// The action that writes the lock body. + /// The current writer. + /// writer.WriteLockStatement("_gate", body => body.WriteMethodCall("Run")); + public CodeWriter WriteLockStatement(string expression, Action body) + { + if (body is null) + throw new ArgumentNullException(nameof(body)); + using (WriteLockStatementScope(expression)) + body(this); + return this; + } + + /// + /// Writes a lock statement and returns its body scope. + /// + /// The lock expression. + /// The lock body scope. + /// using (writer.WriteLockStatementScope("_gate")) writer.WriteMethodCall("Run"); + public BlockScope WriteLockStatementScope(string expression) + { + ValidateStatementPart(expression, nameof(expression)); + Write("lock (").Write(expression).WriteLine(")"); + return OpenBlockScope(); + } + /// /// Writes a comma-separated collection with one item per line. /// @@ -1938,6 +2747,49 @@ public CodeWriter WriteDelimited(IEnumerable items, string delimiter = return this; } + /// + /// Writes a C# collection expression such as [a, b, ..c], optionally one element per line with + /// the closing bracket on its own unindented line. + /// + /// The element expressions; spread elements such as ..source are passed verbatim. + /// Whether to write one element per line. + /// The current writer. + /// writer.WriteCollectionExpression(["first", "second", "..rest"]); // [first, second, ..rest] + public CodeWriter WriteCollectionExpression(IEnumerable items, bool writeOnSeparateLines = false) + { + if (items is null) + throw new ArgumentNullException(nameof(items)); + + var elements = items.ToArray(); + Write('['); + if (elements.Length == 0) + return Write(']'); + + if (!writeOnSeparateLines) + { + for (var index = 0; index < elements.Length; index++) + { + if (index != 0) + Write(", "); + Write(elements[index]); + } + + return Write(']'); + } + + NewLine().Indent(); + for (var index = 0; index < elements.Length; index++) + { + Write(elements[index]); + if (index != elements.Length - 1) + WriteLine(","); + else + NewLine(); + } + Unindent(); + return Write(']'); + } + /// /// Increases indentation until the returned scope is disposed. /// @@ -1946,11 +2798,22 @@ public CodeWriter WriteDelimited(IEnumerable items, string delimiter = public IndentScope IndentedScope() { Indent(); - return new(this, OpenScope("indentation", header: null, new StackTrace(1, fNeedFileInfo: true).ToString())); + return new( + this, + OpenScope( + "indentation", + header: null, + TracksOpenScopes ? new StackTrace(1, fNeedFileInfo: true).ToString() : string.Empty + ) + ); } - /// Invokes a callback at one additional indentation level. - /// Invokes a callback at one additional indentation level. + /// + /// Invokes a callback at one additional indentation level. + /// + /// + /// Invokes a callback at one additional indentation level. + /// /// The action to invoke while indented. /// The current writer. /// writer.Indented(body => body.WriteLine("value")); @@ -1975,8 +2838,12 @@ public IndentScope IndentedScope(string line) return IndentedScope(); } - /// Writes a line and invokes a callback at one additional indentation level. - /// Writes a line and invokes a callback at one additional indentation level. + /// + /// Writes a line and invokes a callback at one additional indentation level. + /// + /// + /// Writes a line and invokes a callback at one additional indentation level. + /// /// The line to write before indenting. /// The action to invoke while indented. /// The current writer. @@ -2024,20 +2891,27 @@ void WriteIndentIfRequired() return; if (_indentLevel != 0) - _builder.Append(IndentCharacter, _indentLevel); + AppendIndentation(); _atLineStart = false; } + void AppendIndentation() + { + if (_indentCharacter == '\t') + _builder.Append('\t', _indentLevel); + else + _builder.Append(' ', _indentLevel * _indentationSize); + } + void WriteExpression(string? expression, Action? expressionWriter) { var callback = expressionWriter; if (callback is not null) { - CodeWriter expressionWriterBuffer = new(new GenerationSettings(GeneratorName, GeneratorVersion)) - { - DefaultIncludeGeneratedAttributes = DefaultIncludeGeneratedAttributes, - }; + // Expressions are typically short, so use a small buffer and copy this writer's current + // settings directly rather than allocating a fresh GenerationSettings. + CodeWriter expressionWriterBuffer = new(this, DefaultExpressionCapacity); callback!(expressionWriterBuffer); expression = expressionWriterBuffer.ToString().TrimEnd(NewLineCharacter); } @@ -2075,7 +2949,7 @@ void WriteParametersWithHeuristic(ImmutableArray pa inlineLength += GetParameterLength(parameters[index]) + (index == 0 ? 0 : 2); Write('('); - if (inlineLength <= DefaultMaximumLineLength) + if (inlineLength <= _maximumLineLength) { for (var index = 0; index < parameters.Length; index++) { @@ -2090,11 +2964,14 @@ void WriteParametersWithHeuristic(ImmutableArray pa NewLine().Indent(); for (var index = 0; index < parameters.Length; index++) { - WriteParameter(parameters[index]).Write(index == parameters.Length - 1 ? ")" : ","); + WriteParameter(parameters[index]); if (index != parameters.Length - 1) + WriteLine(","); + else NewLine(); } Unindent(); + Write(')'); } CodeWriter WriteParameter(ParameterDeclarationOptions parameter) @@ -2226,12 +3103,16 @@ void WriteMemberModifiers( bool isAbstract, bool isVirtual, bool isOverride, - bool isSealed + bool isSealed, + bool isReadOnly = false, + bool isRequired = false ) { if (accessibility is { } value) WriteAccessibility(value).Write(' '); - WriteIf(isStatic, "static ") + WriteIf(isRequired, "required ") + .WriteIf(isReadOnly, "readonly ") + .WriteIf(isStatic, "static ") .WriteIf(isSealed, "sealed ") .WriteIf(isAbstract, "abstract ") .WriteIf(isVirtual, "virtual ") @@ -2246,7 +3127,8 @@ CodeWriter WritePropertyHeader(PropertyDeclarationOptions declaration) declaration.IsAbstract, declaration.IsVirtual, declaration.IsOverride, - declaration.IsSealed + declaration.IsSealed, + isRequired: declaration.IsRequired ); return WriteTypeReference(declaration.Type).Write(' ').Write(declaration.Name); } @@ -2313,9 +3195,9 @@ int CurrentLineLength { if (_builder[index] == NewLineCharacter) break; - length += _builder[index] == IndentCharacter ? IndentDisplayWidth : 1; + length += _builder[index] == '\t' ? _indentationSize : 1; } - return length + (_atLineStart ? _indentLevel * IndentDisplayWidth : 0); + return length + (_atLineStart ? _indentLevel * _indentationSize : 0); } } @@ -2329,7 +3211,11 @@ BlockScope TrackOpenBlockScope( return new BlockScope( this, closingSeparator, - OpenScope("block", header, new StackTrace(1, fNeedFileInfo: true).ToString()), + OpenScope( + "block", + header, + TracksOpenScopes ? new StackTrace(1, fNeedFileInfo: true).ToString() : string.Empty + ), (int)completedItem, itemIndent ); @@ -2593,6 +3479,12 @@ static void ValidateTypeDeclarationModifiers(TypeDeclarationOptions declaration, "Only struct and record struct declarations can be readonly.", nameof(declaration) ); + + if (declaration.IsRefStruct && declaration.Kind != TypeDeclarationKind.Struct) + throw new ArgumentException("Only struct declarations can be ref structs.", nameof(declaration)); + + if (declaration.IsRefStruct && declaration.BaseType is { IsEmpty: false }) + throw new ArgumentException("A ref struct cannot specify a base type.", nameof(declaration)); } static void ValidateAdditionalTypeKindOptions(TypeDeclarationOptions declaration, bool supportsPrimaryConstructor) @@ -2710,10 +3602,51 @@ static void ValidateMethodDeclaration(MethodDeclarationOptions declaration) "Method parameters cannot contain null or whitespace values.", nameof(declaration) ); + if (declaration.IsReadOnly && declaration.IsStatic) + throw new ArgumentException("A readonly method cannot also be static.", nameof(declaration)); if (declaration.IsAbstract && declaration.ExpressionBody is not null) throw new ArgumentException("An abstract method cannot have an expression body.", nameof(declaration)); } + static void ValidateOperatorDeclaration(OperatorDeclarationOptions declaration) + { + ValidateRequired(declaration.OperatorToken, "Operator token", nameof(declaration)); + ValidateTypeReference(declaration.ReturnType, nameof(declaration)); + ValidateMemberModifiers( + declaration.Accessibility, + isAbstract: false, + isVirtual: false, + isOverride: false, + isSealed: false, + nameof(declaration) + ); + switch (declaration.Kind) + { + case OperatorDeclarationKind.Binary: + ValidateParameters( + [declaration.Left, declaration.Right], + "Operator parameters cannot contain null or whitespace values.", + nameof(declaration) + ); + break; + + case OperatorDeclarationKind.Unary: + case OperatorDeclarationKind.ImplicitConversion: + case OperatorDeclarationKind.ExplicitConversion: + ValidateParameters( + [declaration.Left], + "Operator parameters cannot contain null or whitespace values.", + nameof(declaration) + ); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(declaration)); + } + + ValidateAttributes(declaration.Attributes, nameof(declaration)); + } + static void ValidatePropertyDeclaration(PropertyDeclarationOptions declaration) { ValidateRequired(declaration.Name, "Property name", nameof(declaration)); @@ -2740,6 +3673,35 @@ static void ValidatePropertyDeclaration(PropertyDeclarationOptions declaration) ); if (declaration.IsAbstract && declaration.ExpressionBody is not null) throw new ArgumentException("An abstract property cannot have an expression body.", nameof(declaration)); + if (declaration.IsFieldBacked && declaration.ExpressionBody is not null) + throw new ArgumentException( + "A field-keyword property cannot have an expression body.", + nameof(declaration) + ); + if (declaration.IsFieldBacked && declaration.Initializer is not null) + throw new ArgumentException("A field-keyword property cannot have an initializer.", nameof(declaration)); + } + + static void ValidateIndexerDeclaration(IndexerDeclarationOptions declaration) + { + ValidateTypeReference(declaration.Type, nameof(declaration)); + ValidateMemberModifiers( + declaration.Accessibility, + declaration.IsAbstract, + declaration.IsVirtual, + declaration.IsOverride, + declaration.IsSealed, + nameof(declaration) + ); + ValidateParameters( + declaration.Parameters, + "Indexer parameters cannot contain null or whitespace values.", + nameof(declaration) + ); + if (declaration.ExpressionBody is not null && (declaration.HasSetter || declaration.IsInitOnly)) + throw new ArgumentException("An expression-bodied indexer cannot have a setter.", nameof(declaration)); + if (declaration.IsAbstract && declaration.ExpressionBody is not null) + throw new ArgumentException("An abstract indexer cannot have an expression body.", nameof(declaration)); } static void ValidateFieldDeclaration(FieldDeclarationOptions declaration) @@ -2760,6 +3722,12 @@ static void ValidateFieldDeclaration(FieldDeclarationOptions declaration) throw new ArgumentException("A field cannot be both readonly and volatile.", nameof(declaration)); if (declaration.IsConst && declaration.Initializer is null) throw new ArgumentException("A const field requires an initializer.", nameof(declaration)); + if (declaration.IsRefField && declaration.IsConst) + throw new ArgumentException("A ref field cannot be const.", nameof(declaration)); + if (declaration.IsRefField && declaration.Initializer is not null) + throw new ArgumentException("A ref field cannot have an initializer.", nameof(declaration)); + if (declaration.IsRefField && declaration.IsStatic) + throw new ArgumentException("A ref field cannot be static.", nameof(declaration)); } static void ValidateEnumFieldDeclaration(EnumFieldDeclarationOptions declaration) diff --git a/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs b/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs index ea25d82..1311b3f 100644 --- a/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/ConstructorDeclarationOptions.cs @@ -22,13 +22,17 @@ public ConstructorDeclarationOptions(string name, TypeDeclarationAccessibility? Accessibility = accessibility; } - /// Initializes a constructor declaration from its containing type. + /// + /// 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(TypeIdentity type, TypeDeclarationAccessibility? accessibility = null) : this(type.AsTypeReference(), accessibility) { } - /// Initializes a constructor declaration from its containing type reference. + /// + /// Initializes a constructor declaration from its containing type reference. + /// /// The containing type reference. /// The optional accessibility modifier, or to omit accessibility. public ConstructorDeclarationOptions(TypeReference reference, TypeDeclarationAccessibility? accessibility = null) @@ -37,7 +41,9 @@ public ConstructorDeclarationOptions(TypeReference reference, TypeDeclarationAcc Accessibility = accessibility; } - /// Gets the structured containing type reference. + /// + /// Gets the structured containing type reference. + /// public TypeReference Reference { get; } /// @@ -45,15 +51,25 @@ public ConstructorDeclarationOptions(TypeReference reference, TypeDeclarationAcc /// public TypeDeclarationAccessibility? Accessibility { get; init; } - /// Gets whether a static constructor is emitted. - /// Static constructors cannot declare parameters or an initializer. + /// + /// Gets whether a static constructor is emitted. + /// + /// + /// Static constructors cannot declare parameters or an initializer. + /// public bool IsStatic { get; init; } - /// Gets the constructor parameters. - /// Each entry is emitted verbatim as a complete parameter declaration. + /// + /// Gets the constructor parameters. + /// + /// + /// Each entry is emitted verbatim as a complete parameter declaration. + /// public ImmutableArray Parameters { get; init; } - /// Gets attributes applied to the constructor. + /// + /// Gets attributes applied to the constructor. + /// public ImmutableArray Attributes { get; init; } /// @@ -61,7 +77,9 @@ public ConstructorDeclarationOptions(TypeReference reference, TypeDeclarationAcc /// public bool WriteParametersOnSeparateLines { get; init; } - /// Gets the optional constructor initializer without the leading colon. + /// + /// Gets the optional constructor initializer without the leading colon. + /// /// base(connectionString) or this("Default"). public string? Initializer { get; init; } diff --git a/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs b/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs index 8da7000..53b7cfb 100644 --- a/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/EnumFieldDeclarationOptions.cs @@ -2,10 +2,14 @@ namespace Purview.SourceGeneratorFramework; -/// Describes a field in a generated enum declaration. +/// +/// Describes a field in a generated enum declaration. +/// public readonly record struct EnumFieldDeclarationOptions { - /// Initializes an enum field declaration. + /// + /// Initializes an enum field declaration. + /// /// The enum field name. /// /// The enum field value. Strings are emitted as C# expressions; other values are @@ -21,7 +25,9 @@ public EnumFieldDeclarationOptions(string fieldName, object fieldValue, params s FieldValue = fieldValue; } - /// Initializes an enum field declaration. + /// + /// 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) @@ -33,7 +39,9 @@ public EnumFieldDeclarationOptions(string fieldName, params string[] xmlSummary) XmlSummary = [.. xmlSummary ?? []]; } - /// Gets the enum field name. + /// + /// Gets the enum field name. + /// public string FieldName { get; } /// @@ -42,9 +50,13 @@ public EnumFieldDeclarationOptions(string fieldName, params string[] xmlSummary) /// public object? FieldValue { get; } - /// Gets the lines written in the field's XML summary block. + /// + /// Gets the lines written in the field's XML summary block. + /// public ImmutableArray XmlSummary { get; init; } = []; - /// Gets the attributes applied to the enum field. + /// + /// Gets the attributes applied to the enum field. + /// public ImmutableArray Attributes { get; init; } = []; } diff --git a/src/src/SourceGeneratorShared/GenerationContext.cs b/src/src/SourceGeneratorShared/GenerationContext.cs index ed1f436..4db30d3 100644 --- a/src/src/SourceGeneratorShared/GenerationContext.cs +++ b/src/src/SourceGeneratorShared/GenerationContext.cs @@ -6,7 +6,9 @@ namespace Purview.SourceGeneratorFramework; /// Provides execution services for source generation, including the compilation, immutable /// settings, optional logging, and symbol-resolution helpers. /// -/// Initializes a generation context. +/// +/// Initializes a generation context. +/// public sealed class GenerationContext( TCapabilities Capabilities, GenerationSettings Settings, @@ -19,7 +21,9 @@ public sealed class GenerationContext( /// public TCapabilities Capabilities { get; } = Capabilities ?? throw new ArgumentNullException(nameof(Capabilities)); - /// Gets the immutable generation settings. + /// + /// Gets the immutable generation settings. + /// public GenerationSettings Settings { get; } = Settings ?? throw new ArgumentNullException(nameof(Settings)); /// @@ -28,7 +32,9 @@ public sealed class GenerationContext( /// public ISourceGenLogger? Logger { get; } = Logger; - /// Creates a new independently owned code writer. + /// + /// Creates a new independently owned code writer. + /// public CodeWriter CreateCodeWriter() => new(Settings, throwOnUnclosedScopes: Settings.ValidateCodeWriterScopes); /// diff --git a/src/src/SourceGeneratorShared/GenerationSettings.cs b/src/src/SourceGeneratorShared/GenerationSettings.cs index 8440c3e..6ec7910 100644 --- a/src/src/SourceGeneratorShared/GenerationSettings.cs +++ b/src/src/SourceGeneratorShared/GenerationSettings.cs @@ -1,9 +1,15 @@ +using Microsoft.CodeAnalysis.CSharp; + namespace Purview.SourceGeneratorFramework; -/// Describes immutable settings shared by a source-generation operation. +/// +/// Describes immutable settings shared by a source-generation operation. +/// public sealed record GenerationSettings { - /// Initializes source-generation settings. + /// + /// 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. @@ -21,13 +27,19 @@ public GenerationSettings( DisabledSourceGenMSBuildProperty = disabledSourceGenMSBuildProperty; } - /// Gets the source generator name propagated to code writers. + /// + /// Gets the source generator name propagated to code writers. + /// public string GeneratorName { get; } - /// Gets the source generator version propagated to code writers. + /// + /// 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. + /// + /// Gets the optional MSBuild property name that disables the generator when set to true. + /// public string? DisabledSourceGenMSBuildProperty { get; } /// @@ -45,15 +57,45 @@ public GenerationSettings( /// public bool? IsNullableContextEnabled { get; init; } - /// Gets whether created code writers validate undisposed scopes. + /// + /// Gets whether created code writers validate undisposed scopes. + /// public bool ValidateCodeWriterScopes { get; init; } - /// Gets whether the source generator is disabled by build configuration. + /// + /// 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. + /// + /// Gets whether source-generator logging is active for this generation context. + /// public bool IsLoggingEnabled { get; init; } + /// + /// Gets how generated code is indented. The default is . + /// + public IndentationStyle IndentationStyle { get; init; } = IndentationStyle.Tabs; + + /// + /// Gets the indentation width: the number of spaces used per level when + /// is , or the display width of a + /// single tab when it is . The default is 4. + /// + public int IndentationSize { get; init; } = 4; + + /// + /// Gets the maximum line length that drives inline-versus-multiline wrapping heuristics. The default is 100. + /// + public int MaximumLineLength { get; init; } = 100; + + /// + /// Gets the C# language version of the target compilation, when it is known. Generators can use this to + /// gate emitted features such as primary constructors or collection expressions. It is seeded from the + /// consuming project's LangVersion when available. + /// + public LanguageVersion? LanguageVersion { get; init; } + /// /// Creates a new generation settings instance for the specified generator type, using the type name and assembly version. /// diff --git a/src/src/SourceGeneratorShared/GeneratorResult.cs b/src/src/SourceGeneratorShared/GeneratorResult.cs index e697b5c..da66473 100644 --- a/src/src/SourceGeneratorShared/GeneratorResult.cs +++ b/src/src/SourceGeneratorShared/GeneratorResult.cs @@ -12,7 +12,9 @@ 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. + /// + /// This value can be null or default(T) if the result is a failure. + /// public T Value { get; private init; } /// diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs index 9a1d9ab..8f362e2 100644 --- a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs @@ -107,6 +107,7 @@ public static IncrementalValueProvider< IsLoggingEnabled = logger is not null, IsNullableContextEnabled = settings.IsNullableContextEnabled ?? IsNullableContextEnabled(compilation), + LanguageVersion = configuration.LanguageVersion ?? settings.LanguageVersion, }; var capabilities = factory(compilation, resolvedSettings, logger, cancellationToken); @@ -127,6 +128,7 @@ public static IncrementalValueProvider< if (compilation is not CSharpCompilation csharpCompilation) return null; + // Nullable annotations are enabled when the compilation's nullable context options are either return csharpCompilation.Options.NullableContextOptions is NullableContextOptions.Annotations or NullableContextOptions.Enable; @@ -152,6 +154,15 @@ out var loggingEnabledValue SourceGeneratorBuildProperties.LoggingSessionId, out var loggingSessionId ); + if ( + !options.GlobalOptions.TryGetValue( + SourceGeneratorBuildProperties.LanguageVersion, + out var languageVersionValue + ) + ) + { + options.GlobalOptions.TryGetValue("build_property.LangVersion", out languageVersionValue); + } string? disabledValue = null; if (!string.IsNullOrWhiteSpace(disablePropertyName)) @@ -170,12 +181,18 @@ out var loggingSessionId && validateScopes, IsSourceGeneratorDisabled: bool.TryParse(disabledValue, out var isDisabled) && isDisabled, IsLoggingEnabled: bool.TryParse(loggingEnabledValue, out var loggingEnabled) && loggingEnabled, - LoggingSessionId: loggingSessionId + LoggingSessionId: loggingSessionId, + LanguageVersion: TryParseLanguageVersion(languageVersionValue) ); } ) .WithTrackingName("GetGenerationConfiguration"); + static LanguageVersion? TryParseLanguageVersion(string? value) => + string.IsNullOrWhiteSpace(value) || !Enum.TryParse(value, ignoreCase: true, out LanguageVersion parsed) + ? null + : parsed; + /// /// Creates a values provider for syntax nodes annotated with a specific attribute. /// @@ -202,6 +219,7 @@ readonly record struct GenerationConfiguration( bool ValidateCodeWriterScopes, bool IsSourceGeneratorDisabled, bool IsLoggingEnabled, - string? LoggingSessionId + string? LoggingSessionId, + LanguageVersion? LanguageVersion ); } diff --git a/src/src/SourceGeneratorShared/IndentationStyle.cs b/src/src/SourceGeneratorShared/IndentationStyle.cs new file mode 100644 index 0000000..d31fb7e --- /dev/null +++ b/src/src/SourceGeneratorShared/IndentationStyle.cs @@ -0,0 +1,17 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies how generated code is indented. +/// +public enum IndentationStyle +{ + /// + /// Indents with tab characters. This is the default. + /// + Tabs, + + /// + /// Indents with the configured number of space characters. + /// + Spaces, +} diff --git a/src/src/SourceGeneratorShared/IndexerDeclarationOptions.cs b/src/src/SourceGeneratorShared/IndexerDeclarationOptions.cs new file mode 100644 index 0000000..cfb33fa --- /dev/null +++ b/src/src/SourceGeneratorShared/IndexerDeclarationOptions.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Describes a generated indexer declaration. +/// +public readonly record struct IndexerDeclarationOptions +{ + /// + /// Creates an indexer declaration. + /// + /// The indexer element type. + /// The indexer parameters. + public IndexerDeclarationOptions(TypeReference type, params ParameterDeclarationOptions[] parameters) + { + if (type.IsNullOrEmpty()) + throw new ArgumentException("Indexer type cannot be empty.", nameof(type)); + + Type = type; + Parameters = parameters is null ? [] : [.. parameters]; + } + + /// + /// Gets the indexer element type. + /// + public TypeReference Type { get; } + + /// + /// Gets the indexer parameters. + /// + public ImmutableArray Parameters { get; } + + /// + /// Gets the optional accessibility modifier. + /// + public TypeDeclarationAccessibility? Accessibility { get; init; } + + /// + /// Gets whether the indexer is static. + /// + public bool IsStatic { get; init; } + + /// + /// Gets whether the indexer is abstract. + /// + public bool IsAbstract { get; init; } + + /// + /// Gets whether the indexer is virtual. + /// + public bool IsVirtual { get; init; } + + /// + /// Gets whether the indexer is an override. + /// + public bool IsOverride { get; init; } + + /// + /// Gets whether the indexer is sealed. + /// + public bool IsSealed { get; init; } + + /// + /// Gets whether a getter is emitted. The default is . + /// + public bool HasGetter { get; init; } = true; + + /// + /// Gets whether a setter or init accessor is emitted. + /// + public bool HasSetter { get; init; } + + /// + /// Gets whether the setter is emitted as an init accessor. + /// + public bool IsInitOnly { get; init; } + + /// + /// Gets optional getter accessibility. + /// + public TypeDeclarationAccessibility? GetterAccessibility { get; init; } + + /// + /// Gets optional setter accessibility. + /// + public TypeDeclarationAccessibility? SetterAccessibility { get; init; } + + /// + /// Gets an optional expression body without the leading =>. + /// + public string? ExpressionBody { get; init; } + + /// + /// Gets attributes applied to the indexer. + /// + public ImmutableArray Attributes { get; init; } + + /// + /// Gets whether to emit generated attributes. When , the value is inherited from + /// . + /// + public bool? IncludeGeneratedAttributes { get; init; } +} diff --git a/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs b/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs index ff2a213..a5b87c2 100644 --- a/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs +++ b/src/src/SourceGeneratorShared/Logging/SourceGenLogging.cs @@ -3,12 +3,16 @@ namespace Purview.SourceGeneratorFramework.Logging; -/// Registers output sinks for isolated source-generator logging sessions. +/// +/// Registers output sinks for isolated source-generator logging sessions. +/// public static class SourceGenLogging { static readonly ConcurrentDictionary> Sinks = new(); - /// Registers a sink for a source-generator logging session. + /// + /// Registers a sink for a source-generator logging session. + /// public static IDisposable RegisterSink(string sessionId, Action sink) { if (string.IsNullOrWhiteSpace(sessionId)) diff --git a/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs b/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs index d0be63f..e0665bf 100644 --- a/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/MemberDeclarationOptions.cs @@ -2,10 +2,14 @@ namespace Purview.SourceGeneratorFramework; -/// Describes a generated method declaration. +/// +/// Describes a generated method declaration. +/// public readonly record struct MethodDeclarationOptions { - /// Creates a method declaration. + /// + /// Creates a method declaration. + /// /// The method name. /// The return type. The default is void. /// The optional accessibility. @@ -23,58 +27,98 @@ public MethodDeclarationOptions( Accessibility = accessibility; } - /// Creates a method declaration with the set to void. + /// + /// Creates a method declaration with the set to void. + /// /// The method name. /// The optional accessibility. public MethodDeclarationOptions(string name, TypeDeclarationAccessibility? accessibility = null) : this(name, PurviewTypeLibrary.System.Void, accessibility) { } - /// Gets the method name. + /// + /// Gets the method name. + /// public string Name { get; } - /// Gets the return type. + /// + /// Gets the return type. + /// public TypeReference ReturnType { get; } - /// Gets the optional accessibility. + /// + /// Gets the optional accessibility. + /// public TypeDeclarationAccessibility? Accessibility { get; init; } - /// Gets whether the method is static. + /// + /// Gets whether the method is static. + /// public bool IsStatic { get; init; } - /// Gets whether the method is partial. + /// + /// Gets whether the method is partial. + /// public bool IsPartial { get; init; } - /// Gets whether the method is abstract. + /// + /// Gets whether the method is abstract. + /// public bool IsAbstract { get; init; } - /// Gets whether the method is virtual. + /// + /// Gets whether the method is virtual. + /// public bool IsVirtual { get; init; } - /// Gets whether the method is an override. + /// + /// Gets whether the method is an override. + /// public bool IsOverride { get; init; } - /// Gets whether the method is sealed. + /// + /// Gets whether the method is sealed. + /// public bool IsSealed { get; init; } - /// Gets whether the method is asynchronous. + /// + /// Gets whether the method is asynchronous. + /// public bool IsAsync { get; init; } - /// Gets whether the method is unsafe. + /// + /// Gets whether the method is unsafe. + /// public bool IsUnsafe { get; init; } - /// Gets the complete parameter declarations. + /// + /// Gets whether the method is emitted as a readonly instance method, which is only valid + /// on members of struct or record struct declarations. + /// + public bool IsReadOnly { get; init; } + + /// + /// Gets the complete parameter declarations. + /// public ImmutableArray Parameters { get; init; } - /// Gets attributes applied to the method. + /// + /// Gets attributes applied to the method. + /// public ImmutableArray Attributes { get; init; } - /// Gets attributes applied to the return value. + /// + /// Gets attributes applied to the return value. + /// public ImmutableArray ReturnAttributes { get; init; } - /// Gets generic parameters and constraints. + /// + /// Gets generic parameters and constraints. + /// public ImmutableArray GenericTypes { get; init; } - /// Gets an optional expression body without the leading =>. + /// + /// Gets an optional expression body without the leading =>. + /// public string? ExpressionBody { get; init; } /// @@ -85,10 +129,14 @@ public MethodDeclarationOptions(string name, TypeDeclarationAccessibility? acces public bool? IncludeGeneratedAttributes { get; init; } } -/// Describes a generated property declaration. +/// +/// Describes a generated property declaration. +/// public readonly record struct PropertyDeclarationOptions { - /// Creates a property declaration. + /// + /// Creates a property declaration. + /// public PropertyDeclarationOptions( string name, TypeReference type, @@ -100,34 +148,60 @@ public PropertyDeclarationOptions( Accessibility = accessibility; } - /// Gets the property name. + /// + /// Gets the property name. + /// public string Name { get; } - /// Gets the property type. + /// + /// Gets the property type. + /// public TypeReference Type { get; } - /// Gets the optional accessibility. + /// + /// Gets the optional accessibility. + /// public TypeDeclarationAccessibility? Accessibility { get; init; } - /// Gets whether the property is static. + /// + /// Gets whether the property is static. + /// public bool IsStatic { get; init; } - /// Gets whether the property is abstract. + /// + /// Gets whether the required modifier is emitted, marking the property as required at + /// object-initializer time (C# 11+). + /// + public bool IsRequired { get; init; } + + /// + /// Gets whether the property is abstract. + /// public bool IsAbstract { get; init; } - /// Gets whether the property is virtual. + /// + /// Gets whether the property is virtual. + /// public bool IsVirtual { get; init; } - /// Gets whether the property is an override. + /// + /// Gets whether the property is an override. + /// public bool IsOverride { get; init; } - /// Gets whether the property is sealed. + /// + /// Gets whether the property is sealed. + /// public bool IsSealed { get; init; } - /// Gets whether a getter is emitted. The default is . + /// + /// Gets whether a getter is emitted. The default is . + /// public bool HasGetter { get; init; } = true; - /// Gets whether a setter or init accessor is emitted. + /// + /// Gets whether a setter or init accessor is emitted. + /// public bool HasSetter { get; init; } /// @@ -136,19 +210,38 @@ public PropertyDeclarationOptions( /// public bool IsInitOnly { get; init; } - /// Gets optional getter accessibility. + /// + /// Gets whether the property is emitted as a C# 14 field-keyword semi-auto property whose accessors + /// reference the implicit backing field, such as get => field; set => field = value;. + /// + /// + /// Incompatible with , , and accessor bodies. + /// + public bool IsFieldBacked { get; init; } + + /// + /// Gets optional getter accessibility. + /// public TypeDeclarationAccessibility? GetterAccessibility { get; init; } - /// Gets optional setter accessibility. + /// + /// Gets optional setter accessibility. + /// public TypeDeclarationAccessibility? SetterAccessibility { get; init; } - /// Gets an optional expression body without the leading =>. + /// + /// Gets an optional expression body without the leading =>. + /// public string? ExpressionBody { get; init; } - /// Gets an optional initializer without the leading equals sign. + /// + /// Gets an optional initializer without the leading equals sign. + /// public string? Initializer { get; init; } - /// Gets attributes applied to the property. + /// + /// Gets attributes applied to the property. + /// public ImmutableArray Attributes { get; init; } /// @@ -159,10 +252,14 @@ public PropertyDeclarationOptions( public bool? IncludeGeneratedAttributes { get; init; } } -/// Describes a generated field declaration. +/// +/// Describes a generated field declaration. +/// public readonly record struct FieldDeclarationOptions { - /// Creates a field declaration. + /// + /// Creates a field declaration. + /// public FieldDeclarationOptions(string name, TypeReference type, TypeDeclarationAccessibility? accessibility = null) { Name = name; @@ -170,31 +267,61 @@ public FieldDeclarationOptions(string name, TypeReference type, TypeDeclarationA Accessibility = accessibility; } - /// Gets the field name. + /// + /// Gets the field name. + /// public string Name { get; } - /// Gets the field type. + /// + /// Gets the field type. + /// public TypeReference Type { get; } - /// Gets the optional accessibility. + /// + /// Gets the optional accessibility. + /// public TypeDeclarationAccessibility? Accessibility { get; init; } - /// Gets whether the field is static. + /// + /// Gets whether the field is static. + /// public bool IsStatic { get; init; } - /// Gets whether the field is readonly. + /// + /// Gets whether the required modifier is emitted, marking the field as required at + /// object-initializer time (C# 11+). + /// + public bool IsRequired { get; init; } + + /// + /// Gets whether the field is readonly. + /// public bool IsReadOnly { get; init; } - /// Gets whether the field is constant. + /// + /// Gets whether the ref modifier is emitted before the field type, producing a ref field. + /// Only valid in ref struct declarations and incompatible with constants. + /// + public bool IsRefField { get; init; } + + /// + /// Gets whether the field is constant. + /// public bool IsConst { get; init; } - /// Gets whether the field is volatile. + /// + /// Gets whether the field is volatile. + /// public bool IsVolatile { get; init; } - /// Gets an optional initializer without the leading equals sign. + /// + /// Gets an optional initializer without the leading equals sign. + /// public string? Initializer { get; init; } - /// Gets attributes applied to the field. + /// + /// Gets attributes applied to the field. + /// public ImmutableArray Attributes { get; init; } /// diff --git a/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs b/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs index baf3e62..39f8070 100644 --- a/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs +++ b/src/src/SourceGeneratorShared/MethodCallArgumentOptions.cs @@ -1,9 +1,13 @@ namespace Purview.SourceGeneratorFramework; -/// Describes one argument supplied to a generated method call. +/// +/// Describes one argument supplied to a generated method call. +/// public readonly record struct MethodCallArgumentOptions { - /// Creates a method-call argument from its value expression. + /// + /// Creates a method-call argument from its value expression. + /// /// The argument expression or variable name. /// An optional named-argument label. /// The argument passing modifier. @@ -28,13 +32,19 @@ public MethodCallArgumentOptions( public MethodCallArgumentOptions(string value, ParameterModifier modifier) : this(value, null, modifier) { } - /// Gets the argument expression or variable name. + /// + /// Gets the argument expression or variable name. + /// public string Value { get; } - /// Gets an optional named-argument label. + /// + /// Gets an optional named-argument label. + /// public string? Name { get; init; } - /// Gets the argument passing modifier. + /// + /// 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/NullableDirectiveMode.cs b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs index 60e9cfb..dde0c05 100644 --- a/src/src/SourceGeneratorShared/NullableDirectiveMode.cs +++ b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs @@ -13,9 +13,13 @@ public enum NullableDirectiveMode /// Auto = 0, - /// Always emits the #nullable enable directive. + /// + /// Always emits the #nullable enable directive. + /// Always = 1, - /// Never emits the #nullable enable directive. + /// + /// Never emits the #nullable enable directive. + /// Disable = 2, } diff --git a/src/src/SourceGeneratorShared/ObjectCreationOptions.cs b/src/src/SourceGeneratorShared/ObjectCreationOptions.cs index ac78ae1..e67155a 100644 --- a/src/src/SourceGeneratorShared/ObjectCreationOptions.cs +++ b/src/src/SourceGeneratorShared/ObjectCreationOptions.cs @@ -2,10 +2,21 @@ namespace Purview.SourceGeneratorFramework; -/// Describes a generated object-creation expression. +/// +/// Describes one member assignment in a generated object-initializer expression. +/// +/// The member name. +/// The assigned value expression. +public readonly record struct ObjectInitializerMemberOptions(string Name, string? Value); + +/// +/// Describes a generated object-creation expression. +/// public readonly record struct ObjectCreationOptions { - /// Creates an object-creation expression. + /// + /// Creates an object-creation expression. + /// /// The type to instantiate. /// The constructor arguments; strings are implicitly supported. public ObjectCreationOptions(TypeReference reference, params MethodCallArgumentOptions[] arguments) @@ -17,12 +28,29 @@ public ObjectCreationOptions(TypeReference reference, params MethodCallArgumentO Arguments = arguments is null ? [] : [.. arguments]; } - /// Gets the type to instantiate. + /// + /// Gets the type to instantiate. + /// public TypeReference Reference { get; } - /// Gets the constructor arguments. + /// + /// Gets the constructor arguments. + /// public ImmutableArray Arguments { get; } - /// Gets whether constructor arguments are written one per line. + /// + /// Gets whether constructor arguments are written one per line. + /// public bool WriteArgumentsOnSeparateLines { get; init; } + + /// + /// Gets the object-initializer member assignments written after the constructor arguments. + /// + public ImmutableArray InitializerMembers { get; init; } + + /// + /// Gets whether object-initializer members are written one per line with a trailing comma. + /// The default is . + /// + public bool WriteInitializerMembersOnSeparateLines { get; init; } = true; } diff --git a/src/src/SourceGeneratorShared/OperatorDeclarationOptions.cs b/src/src/SourceGeneratorShared/OperatorDeclarationOptions.cs new file mode 100644 index 0000000..73d3148 --- /dev/null +++ b/src/src/SourceGeneratorShared/OperatorDeclarationOptions.cs @@ -0,0 +1,71 @@ +using System.Collections.Immutable; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Identifies the shape of a generated operator declaration. +/// +public enum OperatorDeclarationKind +{ + /// + /// A binary operator such as ==, <, or +, taking two operands. + /// + Binary, + + /// + /// A unary operator such as - or !, taking a single operand. + /// + Unary, + + /// + /// An implicit conversion operator. + /// + ImplicitConversion, + + /// + /// An explicit conversion operator. + /// + ExplicitConversion, +} + +/// +/// Describes a generated operator declaration. +/// +/// +/// The operator token such as ==, <, or <=. Ignored for conversion operators. +/// +/// +/// The operator return type: the result for binary/unary operators, or the target type for conversion operators. +/// +/// +/// The left operand parameter, or the single source parameter for unary and conversion operators. +/// +/// The right operand parameter. Only used for . +/// The optional accessibility modifier, or to omit accessibility. +/// +/// Whether the static keyword is emitted. Operators are implicitly static; the default is +/// and matches the convention of explicit static emission. +/// +/// An optional expression body without the leading =>. +/// Attributes applied to the operator. +/// +/// Whether to emit generated attributes. When , the value is inherited from +/// . +/// +public readonly record struct OperatorDeclarationOptions( + string OperatorToken, + TypeReference ReturnType, + ParameterDeclarationOptions Left, + ParameterDeclarationOptions Right = default, + TypeDeclarationAccessibility? Accessibility = null, + bool IsStatic = true, + string? ExpressionBody = null, + ImmutableArray Attributes = default, + bool? IncludeGeneratedAttributes = null +) +{ + /// + /// Gets the operator shape, which controls how the header is emitted. + /// + public OperatorDeclarationKind Kind { get; init; } = OperatorDeclarationKind.Binary; +} diff --git a/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs b/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs index 9db0200..c2f888d 100644 --- a/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/ParameterDeclarationOptions.cs @@ -2,10 +2,14 @@ namespace Purview.SourceGeneratorFramework; -/// Describes a generated method, constructor, delegate, or primary-constructor parameter. +/// +/// Describes a generated method, constructor, delegate, or primary-constructor parameter. +/// public readonly record struct ParameterDeclarationOptions { - /// Creates a parameter declaration. + /// + /// Creates a parameter declaration. + /// public ParameterDeclarationOptions( string name, TypeReference reference, @@ -17,27 +21,43 @@ public ParameterDeclarationOptions( Modifier = modifier; } - /// Gets the parameter name. + /// + /// Gets the parameter name. + /// public string Name { get; } - /// Gets the parameter type. + /// + /// Gets the parameter type. + /// public TypeReference Reference { get; } - /// Gets the parameter passing modifier. + /// + /// Gets the parameter passing modifier. + /// public ParameterModifier Modifier { get; init; } - /// Gets whether this is emitted for an extension receiver. + /// + /// Gets whether this is emitted for an extension receiver. + /// public bool IsThis { get; init; } - /// Gets whether params is emitted. + /// + /// Gets whether params is emitted. + /// public bool IsParams { get; init; } - /// Gets whether scoped is emitted. + /// + /// Gets whether scoped is emitted. + /// public bool IsScoped { get; init; } - /// Gets an optional default-value expression. + /// + /// Gets an optional default-value expression. + /// public string? DefaultValue { get; init; } - /// Gets attributes applied to the parameter. + /// + /// 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 index 0ad6731..26b8fd7 100644 --- a/src/src/SourceGeneratorShared/ParameterModifier.cs +++ b/src/src/SourceGeneratorShared/ParameterModifier.cs @@ -1,20 +1,32 @@ namespace Purview.SourceGeneratorFramework; -/// Identifies a generated parameter modifier. +/// +/// Identifies a generated parameter modifier. +/// public enum ParameterModifier { - /// No modifier. + /// + /// No modifier. + /// None, - /// The ref modifier. + /// + /// The ref modifier. + /// Ref, - /// The out modifier. + /// + /// The out modifier. + /// Out, - /// The in modifier. + /// + /// The in modifier. + /// In, - /// The ref readonly modifier. + /// + /// The ref readonly modifier. + /// RefReadOnly, } diff --git a/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs b/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs index 3163667..06076d0 100644 --- a/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs +++ b/src/src/SourceGeneratorShared/ResolvedTypeInformation.cs @@ -12,6 +12,8 @@ public readonly record struct ResolvedTypeInformation(TypeReference Reference, A /// /// Gets the type value object associated with the resolved type reference. /// - /// Retrieved from + /// + /// Retrieved from + /// public TypeIdentity Type => Reference.Identity; } diff --git a/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs b/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs index 4a9e56e..6c3bb83 100644 --- a/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs +++ b/src/src/SourceGeneratorShared/SourceGeneratorBuildProperties.cs @@ -22,4 +22,10 @@ public static class SourceGeneratorBuildProperties /// The MSBuild property that identifies the registered logging sink for a generator run. /// public const string LoggingSessionId = BuildProperty + "PurviewSourceGeneratorFrameworkLoggingSessionId"; + + /// + /// The MSBuild property that carries the consuming project's LangVersion so generators can gate + /// emitted features. Falls back to the standard build_property.LangVersion when not configured. + /// + public const string LanguageVersion = BuildProperty + "PurviewSourceGeneratorFrameworkLanguageVersion"; } diff --git a/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs b/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs index 8b1e623..9923b0a 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationAccessibility.cs @@ -5,24 +5,38 @@ namespace Purview.SourceGeneratorFramework; /// public enum TypeDeclarationAccessibility { - /// The public accessibility modifier. + /// + /// The public accessibility modifier. + /// Public, - /// The internal accessibility modifier. + /// + /// The internal accessibility modifier. + /// Internal, - /// The protected accessibility modifier. + /// + /// The protected accessibility modifier. + /// Protected, - /// The private accessibility modifier. + /// + /// The private accessibility modifier. + /// Private, - /// The protected internal accessibility modifier. + /// + /// The protected internal accessibility modifier. + /// ProtectedInternal, - /// The private protected accessibility modifier. + /// + /// The private protected accessibility modifier. + /// PrivateProtected, - /// The file accessibility modifier. + /// + /// The file accessibility modifier. + /// File, } diff --git a/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs b/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs index 7949b37..d967f00 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationAccessibilityExtensions.cs @@ -15,7 +15,9 @@ public static class TypeDeclarationAccessibilityExtensions /// The corresponding declaration accessibility, or when Roslyn reports /// or an unknown future value. /// - /// This method never throws for an accessibility 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 diff --git a/src/src/SourceGeneratorShared/TypeDeclarationKind.cs b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs index c4e3406..7d587fb 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationKind.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationKind.cs @@ -5,24 +5,38 @@ namespace Purview.SourceGeneratorFramework; /// public enum TypeDeclarationKind { - /// A class declaration. + /// + /// A class declaration. + /// Class, - /// A struct declaration. + /// + /// A struct declaration. + /// Struct, - /// A record class declaration. + /// + /// A record class declaration. + /// RecordClass, - /// A record struct declaration. + /// + /// A record struct declaration. + /// RecordStruct, - /// An interface declaration. + /// + /// An interface declaration. + /// Interface, - /// An enum declaration. + /// + /// An enum declaration. + /// Enum, - /// A delegate declaration. + /// + /// A delegate declaration. + /// Delegate, } diff --git a/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs index 94e3c24..daf34d3 100644 --- a/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs +++ b/src/src/SourceGeneratorShared/TypeDeclarationOptions.cs @@ -82,19 +82,33 @@ public TypeDeclarationOptions(TypeIdentity type, TypeDeclarationAccessibility? a /// public bool IsReadOnly { get; init; } + /// + /// Gets whether the ref modifier is emitted, producing a ref struct declaration. + /// Only valid for . + /// + public bool IsRefStruct { get; init; } + /// /// Gets the optional base class or base record type. /// - /// Struct and record struct declarations cannot specify a base type. + /// + /// Struct and record struct declarations cannot specify a base type. + /// public TypeReference? BaseType { get; init; } - /// Gets the optional enum underlying integral type. + /// + /// Gets the optional enum underlying integral type. + /// public TypeReference? EnumUnderlyingType { get; init; } - /// Gets the delegate return type. + /// + /// Gets the delegate return type. + /// public TypeReference? DelegateReturnType { get; init; } - /// Gets the complete delegate parameter declarations. + /// + /// Gets the complete delegate parameter declarations. + /// public ImmutableArray DelegateParameters { get; init; } = []; /// @@ -110,7 +124,9 @@ public TypeDeclarationOptions(TypeIdentity type, TypeDeclarationAccessibility? a /// /// Gets the primary-constructor parameters written after the type name and generic parameters. /// - /// Each entry is emitted verbatim as a complete parameter declaration. + /// + /// Each entry is emitted verbatim as a complete parameter declaration. + /// public ImmutableArray PrimaryConstructorParameters { get; init; } = []; /// diff --git a/src/src/SourceGeneratorShared/TypeIdentity.cs b/src/src/SourceGeneratorShared/TypeIdentity.cs index e274362..e7c67a8 100644 --- a/src/src/SourceGeneratorShared/TypeIdentity.cs +++ b/src/src/SourceGeneratorShared/TypeIdentity.cs @@ -279,10 +279,14 @@ public TypeIdentity(SpecialType specialType) /// public bool IsGenericTypeDefinition => GenericArity > 0 && TypeArguments.IsDefaultOrEmpty; - /// Gets a value indicating whether the type is nested inside another type. + /// + /// 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. + /// + /// Gets a value indicating whether the type is in the global namespace. + /// public bool IsGlobalNamespace => Namespace is null; /// @@ -461,10 +465,14 @@ public bool Matches(ITypeSymbol? other) /// public bool Equals(ITypeSymbol? other) => Matches(other); - /// Determines whether the specified runtime type represents the same semantic type. + /// + /// 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. + /// + /// 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); /// @@ -524,7 +532,9 @@ public bool Similar(TypeIdentity other) => public static bool operator ==(TypeIdentity left, TypeReference? right) => right is not null && right.IsPlainNamedType && right.Identity.Equals(left); - /// Negates . + /// + /// Negates . + /// public static bool operator !=(TypeIdentity left, TypeReference? right) => !(left == right); /// @@ -565,10 +575,14 @@ public override int GetHashCode() // Composition // --------------------------------------------------------------------------------------------- - /// Creates the canonical source-generation type reference for this type. + /// + /// Creates the canonical source-generation type reference for this type. + /// public TypeReference AsTypeReference() => new(this); - /// Creates a nullable structured type reference. + /// + /// Creates a nullable structured type reference. + /// public TypeReference MakeNullable() => AsTypeReference().Nullable(); /// @@ -595,10 +609,14 @@ public override int GetHashCode() /// The modified type reference. public TypeReference MakeNullable(Compilation compilation) => AsTypeReference().Nullable(compilation); - /// Creates an array structured type reference with the specified rank. + /// + /// 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. + /// + /// Creates a pointer structured type reference. + /// public TypeReference MakePointer() => AsTypeReference().MakePointer(); /// @@ -711,7 +729,9 @@ public TypeIdentity MakeGeneric(params TypeReference[] typeArguments) // Factories // --------------------------------------------------------------------------------------------- - /// Gets an empty . + /// + /// Gets an empty . + /// public static readonly TypeIdentity Empty; /// diff --git a/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs b/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs index 0617f32..dc65a8c 100644 --- a/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs +++ b/src/src/SourceGeneratorShared/TypeIdentityExtensions.cs @@ -5,7 +5,9 @@ namespace Purview.SourceGeneratorFramework; [EditorBrowsable(EditorBrowsableState.Never)] public static class TypeIdentityExtensions { - /// Returns a fully qualified reference to a static member on the specified type. + /// + /// Returns a fully qualified reference to a static member on the specified type. + /// /// 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. diff --git a/src/src/SourceGeneratorShared/TypeModifier.cs b/src/src/SourceGeneratorShared/TypeModifier.cs index aa15cb0..080a65f 100644 --- a/src/src/SourceGeneratorShared/TypeModifier.cs +++ b/src/src/SourceGeneratorShared/TypeModifier.cs @@ -5,16 +5,22 @@ namespace Purview.SourceGeneratorFramework; /// public enum TypeModifierKind { - /// A nullable annotation, or a wrapper for a value type. + /// + /// A nullable annotation, or a wrapper for a value type. + /// Nullable = 0, - /// A pointer indirection. + /// + /// 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. + /// + /// An array of the given rank. + /// Array = 2, } @@ -31,13 +37,19 @@ public enum TypeModifierKind /// public enum NullableModifierKind { - /// The value-versus-reference question is unknown. + /// + /// The value-versus-reference question is unknown. + /// Unknown = 0, - /// The modifier represents a nullable value type, such as int? or Nullable<T>. + /// + /// The modifier represents a nullable value type, such as int? or Nullable<T>. + /// ValueType = 1, - /// The modifier represents a nullable reference type annotation, such as string?. + /// + /// The modifier represents a nullable reference type annotation, such as string?. + /// Reference = 2, } @@ -50,10 +62,14 @@ public enum NullableModifierKind /// public readonly struct TypeModifier : IEquatable { - /// Gets the kind of composition step. + /// + /// Gets the kind of composition step. + /// public TypeModifierKind Kind { get; init; } - /// Gets the array rank. Only meaningful when is . + /// + /// Gets the array rank. Only meaningful when is . + /// public int Rank { get; init; } /// @@ -63,10 +79,14 @@ public enum NullableModifierKind /// public NullableModifierKind NullableKind { get; init; } - /// Gets a nullable modifier whose value-versus-reference classification is unknown. + /// + /// Gets a nullable modifier whose value-versus-reference classification is unknown. + /// public static TypeModifier Nullable => new() { Kind = TypeModifierKind.Nullable, Rank = 0 }; - /// Gets a nullable modifier representing a nullable value type. + /// + /// Gets a nullable modifier representing a nullable value type. + /// public static TypeModifier NullableValueType => new() { @@ -75,7 +95,9 @@ public enum NullableModifierKind NullableKind = NullableModifierKind.ValueType, }; - /// Gets a nullable modifier representing a nullable reference type annotation. + /// + /// Gets a nullable modifier representing a nullable reference type annotation. + /// public static TypeModifier NullableReference => new() { @@ -84,10 +106,14 @@ public enum NullableModifierKind NullableKind = NullableModifierKind.Reference, }; - /// Gets a pointer modifier. + /// + /// Gets a pointer modifier. + /// public static TypeModifier PointerModifier => new() { Kind = TypeModifierKind.PointerModifier, Rank = 0 }; - /// Creates an array modifier of the given rank. + /// + /// Creates an array modifier of the given rank. + /// /// Thrown when is less than one. public static TypeModifier Array(int rank = 1) { @@ -131,9 +157,13 @@ public override int GetHashCode() } } - /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + /// + /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + /// public static bool operator ==(TypeModifier left, TypeModifier right) => left.Equals(right); - /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + /// + /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + /// public static bool operator !=(TypeModifier left, TypeModifier right) => !left.Equals(right); } diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs index 6ada51f..85d0516 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -57,13 +57,19 @@ public TypeReference(TypeIdentity typeIdentity) Modifiers = []; } - /// Gets a value indicating whether this reference is empty. + /// + /// Gets a value indicating whether this reference is empty. + /// public bool IsEmpty => Kind == TypeReferenceKind.None; - /// Gets what this reference refers to beneath its modifiers. + /// + /// Gets what this reference refers to beneath its modifiers. + /// public TypeReferenceKind Kind { get; init; } - /// Gets the named type, when is . + /// + /// Gets the named type, when is . + /// public TypeIdentity Identity { get; init; } /// @@ -82,13 +88,19 @@ public TypeReference(TypeIdentity typeIdentity) /// public bool IsPlainNamedType => Kind == TypeReferenceKind.Named && Modifiers.IsDefaultOrEmpty; - /// Gets a value indicating whether the outermost modifier is an array. + /// + /// 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. + /// + /// 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. + /// + /// 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]; @@ -136,7 +148,7 @@ public string RenderFullNameForNullable(bool nullableSupported) if (Modifiers[index].Kind != TypeModifierKind.Array) { var modifier = Modifiers[index]; - if (ShouldRender(modifier, nullableSupported)) + if (ShouldRender(modifier, nullableSupported, Kind)) builder.Append(modifier.Suffix); index++; @@ -154,13 +166,23 @@ public string RenderFullNameForNullable(bool nullableSupported) return builder.ToString(); } - static bool ShouldRender(TypeModifier modifier, bool nullableSupported) + static bool ShouldRender(TypeModifier modifier, bool nullableSupported, TypeReferenceKind referenceKind) { if (modifier.Kind != TypeModifierKind.Nullable) return true; - // Nullable value types and unclassified annotations are always rendered, because they are part of the type identity. - return nullableSupported || modifier.NullableKind != NullableModifierKind.Reference; + if (modifier.NullableKind == NullableModifierKind.Reference) + return nullableSupported; + + if (modifier.NullableKind == NullableModifierKind.Unknown && !nullableSupported) + { + // Type parameters and dynamic are reference-like, so their unclassified annotations are invalid + // outside a nullable context and are elided. An unclassifiable named type is rendered + // conservatively so a genuine value type cannot be silently changed. + return referenceKind is not (TypeReferenceKind.TypeParameter or TypeReferenceKind.Dynamic); + } + + return true; } /// @@ -211,7 +233,9 @@ public string RenderAttributeName // Composition // --------------------------------------------------------------------------------------------- - /// Appends a nullable annotation. + /// + /// Appends a nullable annotation. + /// /// /// The annotation is classified from the annotated type where possible, so a nullable value type such as /// int? is never elided when the target compilation does not support nullable annotations, while a @@ -255,10 +279,14 @@ public TypeReference Nullable(Compilation compilation) => ? AppendNullable(TypeModifier.Nullable) : this; - /// Appends an array of the given rank. + /// + /// Appends an array of the given rank. + /// public TypeReference MakeArray(int rank = 1) => Append(TypeModifier.Array(rank)); - /// Appends a pointer indirection. + /// + /// Appends a pointer indirection. + /// public TypeReference MakePointer() => Append(TypeModifier.PointerModifier); static bool ShouldComposeNullable(NullableDirectiveMode mode, bool? isNullableContextEnabled) => @@ -438,7 +466,9 @@ current is INamedTypeSymbol nullable public static bool operator ==(TypeReference? left, TypeIdentity right) => left is not null && left.IsPlainNamedType && left.Identity.Equals(right); - /// Negates . + /// + /// Negates . + /// public static bool operator !=(TypeReference? left, TypeIdentity right) => !(left == right); /// @@ -594,13 +624,19 @@ public override int GetHashCode() // Factories // --------------------------------------------------------------------------------------------- - /// Gets the empty reference. + /// + /// Gets the empty reference. + /// public static readonly TypeReference Empty = new(); - /// Gets a reference to . + /// + /// Gets a reference to . + /// public static TypeReference Dynamic { get; } = new() { Kind = TypeReferenceKind.Dynamic, Modifiers = [] }; - /// Creates a reference to an open generic parameter. + /// + /// Creates a reference to an open generic parameter. + /// public static TypeReference ForTypeParameter(string name) { if (name == null) @@ -615,10 +651,14 @@ public static TypeReference ForTypeParameter(string name) }; } - /// Creates a reference from a runtime type. + /// + /// Creates a reference from a runtime type. + /// public static TypeReference Create() => Create(typeof(T)); - /// Creates a reference from a runtime type. + /// + /// Creates a reference from a runtime type. + /// /// Thrown when the type cannot be represented. public static TypeReference Create(Type type) { @@ -632,7 +672,9 @@ public static TypeReference Create(Type type) return value; } - /// Creates a reference from a type symbol. + /// + /// Creates a reference from a type symbol. + /// /// Thrown when the symbol cannot be represented. public static TypeReference Create(ITypeSymbol typeSymbol) { diff --git a/src/src/SourceGeneratorShared/TypeReferenceKind.cs b/src/src/SourceGeneratorShared/TypeReferenceKind.cs index cddf150..e376f14 100644 --- a/src/src/SourceGeneratorShared/TypeReferenceKind.cs +++ b/src/src/SourceGeneratorShared/TypeReferenceKind.cs @@ -5,15 +5,23 @@ namespace Purview.SourceGeneratorFramework; /// public enum TypeReferenceKind { - /// No type. The default, uninitialised state. + /// + /// No type. The default, uninitialised state. + /// None = 0, - /// A named type described by a . + /// + /// A named type described by a . + /// Named = 1, - /// An open generic parameter, identified by name. + /// + /// An open generic parameter, identified by name. + /// TypeParameter = 2, - /// The type. + /// + /// The type. + /// Dynamic = 3, } diff --git a/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs b/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs index 349fe70..0c055c1 100644 --- a/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs +++ b/src/src/SourceGeneratorShared/TypeSyntaxMatchingExtensions.cs @@ -218,7 +218,9 @@ public static bool MatchesDeclaredType( /// /// 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. + /// + /// 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 diff --git a/src/src/SourceGeneratorShared/XmlCommentWriter.cs b/src/src/SourceGeneratorShared/XmlCommentWriter.cs index a6c85e9..4ea3297 100644 --- a/src/src/SourceGeneratorShared/XmlCommentWriter.cs +++ b/src/src/SourceGeneratorShared/XmlCommentWriter.cs @@ -57,22 +57,32 @@ public CodeWriter XmlCref(string typeName, string content) => /// The current writer. public CodeWriter XmlReturn(params string[] content) => XmlCore(writer, "returns", content); - /// Writes an XML <value> documentation block. + /// + /// Writes an XML <value> documentation block. + /// public CodeWriter XmlValue(params string[] content) => XmlCore(writer, "value", content); - /// Writes an XML <remarks> documentation block. + /// + /// Writes an XML <remarks> documentation block. + /// public CodeWriter XmlRemarks(params string[] content) => XmlCore(writer, "remarks", content); - /// Writes an XML <permission> documentation block. + /// + /// Writes an XML <permission> documentation block. + /// public CodeWriter XmlPermission(string cref, params string[] content) => string.IsNullOrWhiteSpace(cref) ? throw new ArgumentException("The XML cref cannot be null or empty.", nameof(cref)) : XmlCore(writer, BuildXmlTag("permission", ("cref", cref)), "permission", content); - /// Writes a self-closing XML <inheritdoc /> element. + /// + /// Writes a self-closing XML <inheritdoc /> element. + /// public CodeWriter XmlInheritDoc() => writer.Write("/// ").WriteLine(BuildSelfClosingXmlTag("inheritdoc")); - /// Writes a self-closing XML <inheritdoc /> element for a member. + /// + /// Writes a self-closing XML <inheritdoc /> element for a member. + /// public CodeWriter XmlInheritDoc(string cref) => string.IsNullOrWhiteSpace(cref) ? throw new ArgumentException("The XML cref cannot be null or empty.", nameof(cref)) @@ -145,7 +155,9 @@ public CodeWriter XmlCode(string content) return XmlCore(writer, "c", "c", [content], supportsMultiLine: false, compactSingleLine: true); } - /// Writes an XML <code> documentation block. + /// + /// Writes an XML <code> documentation block. + /// public CodeWriter XmlCodeBlock(params string[] content) => XmlCore(writer, "code", "code", content, supportsMultiLine: true, compactSingleLine: false); @@ -156,7 +168,9 @@ public CodeWriter XmlCodeBlock(params string[] content) => /// The current writer. public CodeWriter XmlExample(params string[] content) => XmlCore(writer, "example", content); - /// Writes an XML <seealso> documentation element. + /// + /// Writes an XML <seealso> documentation element. + /// public CodeWriter XmlSeeAlso(string cref, params string[] content) { if (string.IsNullOrWhiteSpace(cref)) @@ -168,7 +182,9 @@ public CodeWriter XmlSeeAlso(string cref, params string[] content) : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); } - /// Writes an XML <seealso> documentation element for a type. + /// + /// Writes an XML <seealso> documentation element for a type. + /// public CodeWriter XmlSeeAlso(TypeIdentity type, params string[] content) { var cref = ToXmlCref(type); @@ -178,7 +194,9 @@ public CodeWriter XmlSeeAlso(TypeIdentity type, params string[] content) : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); } - /// Writes an XML <seealso> documentation element for a type reference. + /// + /// Writes an XML <seealso> documentation element for a type reference. + /// /// If is . public CodeWriter XmlSeeAlso(TypeReference type, params string[] content) { @@ -192,7 +210,9 @@ public CodeWriter XmlSeeAlso(TypeReference type, params string[] content) : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); } - /// Writes a self-closing XML <include /> documentation element. + /// + /// Writes a self-closing XML <include /> documentation element. + /// public CodeWriter XmlInclude(string file, string path) { if (string.IsNullOrWhiteSpace(file)) @@ -284,7 +304,9 @@ public CodeWriter XmlParam(string parameterName, params string[] content) return XmlCore(writer, startTag, tag, content, compactSingleLine: true); } - /// Writes an XML <typeparam> documentation element. + /// + /// Writes an XML <typeparam> documentation element. + /// public CodeWriter XmlTypeParam(string typeParameterName, params string[] content) { if (string.IsNullOrWhiteSpace(typeParameterName)) @@ -427,7 +449,9 @@ public static string BuildXmlTag(string tag, params (string Name, object Value)[ return builder.ToString(); } - /// Returns an inline XML reference to a type or member. + /// + /// Returns an inline XML reference to a type or member. + /// public static string XmlSee(string cref, string? description = null) { if (string.IsNullOrWhiteSpace(cref)) @@ -439,11 +463,15 @@ public static string XmlSee(string cref, string? description = null) : BuildXmlTag("see", ("cref", cref)) + description + ""; } - /// Returns an inline XML reference to a type. + /// + /// Returns an inline XML reference to a type. + /// public static string XmlSee(TypeIdentity type, string? description = null) => XmlSee(ToXmlCref(type), description); - /// Returns an inline XML reference to a type reference. + /// + /// Returns an inline XML reference to a type reference. + /// /// If is . public static string XmlSee(TypeReference type, string? description = null) => type == null ? throw new ArgumentNullException(nameof(type)) : XmlSee(ToXmlCref(type), description); @@ -580,54 +608,84 @@ static void WrapInNullable(StringBuilder builder) builder.Append("global::System.Nullable{").Append(inner).Append('}'); } - /// Returns an inline XML <para> element containing the provided content. + /// + /// Returns an inline XML <para> element containing the provided content. + /// public static string XmlInlinePara(params string[] content) => XmlCore("para", content, false); - /// Returns an inline XML reference to a parameter. + /// + /// Returns an inline XML reference to a parameter. + /// public static string XmlParamRef(string parameterName) => BuildSelfClosingXmlTag("paramref", ("name", parameterName)); - /// Returns an inline XML reference to a type parameter. + /// + /// Returns an inline XML reference to a type parameter. + /// public static string XmlTypeParamRef(string typeParameterName) => BuildSelfClosingXmlTag("typeparamref", ("name", typeParameterName)); - /// Returns inline code suitable for use in the middle of documentation text. + /// + /// Returns inline code suitable for use in the middle of documentation text. + /// public static string XmlInlineCode(string content) => $"{content}"; - /// Returns inline code suitable for use in the middle of documentation text. + /// + /// Returns inline code suitable for use in the middle of documentation text. + /// public static string XmlInlineCodeBlock(params string[] content) => XmlCore("code", content, false); - /// Escapes plain text for safe composition with XML documentation elements. + /// + /// Escapes plain text for safe composition with XML documentation elements. + /// public static string XmlText(string content) => EscapeXml(content ?? throw new ArgumentNullException(nameof(content))); - /// Returns an XML line break for use inside documentation text. + /// + /// Returns an XML line break for use inside documentation text. + /// public static string XmlLineBreak() => "
"; - /// Returns an XML list item containing a description. + /// + /// Returns an XML list item containing a description. + /// public static string XmlListItem(string description) => $"{description}"; - /// Returns an XML list item containing a term and its description. + /// + /// Returns an XML list item containing a term and its description. + /// public static string XmlListItem(string term, string description) => $"{term}{description}"; - /// Returns an XML list header containing a term and its description. + /// + /// Returns an XML list header containing a term and its description. + /// public static string XmlListHeader(string term, string description) => $"{term}{description}"; - /// Returns an XML list header containing a term and its description. + /// + /// Returns an XML list header containing a term and its description. + /// public static string XmlListHeader(string description) => $"{description}"; - /// Returns an XML list term. + /// + /// Returns an XML list term. + /// public static string XmlTerm(string content) => $"{content}"; - /// Returns an XML list description. + /// + /// Returns an XML list description. + /// public static string XmlDescription(string content) => $"{content}"; - /// Returns an arbitrary inline XML element. + /// + /// Returns an arbitrary inline XML element. + /// public static string XmlInlineElement(string tag, string content) => BuildXmlTag(tag) + content + $""; - /// Returns a self-closing XML element with optional attributes. + /// + /// Returns a self-closing XML element with optional attributes. + /// public static string BuildSelfClosingXmlTag(string tag, params (string Name, object Value)[]? attributes) { var openTag = BuildXmlTag(tag, attributes); diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/DiscardedCodeWriterScopeAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/DiscardedCodeWriterScopeAnalyzerTests.cs new file mode 100644 index 0000000..b194d58 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/DiscardedCodeWriterScopeAnalyzerTests.cs @@ -0,0 +1,120 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class DiscardedCodeWriterScopeAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task DiscardedScope_ReportsDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteClassScope(new TypeDeclarationOptions("C")); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions + { + AdditionalAssemblyTypes = + [ + typeof(CodeWriter), + typeof(GenerationSettings), + typeof(TypeDeclarationOptions), + ], + }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(DiscardedCodeWriterScopeAnalyzer.Rule.Id); + } + + [Test] + public async Task ScopeUsedInUsing_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.WriteClassScope(new TypeDeclarationOptions("C"))) + { + writer.WriteLine("// body"); + } + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions + { + AdditionalAssemblyTypes = + [ + typeof(CodeWriter), + typeof(GenerationSettings), + typeof(TypeDeclarationOptions), + ], + }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task NonScopeMethod_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteProperty(new PropertyDeclarationOptions("Name", TypeIdentity.Create())); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions + { + AdditionalAssemblyTypes = + [ + typeof(CodeWriter), + typeof(GenerationSettings), + typeof(PropertyDeclarationOptions), + ], + }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterApiAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterApiAnalyzerTests.cs new file mode 100644 index 0000000..e4c7431 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterApiAnalyzerTests.cs @@ -0,0 +1,121 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class PreferStructuredCodeWriterApiAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task WriteLine_WithClassDeclaration_ReportsDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteLine("public class C { }"); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)] }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterApiAnalyzer.Rule.Id); + } + + [Test] + public async Task WriteLine_WithStatement_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteLine("return value;"); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)] }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task WriteLine_WithUsingStatement_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteLine("using (var stream = Open())"); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)] }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task WriteMethodCall_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.WriteMethodCall("Run", "value"); + } + } + """; + + // Act + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)] }, + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs index ac41023..a2517ad 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs @@ -59,7 +59,7 @@ await Assert "global::System.Collections.Generic.IEnumerable<(RequiredAttributeData Instance, global::Microsoft.CodeAnalysis.AttributeData Attribute)> AllAttributeData(" ); await Assert.That(generated).Contains("yield return (instance, attributes[i]);"); - await Assert.That(generated).Contains("global::Microsoft.CodeAnalysis.ISymbol symbol)"); + await Assert.That(generated).Contains("global::Microsoft.CodeAnalysis.ISymbol symbol\n\t\t)"); await Assert.That(generated).Contains("return AllAttributeData(symbol.GetAttributes());"); await Assert.That(generated).Contains("return FromAttributeData(symbol.GetAttributes());"); await Assert.That(generated).Contains("return FromAttributeData(symbol.GetAttributes(), out attribute);"); diff --git a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs index 74b6af8..cec3bd4 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs +++ b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs @@ -204,4 +204,132 @@ await Assert .That(() => query.Get(static method => method.Identifier.ValueText == "Nope")) .Throws(); } + + [Test] + public async Task NestedQuery_GivenRoot_ScopesNodeSearches() + { + var query = CreateQuery(); + var method = query.GetMethod("DoWork"); + + var nested = query.In(method); + + await Assert.That(nested.Has()).IsTrue(); + await Assert.That(nested.HasMethod("Compute")).IsFalse(); + await Assert.That(nested.HasProperty("Count")).IsFalse(); + } + + [Test] + public async Task NestedQuery_ChainingExtension_ScopesSearches() + { + var query = CreateQuery(); + + var nested = query.GetClass("Sample").Query(query); + + await Assert.That(nested.HasProperty("Count")).IsTrue(); + await Assert.That(nested.HasField("Constant")).IsTrue(); + await Assert.That(nested.HasMethod("DoWork")).IsTrue(); + await Assert.That(nested.HasClass("Sample")).IsFalse(); + } + + [Test] + public async Task GetAllAndCount_ReturnAllMatches() + { + var query = CreateQuery(); + + await Assert.That(query.Count()).IsEqualTo(2); + await Assert.That(query.GetAll().Length).IsEqualTo(3); + await Assert + .That( + query + .GetAll(static method => method.Identifier.ValueText.StartsWith('C')) + .Length + ) + .IsEqualTo(1); + } + + [Test] + public async Task OperatorAndIndexerAndAttributeQueries_FindDeclarations() + { + // Arrange + const string source = """ + using System; + + namespace Test; + + public struct Money + { + [Obsolete] + public string this[int index] => ""; + + public static bool operator ==(Money left, Money right) => true; + public static bool operator !=(Money left, Money right) => false; + public static implicit operator int(Money value) => 0; + } + """; + var (compilation, _) = TestCompilation.CreateWithRoot(source); + var query = new CodeQuery([.. compilation.SyntaxTrees], compilation); + + // Act / Assert + await Assert.That(query.HasOperator("==")).IsTrue(); + await Assert.That(query.HasOperator("!=")).IsTrue(); + await Assert.That(query.HasConversionOperator("implicit")).IsTrue(); + await Assert.That(query.HasOperator("+")).IsFalse(); + await Assert.That(query.GetOperator("==").ParameterList.Parameters.Count).IsEqualTo(2); + + await Assert.That(query.HasIndexer(TypeReference.Create())).IsTrue(); + await Assert.That(query.HasIndexer(TypeReference.Create())).IsFalse(); + + var money = query.GetStruct("Money"); + await Assert.That(query.HasAttribute(money, "Obsolete")).IsTrue(); + await Assert.That(query.GetAttribute(money, "Obsolete")).IsNotNull(); + await Assert.That(query.HasAttribute(money, "Missing")).IsFalse(); + } + + [Test] + public async Task StatementQueries_FindStatementsAndInvocations() + { + // Arrange + const string source = """ + using System; + using System.Collections.Generic; + + namespace Test; + + public class Worker + { + public void Run(List items) + { + var total = 0; + try + { + foreach (var item in items) + { + total += int.Parse(item); + } + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + if (total > 0) + { + Process(total); + } + } + + void Process(int value) { } + } + """; + var (compilation, _) = TestCompilation.CreateWithRoot(source); + var query = new CodeQuery([.. compilation.SyntaxTrees], compilation); + + // Act / Assert + await Assert.That(query.HasTry()).IsTrue(); + await Assert.That(query.HasForeach()).IsTrue(); + await Assert.That(query.HasIf()).IsTrue(); + await Assert.That(query.HasWhile("true")).IsFalse(); + await Assert.That(query.HasInvocation("Parse")).IsTrue(); + await Assert.That(query.HasInvocation("Process")).IsTrue(); + await Assert.That(query.HasInvocation("Missing")).IsFalse(); + } } diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index 6adb31c..1d90a12 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis.CSharp; + namespace Purview.SourceGeneratorFramework; public class CodeWriterTests @@ -940,7 +942,8 @@ await Assert + "public global::Aspire.Hosting.IDistributedApplicationBuilder AddAspireResourceKit(\n" + "\tglobal::System.Action? onBuilt = null,\n" + "\tglobal::System.Action? onConfigured = null,\n" - + "\tglobal::System.Action>? configureOptions = null)\n" + + "\tglobal::System.Action>? configureOptions = null\n" + + ")\n" + "{\n" + "\treturn builder;\n" + "}\n" @@ -1222,7 +1225,8 @@ await Assert .IsEqualTo( GeneratedAttributes() + "global::System.Collections.Generic.Dictionary[]? Load(\n" - + "\tglobal::System.Collections.Generic.List? items) => items.ToArray();\n" + + "\tglobal::System.Collections.Generic.List? items\n" + + ") => items.ToArray();\n" ); } @@ -1624,7 +1628,7 @@ public async Task WriteAwaitedMethodCall_WithStructuredArguments_WritesReceiverA writeArgumentsOnSeparateLines: true ); - await Assert.That(writer.ToString()).IsEqualTo("await service.LoadAsync(\n\ttoken,\n\tout result);\n"); + await Assert.That(writer.ToString()).IsEqualTo("await service.LoadAsync(\n\ttoken,\n\tout result\n);\n"); } [Test] @@ -1644,7 +1648,8 @@ await Assert .IsEqualTo( "factory.Create(\n" + "\tfirstArgumentWithANameThatMakesTheCallLong,\n" - + "\tsecondArgumentWithANameThatMakesTheCallLong);\n" + + "\tsecondArgumentWithANameThatMakesTheCallLong\n" + + ");\n" ); } @@ -1665,7 +1670,7 @@ public async Task WriteMethodCall_WithStructuredArguments_WritesModifiersAndMult await Assert .That(writer.ToString()) - .IsEqualTo("AMethodCallWithLotsOfParams(\n" + "\tref a-long-a-param,\n" + "\tout another-long-param);\n"); + .IsEqualTo("AMethodCallWithLotsOfParams(\n" + "\tref a-long-a-param,\n" + "\tout another-long-param\n);\n"); } [Test] @@ -1699,10 +1704,12 @@ await Assert .IsEqualTo( "var @event = new ASpecificType(\n" + "\tpropVal1,\n" - + "\tsecond: propVal2);\n" + + "\tsecond: propVal2\n" + + ");\n" + "existingEvent = new ASpecificType(\n" + "\tpropVal1,\n" - + "\tsecond: propVal2);\n" + + "\tsecond: propVal2\n" + + ");\n" ); } @@ -2468,4 +2475,894 @@ partial void Apply() """ ); } + + [Test] + public async Task ModernizationOptions_AreValueTypes() + { + // Arrange / Act / Assert + await Assert.That(typeof(OperatorDeclarationOptions).IsValueType).IsTrue(); + await Assert.That(typeof(ObjectInitializerMemberOptions).IsValueType).IsTrue(); + } + + [Test] + public async Task WriteOperator_GivenBlockBody_WritesAccessibilityStaticTokenAndParameters() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "==", + Type("bool"), + new("left", Type("global::Testing.Name")), + new("right", Type("global::Testing.Name")) + ) + { + Accessibility = TypeDeclarationAccessibility.Public, + }; + + // Act + writer.WriteOperator(declaration, body => body.WriteLine("return left.Equals(right);")); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public static bool operator ==(global::Testing.Name left, global::Testing.Name right)\n" + + "{\n" + + "\treturn left.Equals(right);\n" + + "}\n" + ); + } + + [Test] + public async Task WriteOperator_GivenExpressionBody_WritesExpressionAndBalancesScope() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "<", + Type("bool"), + new("left", Type("global::Testing.Money")), + new("right", Type("global::Testing.Money")) + ) + { + Accessibility = TypeDeclarationAccessibility.Public, + ExpressionBody = "left.CompareTo(right) < 0", + }; + + // Act + using (writer.WriteOperatorScope(declaration)) + { + // Intentionally empty: an expression-bodied operator returns an empty scope. + } + + // Assert + await Assert.That(writer.OpenScopeCount).IsEqualTo(0); + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public static bool operator <(global::Testing.Money left, global::Testing.Money right) => left.CompareTo(right) < 0;\n" + ); + } + + [Test] + public async Task WriteOperatorScope_GivenBlockBody_TracksOpenScopeCount() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "==", + Type("bool"), + new("left", Type("Name")), + new("right", Type("Name")) + ); + + // Act + using (writer.WriteOperatorScope(declaration)) + { + writer.WriteLine("return left.Equals(right);"); + await Assert.That(writer.OpenScopeCount).IsEqualTo(1); + } + + // Assert + await Assert.That(writer.OpenScopeCount).IsEqualTo(0); + } + + [Test] + public async Task WriteOperator_GivenNullBody_Throws() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "==", + Type("bool"), + new("left", Type("Name")), + new("right", Type("Name")) + ); + + // Act / Assert + await Assert.That(() => writer.WriteOperator(declaration, null!)).Throws(); + } + + [Test] + public async Task WriteOperator_GivenExpressionBodyAndCallback_ThrowsWithoutWriting() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "==", + Type("bool"), + new("left", Type("Name")), + new("right", Type("Name")) + ) + { + ExpressionBody = "left.Equals(right)", + }; + + // Act / Assert + await Assert.That(() => writer.WriteOperator(declaration, _ => { })).Throws(); + await Assert.That(writer.ToString()).IsEmpty(); + } + + [Test] + public async Task WritePartialMethod_GivenIsReadOnly_WritesReadonlyModifier() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new MethodDeclarationOptions("OnValidate", Type("void")) + { + IsPartial = true, + IsReadOnly = true, + Parameters = + [ + new("id", Type("global::System.Guid")), + new("displayName", Type("string").Nullable()), + new("isActive", Type("bool")), + ], + }; + + // Act + writer.WritePartialMethod(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "readonly partial void OnValidate(global::System.Guid id, string? displayName, bool isActive);\n" + ); + } + + [Test] + public async Task WritePartialMethod_GivenIsReadOnlyFalse_OmitsReadonlyModifier() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new MethodDeclarationOptions("Apply", Type("void")) { IsPartial = true }; + + // Act + writer.WritePartialMethod(declaration); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo(GeneratedAttributes() + "partial void Apply();\n"); + } + + [Test] + public async Task WriteMethod_GivenIsReadOnlyAndIsStatic_ThrowsWithoutWriting() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new MethodDeclarationOptions("Invalid", Type("void")) { IsReadOnly = true, IsStatic = true }; + + // Act / Assert + await Assert.That(() => writer.WriteMethodScope(declaration)).Throws(); + await Assert.That(writer.ToString()).IsEmpty(); + } + + [Test] + public async Task WriteAssignment_WithObjectInitializerMembers_WritesBlockInitializer() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("global::Testing.OrderAggregate")) + { + InitializerMembers = + [ + new("Details", "jsonModel.Details ?? new global::Purview.EventSourcing.Aggregates.AggregateDetails()"), + new("CustomerId", "jsonModel.CustomerId"), + ], + }; + + // Act + writer.WriteAssignment("var", "aggregate", creation); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + "var aggregate = new global::Testing.OrderAggregate\n" + + "{\n" + + "\tDetails = jsonModel.Details ?? new global::Purview.EventSourcing.Aggregates.AggregateDetails(),\n" + + "\tCustomerId = jsonModel.CustomerId,\n" + + "};\n" + ); + } + + [Test] + public async Task WriteAssignment_WithObjectInitializerMembersAndConstructorArguments_WritesArgumentsBeforeInitializer() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions( + Type("global::Testing.OrderEvents.OrderCreatedEvent"), + "customerId", + "total" + ) + { + InitializerMembers = [new("CustomerId", "customerId"), new("Total", "total")], + }; + + // Act + writer.WriteAssignment("var", "@event", creation); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + "var @event = new global::Testing.OrderEvents.OrderCreatedEvent(customerId, total)\n" + + "{\n" + + "\tCustomerId = customerId,\n" + + "\tTotal = total,\n" + + "};\n" + ); + } + + [Test] + public async Task WriteAssignment_WithInlineInitializerMembers_WritesSingleLineInitializer() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("Order")) + { + InitializerMembers = [new("A", "1"), new("B", "2")], + WriteInitializerMembersOnSeparateLines = false, + }; + + // Act + writer.WriteAssignment("var order", creation); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("var order = new Order { A = 1, B = 2, };\n"); + } + + [Test] + public async Task WriteAssignment_WithEmptyInitializerMembers_RendersAsToday() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("Order")); + + // Act + writer.WriteAssignment("var order", creation); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("var order = new Order();\n"); + } + + [Test] + public async Task WriteAssignment_WithEmptyArgumentsAndForceNotNull_WritesBangAfterParentheses() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("Order")); + + // Act + writer.WriteAssignment("var order", creation, forceNotNull: true); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("var order = new Order()!;\n"); + } + + [Test] + public async Task WriteAssignment_WithInitializerMembersAndForceNotNull_WritesBangAfterBrace() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("Order")) + { + InitializerMembers = [new("A", "1")], + WriteInitializerMembersOnSeparateLines = false, + }; + + // Act + writer.WriteAssignment("var order", creation, forceNotNull: true); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("var order = new Order { A = 1, }!;\n"); + } + + [Test] + public async Task WriteAssignment_WithMultilineArgumentsAndInitializerMembers_WritesClosingBraceAndSemicolon() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var creation = new ObjectCreationOptions(Type("Order"), "aParameterNameThatForcesTheArgumentsOntoTheirOwnLine") + { + WriteArgumentsOnSeparateLines = true, + InitializerMembers = [new("A", "1")], + }; + + // Act + writer.WriteAssignment("var order", creation); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + "var order = new Order(\n" + + "\taParameterNameThatForcesTheArgumentsOntoTheirOwnLine\n" + + ")\n" + + "{\n" + + "\tA = 1,\n" + + "};\n" + ); + } + + [Test] + public async Task WriteThrow_GivenExceptionTypeAndMessage_WritesThrow() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteThrow( + new TypeIdentity("InvalidOperationException", "System"), + "Collection property 'Tags' cannot be null." + ); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + "throw new global::System.InvalidOperationException(\"Collection property 'Tags' cannot be null.\");\n" + ); + } + + [Test] + public async Task WriteThrow_GivenMessageWithQuotesAndBackslashes_EscapesMessage() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteThrow(new TypeIdentity("InvalidOperationException", "System"), "He said \"hi\" to C:\\temp\\file."); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + "throw new global::System.InvalidOperationException(\"He said \\\"hi\\\" to C:\\\\temp\\\\file.\");\n" + ); + } + + [Test] + public async Task WriteThrow_GivenNullMessage_WritesEmptyConstructor() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteThrow(new TypeIdentity("InvalidOperationException", "System")); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("throw new global::System.InvalidOperationException();\n"); + } + + [Test] + public async Task WriteOperator_GivenImplicitConversion_WritesConversionOperator() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "implicit", + Type("global::Testing.Widget"), + new("source", Type("global::Testing.RawWidget")) + ) + { + Accessibility = TypeDeclarationAccessibility.Public, + Kind = OperatorDeclarationKind.ImplicitConversion, + ExpressionBody = "new global::Testing.Widget(source.Value)", + }; + + // Act + writer.WriteOperatorScope(declaration); + + // Assert + await Assert.That(writer.OpenScopeCount).IsEqualTo(0); + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public static implicit operator global::Testing.Widget(global::Testing.RawWidget source) => new global::Testing.Widget(source.Value);\n" + ); + } + + [Test] + public async Task WriteOperator_GivenExplicitConversion_WritesExplicitKeyword() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "explicit", + Type("global::Testing.RawWidget"), + new("widget", Type("global::Testing.Widget")) + ) + { + Accessibility = TypeDeclarationAccessibility.Public, + Kind = OperatorDeclarationKind.ExplicitConversion, + }; + + // Act + writer.WriteOperator( + declaration, + body => body.WriteLine("return new global::Testing.RawWidget(widget.Value);") + ); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public static explicit operator global::Testing.RawWidget(global::Testing.Widget widget)\n" + + "{\n" + + "\treturn new global::Testing.RawWidget(widget.Value);\n" + + "}\n" + ); + } + + [Test] + public async Task WriteOperator_GivenUnaryOperator_WritesSingleOperand() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new OperatorDeclarationOptions( + "-", + Type("global::Testing.Money"), + new("value", Type("global::Testing.Money")) + ) + { + Accessibility = TypeDeclarationAccessibility.Public, + Kind = OperatorDeclarationKind.Unary, + }; + + // Act + writer.WriteOperator(declaration, body => body.WriteLine("return new global::Testing.Money(-value.Amount);")); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public static global::Testing.Money operator -(global::Testing.Money value)\n" + + "{\n" + + "\treturn new global::Testing.Money(-value.Amount);\n" + + "}\n" + ); + } + + [Test] + public async Task WriteProperty_GivenRequired_WritesRequiredModifier() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new PropertyDeclarationOptions("Name", Type("string")) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsRequired = true, + IsInitOnly = true, + }; + + // Act + writer.WriteProperty(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "public required string Name { get; init; }\n"); + } + + [Test] + public async Task WriteField_GivenRequired_WritesRequiredModifier() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new FieldDeclarationOptions("_name", Type("string")) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsRequired = true, + }; + + // Act + writer.WriteField(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes(includeCoverageExclusion: false) + "public required string _name;\n"); + } + + [Test] + public async Task WriteIndexer_GivenAutoAccessors_WritesIndexer() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new IndexerDeclarationOptions(Type("string"), [new("index", Type("int"))]) + { + Accessibility = TypeDeclarationAccessibility.Public, + HasSetter = true, + }; + + // Act + writer.WriteIndexer(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "public string this[int index] { get; set; }\n"); + } + + [Test] + public async Task WriteIndexer_GivenExpressionBody_WritesExpressionIndexer() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new IndexerDeclarationOptions(Type("string"), [new("index", Type("int"))]) + { + ExpressionBody = "_items[index]", + }; + + // Act + writer.WriteIndexer(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "string this[int index] => _items[index];\n"); + } + + [Test] + public async Task WriteIndexer_GivenAccessorBodies_WritesScopedAccessors() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new IndexerDeclarationOptions(Type("string"), [new("index", Type("int"))]) + { + Accessibility = TypeDeclarationAccessibility.Public, + HasSetter = true, + }; + + // Act + writer.WriteIndexer( + declaration, + getter => getter.WriteLine("return _items[index];"), + setter => setter.WriteLine("_items[index] = value;") + ); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo( + GeneratedAttributes() + + "public string this[int index]\n" + + "{\n" + + "\tget\n\t{\n\t\treturn _items[index];\n\t}\n" + + "\tset\n\t{\n\t\t_items[index] = value;\n\t}\n" + + "}\n" + ); + } + + [Test] + public async Task WriteStatementFamily_WritesStructuredBlocks() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteTry(tryBody => + tryBody.WriteForeach( + "var item in items", + foreachBody => + foreachBody.WriteIfElse( + "item is null", + ifBody => ifBody.WriteThrow(TypeIdentity.Create(), "Null item"), + elseBody => elseBody.WriteMethodCall("Process", "item") + ) + ) + ); + writer.WriteCatch(TypeIdentity.Create(), "ex", catchBody => catchBody.WriteMethodCall("Log", "ex")); + writer.WriteFinally(finallyBody => finallyBody.WriteMethodCall("Dispose")); + writer.WriteWhile("!finished", whileBody => whileBody.WriteMethodCall("Advance")); + writer.WriteUsingStatement("var stream = Open()", usingBody => usingBody.WriteMethodCall("Read", "stream")); + writer.WriteLockStatement("_gate", lockBody => lockBody.WriteMethodCall("Run")); + + // Assert + var result = writer.ToString(); + await Assert + .That(result) + .Contains( + "try\n{\n\tforeach (var item in items)\n\t{\n\t\tif (item is null)\n\t\t{\n\t\t\tthrow new global::System.InvalidOperationException(\"Null item\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\tProcess(item);\n\t\t}\n\t}" + ); + await Assert.That(result).Contains("catch (global::System.Exception ex)\n{\n\tLog(ex);\n}"); + await Assert.That(result).Contains("finally\n{\n\tDispose();\n}"); + await Assert.That(result).Contains("while (!finished)\n{\n\tAdvance();\n}"); + await Assert.That(result).Contains("using (var stream = Open())\n{\n\tRead(stream);\n}"); + await Assert.That(result).Contains("lock (_gate)\n{\n\tRun();\n}"); + await Assert.That(writer.OpenScopeCount).IsEqualTo(0); + } + + [Test] + public async Task WriteDoWhile_GivenCondition_WritesTrailingCondition() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteDoWhile("!finished", body => body.WriteMethodCall("Advance")); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("do\n{\n\tAdvance();\n} while (!finished);\n"); + } + + [Test] + public async Task OpenRegion_GivenName_WritesRegionDirectives() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.OpenRegion("Generated members", body => body.WriteLine("public int Value { get; }")); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo("#region Generated members\n\tpublic int Value { get; }\n#endregion\n"); + } + + [Test] + public async Task WriteUsing_GivenGlobal_WritesGlobalUsing() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteUsing("System.Linq", isGlobal: true); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("global using System.Linq;\n"); + } + + [Test] + public async Task WriteUsingAlias_WritesAliasDirective() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteUsingAlias("Events", "global::Purview.Events"); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("using Events = global::Purview.Events;\n"); + } + + [Test] + public async Task WriteMethod_GivenSpacesIndentation_UsesConfiguredSize() + { + // Arrange + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + IndentationStyle = IndentationStyle.Spaces, + IndentationSize = 2, + } + ); + + // Act + using ( + writer.WriteMethodScope( + new MethodDeclarationOptions("M", Type("void")) { Accessibility = TypeDeclarationAccessibility.Public } + ) + ) + { + writer.WriteLine("return;"); + } + + // Assert + await Assert.That(writer.ToString()).IsEqualTo(GeneratedAttributes() + "public void M()\n{\n return;\n}\n"); + } + + [Test] + public async Task RenderFullName_GivenTypeParameterNullableAndDisabledContext_ElidesAnnotation() + { + // Arrange + var typeParameter = TypeReference.ForTypeParameter("T").Nullable(); + var dynamic = TypeReference.Dynamic.Nullable(); + + // Act / Assert + await Assert.That(typeParameter.RenderFullNameForNullable(nullableSupported: false)).IsEqualTo("T"); + await Assert.That(dynamic.RenderFullNameForNullable(nullableSupported: false)).IsEqualTo("dynamic"); + } + + [Test] + public async Task RenderFullName_GivenTypeParameterNullableAndEnabledContext_KeepsAnnotation() + { + // Arrange + var typeParameter = TypeReference.ForTypeParameter("T").Nullable(); + + // Act / Assert + await Assert.That(typeParameter.RenderFullNameForNullable(nullableSupported: true)).IsEqualTo("T?"); + } + + [Test] + public async Task GenerationSettings_GivenLanguageVersion_StoresIt() + { + // Arrange / Act + var settings = new GenerationSettings("G") { LanguageVersion = LanguageVersion.CSharp12 }; + + // Assert + await Assert.That(settings.LanguageVersion).IsEqualTo(LanguageVersion.CSharp12); + } + + [Test] + public async Task WriteProperty_GivenIsFieldBacked_WritesFieldKeywordAccessors() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new PropertyDeclarationOptions("Value", Type("int")) + { + Accessibility = TypeDeclarationAccessibility.Public, + HasSetter = true, + IsFieldBacked = true, + }; + + // Act + writer.WriteProperty(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "public int Value { get => field; set => field = value; }\n"); + } + + [Test] + public async Task WriteProperty_GivenIsFieldBackedInitOnly_WritesFieldKeywordInit() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new PropertyDeclarationOptions("Name", Type("string")) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsInitOnly = true, + IsFieldBacked = true, + }; + + // Act + writer.WriteProperty(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes() + "public string Name { get => field; init => field = value; }\n"); + } + + [Test] + public async Task WriteProperty_GivenIsFieldBackedAndExpressionBody_ThrowsWithoutWriting() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new PropertyDeclarationOptions("Value", Type("int")) + { + IsFieldBacked = true, + ExpressionBody = "field", + }; + + // Act / Assert + await Assert.That(() => writer.WriteProperty(declaration)).Throws(); + await Assert.That(writer.ToString()).IsEmpty(); + } + + [Test] + public async Task WriteStruct_GivenIsRefStruct_WritesRefStruct() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new TypeDeclarationOptions("Buffer") + { + Accessibility = TypeDeclarationAccessibility.Public, + Kind = TypeDeclarationKind.Struct, + IsRefStruct = true, + IsPartial = false, + }; + + // Act + using (writer.WriteStructScope(declaration)) + { + // Intentionally empty. + } + + // Assert + await Assert.That(writer.ToString()).IsEqualTo(GeneratedAttributes() + "public ref struct Buffer\n{\n}\n"); + } + + [Test] + public async Task WriteStruct_GivenIsRefStructOnRecordStruct_Throws() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new TypeDeclarationOptions("Invalid") + { + Kind = TypeDeclarationKind.RecordStruct, + IsRefStruct = true, + }; + + // Act / Assert + await Assert.That(() => writer.WriteRecordStructScope(declaration)).Throws(); + } + + [Test] + public async Task WriteField_GivenIsRefField_WritesRefField() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new FieldDeclarationOptions("_value", Type("int")) + { + Accessibility = TypeDeclarationAccessibility.Public, + IsRefField = true, + }; + + // Act + writer.WriteField(declaration); + + // Assert + await Assert + .That(writer.ToString()) + .IsEqualTo(GeneratedAttributes(includeCoverageExclusion: false) + "public ref int _value;\n"); + } + + [Test] + public async Task WriteField_GivenIsRefFieldAndInitializer_Throws() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + var declaration = new FieldDeclarationOptions("_value", Type("int")) { IsRefField = true, Initializer = "0" }; + + // Act / Assert + await Assert.That(() => writer.WriteField(declaration)).Throws(); + } + + [Test] + public async Task WriteCollectionExpression_GivenItems_WritesInlineExpression() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteCollectionExpression(["first", "second", "..rest"]); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("[first, second, ..rest]"); + } + + [Test] + public async Task WriteCollectionExpression_GivenSeparateLines_WritesMultilineExpression() + { + // Arrange + var writer = CodeWriterFactory.ForTests(); + + // Act + writer.WriteCollectionExpression(["first", "second"], writeOnSeparateLines: true); + + // Assert + await Assert.That(writer.ToString()).IsEqualTo("[\n\tfirst,\n\tsecond\n]"); + } }