From a6134d06b6797cb3aba807ca8ae1f704ea77ac13 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 29 Oct 2026 09:36:50 +0000 Subject: [PATCH 1/3] [http-client-csharp] Prototype compiler-backed structural C# reduction Consolidate the provider-aware prototype, accepted structural rules through C9, supporting benchmarks and regression coverage. Keep Roslyn as the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ValidateGetFirstPropertyName.cs | 2 +- ...amingConvenienceMethod(WriteToAsyncStj).cs | 2 +- .../perf/.gitignore | 1 + .../perf/AppServiceHotspotProfilingResults.md | 99 + .../perf/FullGenerationBenchmark.cs | 172 + .../perf/GeneratorInitializer.cs | 20 +- .../perf/HotspotProfilingResults.md | 257 ++ .../perf/LatestMainProfilingResults.md | 70 + ...lReaderWriterContextDefinitionBenchmark.cs | 6 +- .../perf/NativeReductionPlan.md | 136 + .../perf/PostProcessingBenchmark.cs | 251 ++ .../perf/Program.cs | 36 +- .../perf/Projects/Model/tspCodeModel.json | 2856 +---------------- .../perf/ProviderReducerPrototype.md | 137 + .../perf/ProviderReducerStatus.md | 58 + .../perf/README.md | 40 + .../perf/StructuralTypeNameReduction.md | 1367 ++++++++ .../src/CSharpGen.cs | 169 +- .../Expressions/BinaryOperatorExpression.cs | 7 +- .../ConditionalStaticReceiverRules.cs | 86 + .../ExpressionReceiverSyntaxFacts.cs | 35 + .../PostProcessing/GeneratedCodeWorkspace.cs | 115 +- ...ratedCodeWorkspacePostProcessingProfile.cs | 81 + .../PostProcessing/MemberRemoverRewriter.cs | 4 + .../PostProcessing/ParenthesesSyntaxFacts.cs | 43 + .../ProviderConditionalSyntax.cs | 223 ++ .../src/PostProcessing/ProviderNameReducer.cs | 472 +++ .../PostProcessing/ProviderSyntaxCleanup.cs | 195 ++ .../StructuralAttributeRules.cs | 98 + .../StructuralCrefImportRules.cs | 108 + .../StructuralParenthesesRules.cs | 95 + .../StructuralTypeNameReducer.cs | 1068 ++++++ .../StructuralTypeNameSelector.cs | 446 +++ .../src/Primitives/CodeFile.cs | 7 +- .../src/Primitives/TypeProviderWriter.cs | 2 +- .../src/Primitives/TypeReferenceSite.cs | 11 + .../src/Statements/ExpressionStatement.cs | 17 +- .../src/Writers/CodeWriter.cs | 56 +- .../test/ProfilingTests.cs | 67 + .../test/ProviderConditionalCleanupTests.cs | 199 ++ .../test/ProviderCrefCleanupTests.cs | 119 + .../test/ProviderNameReducerBindingTests.cs | 452 +++ .../test/ProviderQualifierReductionTests.cs | 298 ++ .../test/ProviderReducerTests.cs | 192 ++ .../test/ProviderRetainedCleanupTests.cs | 120 + .../test/ProviderSyntaxCleanupTests.cs | 151 + .../ExpressionStatementEmissionTests.cs | 89 + .../test/StructuralAttributeSafetyTests.cs | 209 ++ .../test/StructuralAttributeTests.cs | 211 ++ ...ucturalC5CrefCompositionRegressionTests.cs | 152 + ...ructuralC5CrefDiagnosticRegressionTests.cs | 85 + .../StructuralC5CrefDiagnosticSafetyTests.cs | 97 + ...ructuralC5HandlerCaptureRegressionTests.cs | 60 + ...cturalConditionalParenthesesSafetyTests.cs | 159 + .../StructuralConditionalParenthesesTests.cs | 99 + ...tionalStaticReceiverImplicitSafetyTests.cs | 108 + ...lConditionalStaticReceiverImplicitTests.cs | 33 + ...ralConditionalStaticReceiverSafetyTests.cs | 249 ++ ...tructuralConditionalStaticReceiverTests.cs | 94 + .../test/StructuralCrefSafetyTests.cs | 243 ++ .../test/StructuralCrefTests.cs | 201 ++ .../StructuralImmediateNamespaceCrefTests.cs | 167 + .../test/StructuralNamespacePrefixTests.cs | 292 ++ .../test/StructuralNamespaceRoleTests.cs | 401 +++ .../test/StructuralNamespaceSourceTests.cs | 279 ++ .../StructuralNamespaceTriviaCacheTests.cs | 62 + .../test/StructuralNamespaceTriviaTests.cs | 129 + ...tructuralOptionalDefaultReflectionTests.cs | 40 + .../StructuralOptionalDefaultSafetyTests.cs | 202 ++ .../test/StructuralOptionalDefaultTests.cs | 180 ++ ...StructuralParenthesesGrammarSafetyTests.cs | 147 + .../test/StructuralParenthesesGrammarTests.cs | 48 + ...cturalParenthesesGrammarVisibilityTests.cs | 36 + .../test/StructuralParenthesesSafetyTests.cs | 170 + .../test/StructuralParenthesesTests.cs | 219 ++ ...tructuralStaticReceiverCompositionTests.cs | 32 + .../StructuralStaticReceiverSafetyTests.cs | 219 ++ .../test/StructuralStaticReceiverTests.cs | 175 + .../test/StructuralThisReceiverSafetyTests.cs | 200 ++ .../test/StructuralThisReceiverSourceTests.cs | 89 + .../test/StructuralThisReceiverTests.cs | 184 ++ .../StructuralTypeNameIntegrationTests.cs | 461 +++ ...StructuralTypeNameQualifiedContextTests.cs | 339 ++ .../test/StructuralTypeNameRuleEvaluator.cs | 19 + .../StructuralTypeNameRuleEvaluatorTests.cs | 130 + .../test/StructuralTypeNameRuleOracleTests.cs | 338 ++ .../Writers/OptionalDefaultEmissionTests.cs | 69 + 87 files changed, 14208 insertions(+), 2954 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/.gitignore create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/AppServiceHotspotProfilingResults.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/FullGenerationBenchmark.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/HotspotProfilingResults.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/LatestMainProfilingResults.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/NativeReductionPlan.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerPrototype.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/README.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/StructuralTypeNameReduction.md create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspacePostProcessingProfile.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeReferenceSite.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProfilingTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderCrefCleanupTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderNameReducerBindingTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderReducerTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderRetainedCleanupTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderSyntaxCleanupTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Statements/ExpressionStatementEmissionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5HandlerCaptureRegressionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaCacheTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultReflectionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarVisibilityTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverCompositionTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSourceTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluator.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Writers/OptionalDefaultEmissionTests.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelSerializationExtensionsDefinitionTests/ValidateGetFirstPropertyName.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelSerializationExtensionsDefinitionTests/ValidateGetFirstPropertyName.cs index 1073b1c7c91..dc1897c3843 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelSerializationExtensionsDefinitionTests/ValidateGetFirstPropertyName.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/Definitions/TestData/ModelSerializationExtensionsDefinitionTests/ValidateGetFirstPropertyName.cs @@ -37,7 +37,7 @@ public static string GetFirstPropertyName(this global::System.ReadOnlySpan #else key = global::System.Text.Encoding.UTF8.GetString(local.Slice(0, bytesConsumed).ToArray()); #endif - (bytesConsumed += (jsonPath.Length - local.Length)); + bytesConsumed += (jsonPath.Length - local.Length); return key; } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ScmMethodProviderCollectionTests/JsonLinesRequestGeneratesAsyncStreamingConvenienceMethod(WriteToAsyncStj).cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ScmMethodProviderCollectionTests/JsonLinesRequestGeneratesAsyncStreamingConvenienceMethod(WriteToAsyncStj).cs index 3a964ba8718..53c53cb3596 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ScmMethodProviderCollectionTests/JsonLinesRequestGeneratesAsyncStreamingConvenienceMethod(WriteToAsyncStj).cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/TestData/ScmMethodProviderCollectionTests/JsonLinesRequestGeneratesAsyncStreamingConvenienceMethod(WriteToAsyncStj).cs @@ -1,4 +1,4 @@ -public override async global::System.Threading.Tasks.Task WriteToAsync(global::System.IO.Stream stream, global::System.Threading.CancellationToken cancellationToken = ((global::System.Threading.CancellationToken)default)) +public override async global::System.Threading.Tasks.Task WriteToAsync(global::System.IO.Stream stream, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) { bool first = true; await foreach (T value in _values.WithCancellation(cancellationToken)) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/.gitignore b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/.gitignore new file mode 100644 index 00000000000..91a99327c4b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/.gitignore @@ -0,0 +1 @@ +BenchmarkDotNet.Artifacts/ diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/AppServiceHotspotProfilingResults.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/AppServiceHotspotProfilingResults.md new file mode 100644 index 00000000000..fad0ab1e30a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/AppServiceHotspotProfilingResults.md @@ -0,0 +1,99 @@ +# AppService production SDK hotspot profiling + +> Historical pre-main-merge investigation. Measurements and legacy/shadow phase names below do not describe the current generator. See [README.md](README.md) for the updated workloads and measurement caveats. + +Date: 2026-10-22. + +## Status: blocked before SDK generation + +**No AppService performance measurements have been collected.** This file records the setup attempt and the tooling blocker; it must not be read as a completed production-service profile. The separate [fixture profiling report](HotspotProfilingResults.md) is unchanged and does not contain AppService results. + +The user approved generation and profiling of **only `Azure.ResourceManager.AppService`**, using the sibling local Azure SDK for .NET repository. Replacing Roslyn `Simplifier.ReduceAsync` is already the agreed direction; the production profile will characterize replacement requirements and additional CPU/allocation hotspots, not reopen that decision. + +## Recorded inputs + +| Item | Value | +| --- | --- | +| SDK repository | `/workspaces/azure-sdk-for-net` | +| SDK revision | `97b9032786d85215b83d87ea8f2859f44bd10662` | +| Service | `sdk/websites/Azure.ResourceManager.AppService` | +| Service version | `1.6.0-beta.1`; `ApiCompatVersion` `1.5.0` | +| Pinned specs | `Azure/azure-rest-api-specs` at `a61a447dd6ecb9063b7ec7da5b9cd74c4d4b4e6a` | +| Spec directory | `specification/web/resource-manager/Microsoft.Web/AppService` | +| Actual environment | Codespaces/container Linux, not WSL | +| Required SDK | `.NET SDK 10.0.401`, from the SDK repo's `global.json`; `rollForward: feature` | +| Installed SDKs | `10.0.103`, `10.0.400` — neither satisfies that pin | + +The management emitter's existing lockfile was restored successfully using `npm ci --no-audit --no-fund` (488 packages, approximately 21 seconds reported by npm). No dependency versions or lockfile contents were changed. This replaces stale installed September packages with the existing October lockfile inputs; actual loaded generator assembly versions will be recorded after a successful build, not inferred from package names. + +## Attempted official scoped workflow + +From `eng/packages/http-client-csharp-mgmt`: + +```sh +npm ci --no-audit --no-fund +pwsh -NoProfile -File ./eng/scripts/RegenSdkLocal.ps1 \ + -Services Azure.ResourceManager.AppService -Parallel 1 -SaveInputs +``` + +The emitter build succeeded. The management-generator .NET build then failed because the pinned SDK could not be resolved: + +```text +Requested SDK version: 10.0.401 +Installed SDKs: 10.0.103, 10.0.400 +global.json file: /workspaces/azure-sdk-for-net/global.json +A compatible .NET SDK was not found. +``` + +The wrapper failed during build step 1, before service generation. No TypeSpec service compilation, new saved-input generation, generator replay, or performance trace was completed. A stale pre-existing generator binary was not substituted, and `global.json` was not relaxed or edited. + +## Required manual tooling action + +Install **.NET SDK 10.0.401**, preferably alongside the existing SDKs, and verify that it is visible to `dotnet --list-sdks`. A user-local installation is also usable; provide its path so the generation command can explicitly select it without changing repository files. + +For example, using the official [dotnet-install script](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script): + +```sh +# After obtaining dotnet-install.sh from the official .NET distribution: +bash dotnet-install.sh --version 10.0.401 --install-dir "$HOME/.dotnet-sdk-10.0.401" +"$HOME/.dotnet-sdk-10.0.401/dotnet" --list-sdks +``` + +No host SDK or kernel settings were installed/changed by this investigation. The installed `powershell-yaml` 0.4.12 satisfies the script's minimum 0.4.7 requirement. + +For the later native/on-CPU profile, Linux `perf` is still missing and `perf_event_paranoid=4` remains restrictive. Kernel-matched perf tools and appropriate WSL/host permissions are a **separate** manual requirement. Managed EventPipe collection can proceed once the .NET SDK build blocker is removed. `dotnet-gcdump` remains optional for retained-heap/root attribution; allocation traces alone do not establish retained memory or leaks. + +## Safety checks and local evidence + +Before running the workflow, all **2,677 original service files / 31,407,964 bytes** were hashed and archived. After the failure, the complete service file manifest matched the original exactly. Tracked package/lock/global/spec inputs also remained unchanged, and SDK `git status --short` still showed only its pre-existing local `.pi/` directory. + +Raw setup evidence and prepared replay/analyzer scripts are at: + +```text +/tmp/mtg-appservice-20261022/ +``` + +Important files include: + +- `original-service.tar.gz`, `original-service-manifest.json` +- `dependency-input-manifest.json`, `blocked-status.json` +- `npm-ci.log`, `regeneration.log`, regeneration start/end timestamps +- `capture_and_restore.py`, `profile_appservice.py`, `analyze/` + +A stable local copy of the small evidence/scripts and original archive is saved under: + +```text +/workspaces/typespec/.pi/benchmarks/appservice-20261022/ +``` + +Both locations are local artifacts, not published attachments; `/tmp` is ephemeral. + +## Resume after SDK installation + +1. Revalidate the SDK/service state and reuse the existing original snapshot; do not overwrite it. +2. Rerun the exact official scoped regeneration command, selecting the installed SDK. Keep `-Services Azure.ResourceManager.AppService`; omitting the filter would regenerate all management SDKs and is outside scope. +3. Preserve the complete fresh result, saved inputs, custom code, and ancestor NuGet/ApiCompat context. Restore only investigation-created changes to the original service after preserving evidence. +4. Build the local management generator in Release and record actual core/plugin dependency versions and binary hashes. Its pinned MTG dependency is not automatically the sibling TypeSpec source used by the fixture benchmark. +5. Validate generator-only replay output against the freshly generated C# inventory before trusting measurements. Use the actual `ManagementClientGenerator` export and exclude TypeSpec compilation/build activity from profiling. +6. Run separate repeated CLI timing/process-memory measurements, EventPipe sampled-thread collection, and GC/allocation-stack collection. New CLI processes are not warm in-process generation. Do not interpret blocked/safepoint samples as CPU percentages or allocation traffic as retained heap. +7. Replace this blocked-status report with the completed AppService findings, keeping the fixture report separate. No full regeneration pipeline, generator implementation changes, commits, or pushes are authorized by this profiling task. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/FullGenerationBenchmark.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/FullGenerationBenchmark.cs new file mode 100644 index 00000000000..19c226b4074 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/FullGenerationBenchmark.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.TypeSpec.Generator.Perf +{ + public class FullGenerationBenchmark + { + private const string ProfileEnvironmentVariable = "POSTPROCESSING_BENCHMARK_PROFILE_STEPS"; + private const string ProfileOutputDirectoryEnvironmentVariable = "POSTPROCESSING_BENCHMARK_PROFILE_DIR"; + + private bool _profileSteps; + + [Params(false, true)] + public bool DisableRoslynReduce { get; set; } + + [GlobalSetup] + public void GlobalSetup() + { + _profileSteps = string.Equals( + Environment.GetEnvironmentVariable(ProfileEnvironmentVariable), + "true", + StringComparison.OrdinalIgnoreCase); + } + + [Benchmark] + public async Task GenerateSampleTypeSpecProject() + { + var postProcessingProfile = _profileSteps ? new GeneratedCodeWorkspacePostProcessingProfile() : null; + var generationProfile = _profileSteps ? new GeneratedCodeWorkspacePostProcessingProfile() : null; + GeneratedCodeWorkspace.PostProcessingProfile = postProcessingProfile; + CSharpGen.GenerationProfile = generationProfile; + + var benchmarkDirectory = CreateBenchmarkInputDirectory(); + var stopwatch = Stopwatch.StartNew(); + try + { + CodeModelGenerator.Instance = new BenchmarkCodeModelGenerator(benchmarkDirectory, DisableRoslynReduce); + CodeModelGenerator.Instance.Configure(); + + var csharpGen = new CSharpGen(); + await csharpGen.ExecuteAsync(); + + return Directory.GetFiles(CodeModelGenerator.Instance.Configuration.ProjectGeneratedDirectory, "*.cs", SearchOption.AllDirectories) + .Sum(static path => (int)new FileInfo(path).Length); + } + finally + { + stopwatch.Stop(); + if (generationProfile != null) + { + WriteProfile( + generationProfile, + $"full-generation-profile-reduce-{!DisableRoslynReduce}-{DateTime.UtcNow:yyyyMMddHHmmssfff}.csv", + $"Full generation invocation elapsed ms: {stopwatch.Elapsed.TotalMilliseconds:F3}{Environment.NewLine}" + + $"Input directory: {benchmarkDirectory}{Environment.NewLine}"); + } + + if (postProcessingProfile != null) + { + WriteProfile( + postProcessingProfile, + $"full-generation-post-processing-profile-reduce-{!DisableRoslynReduce}-{DateTime.UtcNow:yyyyMMddHHmmssfff}.csv", + $"Full generation post-processing profile{Environment.NewLine}" + + $"Input directory: {benchmarkDirectory}{Environment.NewLine}"); + } + + CSharpGen.GenerationProfile = null; + GeneratedCodeWorkspace.PostProcessingProfile = null; + TryDeleteDirectory(benchmarkDirectory); + } + } + + private static void WriteProfile(GeneratedCodeWorkspacePostProcessingProfile profile, string fileName, string header) + { + var profileDirectory = GetProfileOutputDirectory(); + Directory.CreateDirectory(profileDirectory); + File.WriteAllText(Path.Combine(profileDirectory, fileName), header + profile.GetSummary()); + } + + private static string CreateBenchmarkInputDirectory() + { + var sourceDirectory = FindFullGenerationInputDirectory(); + var benchmarkDirectory = Path.Combine(Path.GetTempPath(), "typespec-full-generation-benchmark", Guid.NewGuid().ToString("N")); + CopyDirectory(sourceDirectory, benchmarkDirectory); + return benchmarkDirectory; + } + + private static string FindFullGenerationInputDirectory() + { + const string relativePath = "packages/http-client-csharp/generator/TestProjects/Local/Sample-TypeSpec"; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var inputDirectory = Path.Combine(directory.FullName, relativePath); + if (File.Exists(Path.Combine(inputDirectory, "tspCodeModel.json")) && + File.Exists(Path.Combine(inputDirectory, "Configuration.json"))) + { + return inputDirectory; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException($"Could not find '{relativePath}' from '{AppContext.BaseDirectory}'."); + } + + private static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var sourceFile in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, sourceFile); + // Build artifacts are not generator inputs and can vary between runs. + if (relativePath.Split(Path.DirectorySeparatorChar).Any(static part => part is "bin" or "obj")) + { + continue; + } + + var destinationFile = Path.Combine(destinationDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + File.Copy(sourceFile, destinationFile, overwrite: true); + } + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch + { + // Best-effort cleanup for benchmark temp output. + } + } + + private static string GetProfileOutputDirectory() + { + var configuredPath = Environment.GetEnvironmentVariable(ProfileOutputDirectoryEnvironmentVariable); + return string.IsNullOrWhiteSpace(configuredPath) + ? Path.Combine(Path.GetTempPath(), "typespec-post-processing-profiles") + : Path.GetFullPath(configuredPath); + } + + private sealed class BenchmarkCodeModelGenerator : CodeModelGenerator + { + public BenchmarkCodeModelGenerator(string outputPath, bool disableRoslynReduce) + : base(new GeneratorContext(LoadConfiguration(outputPath, disableRoslynReduce))) + { + } + + private static Configuration LoadConfiguration(string outputPath, bool disableRoslynReduce) + { + var configuration = JsonNode.Parse(File.ReadAllText(Path.Combine(outputPath, "Configuration.json")))!; + configuration["disable-roslyn-reduce"] = disableRoslynReduce; + return Configuration.Load(outputPath, configuration.ToJsonString()); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/GeneratorInitializer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/GeneratorInitializer.cs index f1c4bdf169d..92f23a76a8d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/GeneratorInitializer.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/GeneratorInitializer.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.IO; -using System.Reflection; +using Microsoft.TypeSpec.Generator.SourceInput; namespace Microsoft.TypeSpec.Generator.Perf { @@ -10,12 +11,17 @@ internal static class GeneratorInitializer { public static void Initialize() { - GeneratorHandler generatorHandler = new GeneratorHandler(); - generatorHandler.LoadGenerator(new CommandLineOptions - { - OutputDirectory = Path.Combine(Directory.GetParent(Assembly.GetExecutingAssembly().Location)!.FullName, "Projects", "Model"), - GeneratorName = "CodeModelGenerator" - }); + // Microbenchmarks need the base generator, not plugin discovery across + // every assembly in the benchmark/test output directory. + var outputPath = Path.Combine(AppContext.BaseDirectory, "Projects", "Model"); + CodeModelGenerator.Instance = new BenchmarkCodeModelGenerator(outputPath); + CodeModelGenerator.Instance.Configure(); + CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel(null, null); + } + + private sealed class BenchmarkCodeModelGenerator(string outputPath) + : CodeModelGenerator(new GeneratorContext(Configuration.Load(outputPath))) + { } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/HotspotProfilingResults.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/HotspotProfilingResults.md new file mode 100644 index 00000000000..78ca306b07e --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/HotspotProfilingResults.md @@ -0,0 +1,257 @@ +# MTG CPU and allocation hotspot investigation + +> Historical pre-main-merge investigation. Measurements and legacy/shadow phase names below do not describe the current generator. See [README.md](README.md) for the updated workloads and measurement caveats. + +Date: 2026-10-22. Companion to [NativeReductionPlan.md](NativeReductionPlan.md). + +## Conclusions + +1. **Replacing `Simplifier.ReduceAsync` is the agreed direction**, confirmed by the user on 2026-10-22. This investigation is not a gate to reconsider that decision; it characterizes replacement requirements and additional hotspots. Roslyn-backed reference discovery/reachability is another substantial cost in the small full-generation workload. +2. **Simplification is the clearest allocation hotspot on the larger saved C# corpus.** Approximately 91–92% of sampled allocated bytes have simplification on their stack. Allocation traffic is about 174 MB per warm 139-document operation and 943 MB at the synthetic 695-document size. +3. **These data do not establish a method-level on-CPU ranking or select Rust over specialized C#.** Linux `perf` is unavailable here. EventPipe thread samples include blocked threads, GC safepoints, and contention. They identify execution contexts, not reliable percentages of CPU cycles. Keep the existing Roslyn path as the comparison baseline/fallback while designing its replacement. +4. Prioritize **avoiding repeated general-purpose semantic work**, then compare a bounded specialized C# implementation with Rust. Do not start by porting Roslyn, the provider object model, or the graph traversal alone. +5. Validate these priorities against **Azure.ResourceManager.AppService** next. The full-generation fixture uses the base generator, not the production management plugin; the saved-corpus test exercises document processing, not full SDK generation. + +No generator implementation was changed, no Rust prototype was created, and no SDK generation/pipeline was run for this report. + +## Environment and workload provenance + +| Item | Value | +| --- | --- | +| TypeSpec revision | `1b0c2382b2bcb208e949093f571ca073c8cbf6c7` | +| Branch | `benchmark-native-reducer-plan` | +| Machine | AMD EPYC 7763; 32 logical / 16 physical cores visible | +| Actual host | Codespaces/container, Debian 13, Linux `6.8.0-1064-azure`, x64 — **not WSL** | +| Generator build | Release; pinned SDK `10.0.103` | +| Measurement runtime | .NET `10.0.11`, concurrent workstation GC | +| Dependencies | Roslyn `4.8.0`; BenchmarkDotNet `0.13.4` | +| Diagnostics | `dotnet-trace 9.0.661903`; TraceEvent `3.1.21` for offline analysis | +| Fixture | `generator/TestProjects/Local/Sample-TypeSpec` | +| Model input | 500,067 bytes; SHA-256 `7c1079d5d992fb2c21cce956f97d439c377ce7beead4d116c56c06212a732e3e` | +| Original configuration | SHA-256 `519de546b62c923276725163bf695bae0fc9399f65529154d747c724d48432a9` | + +The entire fixture was copied, preserving custom source and project context, rather than using its two JSON files alone. The original fixture was not edited. The model contains 28 models, 14 enums, and one client; the base-generator result contains **19 generated C# files / 77,002 bytes** with reduction enabled. Its sequential profile records **53 document-processing operations**; that counter is not the number of final output files. An earlier log-tail estimate of 14 output files was incorrect; the preserved output inventory is authoritative. + +The separate post-processing benchmark consumes the original **139 saved generated C# files / 890,975 bytes**. Its ×5 case has **695 documents**, with namespaces rewritten by the existing benchmark to make copies distinct. This is a synthetic scaling workload, not five independently generated SDKs. + +The build succeeded with two existing assembly-version conflict warnings: `System.Memory.Data` 10.0.0.3 versus 10.0.0.10, and `System.ClientModel` 1.14 versus 1.16. The primary versions selected were 10.0.0.3 and 1.14. These inputs were not upgraded for the experiment. + +## Measurement design and interpretation limits + +Collections were serial; measured workloads did not overlap builds or other benchmark runs. + +- An external runner calls the **existing public benchmark methods**, without modifying their implementation. It records elapsed time, process CPU time, `GC.GetTotalAllocatedBytes`, collection counts, and process-memory counters. EventSource markers delimit each invocation. +- Full-generation comparisons fix `UseProviderReferenceMap=false`. The existing `disable-roslyn-reduce` option is changed only in an external fixture copy. Three process pairs use orders on/off, off/on, on/off; each process runs seven invocations. The first is separated from the six subsequent warm invocations. +- Normal-concurrency sampled-thread and allocation traces are collected **separately**. Warm regions exclude the first invocation using markers. There are 12 / 8 / 3 warm sampled-stack invocations for full generation / corpus ×1 / corpus ×5, and 4 / 3 / 2 warm allocation-trace invocations. +- Sequential step profiles provide attribution, not normal-concurrency timing. Setting `POSTPROCESSING_BENCHMARK_PROFILE_STEPS=true` replaces document `Task.WhenAll` with sequential processing. Full-generation step medians use three warm invocations; corpus step medians use two. +- Step allocation counters are process-wide estimates. Parent/child rows overlap; **do not add nested durations or allocations**. Async sampled stacks may omit an originating phase, so stack buckets are heuristic attribution, not exact phase boundaries. +- The full benchmark includes fixture copying, output byte counting, and cleanup. Its internal profile stopwatch starts after copying and stops before cleanup/profile emission. These scopes differ. +- First-in-process benchmark timing **does not include CLI process startup, plugin discovery, or TypeSpec compilation**. “Warm” below means repeated generation within one process, not merely warmed filesystem caches. + +All six traces resolved successfully with **zero reported lost events and zero missing stacks** for analyzed sample/allocation events. Sampled allocation totals were 1.3%, 2.8%, and 2.8% above exact traced-run allocation counters for full generation / corpus ×1 / corpus ×5. This is a useful sampling cross-check, not proof of exact allocation attribution. + +## 1. Full-generation phase hotspots + +Warm median step-profile values, **reference-map mode off**. MB here means 1,000,000 bytes. + +| Phase | Elapsed ms | Allocated MB | Interpretation | +| --- | ---: | ---: | --- | +| `Generation.PostProcessAsync` | 941.085 | 34.008 | Parent containing reference discovery, internalization, and removal | +| `PostProcessor.Remove.BuildAllReferenceMapAsync` | 479.555 | 13.067 | Child: all-reference map construction | +| `PostProcessor.Internalize.BuildPublicReferenceMapAsync` | 236.475 | 12.783 | Child: public-reference map construction | +| `Generation.WriteGeneratedFilesToDisk` | 274.340 | 15.581 | Parent includes deferred document processing, **not just disk I/O** | +| `Roslyn.Simplifier.ReduceAsync` | 259.569 | 14.899 | Document-processing child; 53 operations | +| `Generation.WriteTypeProviders` | 73.218 | 6.272 | Provider-to-C# writing | +| `Generation.CreateSourceInputModel` | 42.063 | 1.466 | Source-input setup | +| `Generation.BuildTypeProviders` | 30.077 | 4.873 | Provider construction | +| `Generation.ResolveExternalTypeReferences` | 26.898 | 9.178 | Smaller elapsed cost but material allocations | + +The expensive reference-map work includes Roslyn symbol/reference discovery. By comparison, `PostProcessor.Internalize.VisitSymbolsFromRoot` measured only **0.483 ms** in this profile. This does **not** support a native graph-traversal rewrite as the first target; discovering semantic edges is different from traversing an already-built compact graph. + +Source landmarks: + +- [`ReferenceMapBuilder.cs`](../src/PostProcessing/ReferenceMapBuilder.cs): public/all-reference discovery and Roslyn symbol queries. +- [`PostProcessor.cs`](../src/PostProcessing/PostProcessor.cs): internalization, removal, and invalid-reference cleanup. +- [`GeneratedCodeWorkspace.cs`](../src/PostProcessing/GeneratedCodeWorkspace.cs): semantic-model acquisition and `Simplifier.ReduceAsync`. +- [`CSharpGen.cs`](../src/CSharpGen.cs): phase orchestration and deferred generated-file enumeration. + +### Clean reduction-on/off experiment + +Each row compares the median of six warm invocations **within that process**. Reference-map mode stays off. + +| Pair | Reduction on ms | Reduction off ms | Wall reduction | Allocation reduction | +| --- | ---: | ---: | ---: | ---: | +| 1 | 1,260.172 | 1,130.202 | 10.3% | 22.9% | +| 2, reversed order | 1,218.413 | 943.034 | 22.6% | 22.9% | +| 3 | 1,298.262 | 1,114.900 | 14.1% | 23.0% | + +Wall results are noisy; the allocation difference is more repeatable. For completeness, pooling all 18 warm observations per mode gives medians of **1,280.415 versus 1,001.874 ms**, **3,801.146 versus 1,772.671 ms process CPU**, and **70.853 versus 54.466 MiB allocated**. Pooled medians and medians of process medians are different aggregations; do not mix them to manufacture one headline speedup. + +The median first-in-process durations across the three launches were **3,719.920 / 3,352.849 ms** on/off, excluding CLI startup. + +**This is an experimental bound, not a safe optimization.** Disabling reduction retains earlier semantic-model work and changes output: 13 of the 19 generated C# files differ; generated-source bytes change from 77,002 to 81,369. The benchmark's broader file-byte result changes from 102,732 to 107,099. Observed transformations include removing global qualifications and redundant parentheses; the workload is not exclusively namespace trimming. No binding-equivalence claim is made for the disabled mode. + +### Official BenchmarkDotNet cross-check + +The repository's `FullGenerationBenchmark`, normal concurrency and reduction enabled, was also run independently with MemoryDiagnoser: one launch, one warmup, five requested measured iterations, one invocation/unroll. BDN removed one outlier in the false case, leaving four retained observations; true retained five. Confidence intervals are wide, so this is a small cross-check, not a performance acceptance result. + +| Existing reference-map mode | Mean ms | Median ms | StdDev ms | Managed allocation, MiB/op | +| --- | ---: | ---: | ---: | ---: | +| `false` | 990.276 | 1,011.326 | 59.236 | 71.57 | +| `true` | 499.880 | 498.381 | 19.329 | 49.29 | + +This existing C# mode is a **different experiment** from turning simplification off. Its observation reinforces investigating how reference discovery is performed, but output/behavior equivalence was not independently established by this profiling run. Do not combine its numbers with the reducer experiment or infer a Rust benefit. + +## 2. Larger-corpus CPU and memory evidence + +Normal-concurrency, uninstrumented runner results. Corpus setup is outside the measured benchmark method; file parsing/workspace processing within that method remains included. + +| Workload | Warm observations | Median wall ms | Median process CPU ms | Allocated MB/op | Observed process peak RSS, MiB | +| --- | ---: | ---: | ---: | ---: | ---: | +| 139 documents | 6 | 1,419.551 | 19,481.552 | 173.695 | 381.6 | +| 695 documents, synthetic ×5 | 4 | 2,974.685 | 27,833.030 | 942.666 | 511.6 | + +The RSS column is the maximum recorded process-peak counter, **including the first invocation**; it is not a warm per-operation median. For ×5, the maximum comes from iteration 0. + +Process CPU time sums work across all threads, so it can exceed elapsed time on this 32-logical-CPU host. This shows substantial process CPU consumption, but does not assign that CPU to individual methods. GC/scheduling/parallelism contribute. The synthetic ×5 case is not a linear production-service scaling model. + +Sequential attribution cross-check: + +| Workload | `ReduceAsync` ms | Process-document parent ms | `ReduceAsync` allocated MB | +| --- | ---: | ---: | ---: | +| 139 documents | 1,699.196 | 1,759.043 | 158.423 | +| 695 documents | 3,102.631 | 3,320.862 | 800.787 | + +Do not subtract these sequential numbers from the concurrent timings to estimate speedup. They do show simplification dominating document-processing work in this isolated corpus benchmark, which does **not** run the full-generation reference-map/internalization pipeline. + +### Allocation-stack attribution + +Percentages are sampled allocated-byte weights in warm regions. The analyzer uses mutually exclusive, precedence-ordered **stack-presence buckets**; they are not exact measured phase budgets. + +| Stack bucket | Full generation | Corpus ×1 | Corpus ×5 | +| --- | ---: | ---: | ---: | +| Reachability/reference analysis | 36.94% | — | — | +| Roslyn simplification | 23.52% | 91.42% | 92.13% | +| Other Roslyn | 18.31% | 8.54% | 7.86% | +| Input/JSON-related stacks | 12.14% | — | — | +| MTG writing | 4.23% | — | — | +| MTG providers | 3.91% | — | — | + +Full-generation allocation types include `System.String` (13.35%) and `System.Char[]` (6.77%), but their presence does not make all string processing an appropriate native boundary. The corpus runs prominently allocate `NodeOrTokenToReduce[]` (9.24% / 8.72%), Roslyn symbols, closures, tasks, and strings. Inclusive stacks repeatedly include `CSharpNameReducer.SimplifyName`, `CSharpSemanticModel.GetSymbolInfo`, and simplification rewriters. Parent method percentages overlap and must not be summed. + +**Allocation is not retention.** Hundreds of MB of allocation traffic per invocation do not mean that amount remains live. RSS includes runtime/JIT/native/mapped pages and is not the managed live heap. No heap-root/type-retention snapshot was collected, and these runs establish neither a leak nor a retained-object ranking. + +## 3. What the thread samples can—and cannot—say + +| Warm trace | Managed observations | External observations | +| --- | ---: | ---: | +| Full generation | 18,669 | 443,864 | +| Corpus ×1 | 69,349 | 248,562 | +| Corpus ×5 | 84,956 | 212,506 | + +External observations are largely waits. Including them in a “CPU percentage” denominator would be misleading. Filtering to `Type=Managed` still does **not** yield an on-CPU profile: approximately 43–50% of managed leaf observations are `Thread.PollGC`; monitor/reader-writer-lock paths and other waits also appear. Safepoint/wait samples are not proof that these methods burn that share of CPU. + +For contextual evidence only, full-generation managed-stack buckets contain simplification in 35.58% and reference analysis in 35.04% of observations. Corpus simplification appears in 85.68% / 82.76%. These support the phase/allocation findings, but **must not be presented as CPU-time fractions**, GC CPU percentages, or an Amdahl-law bound. + +A native/kernel CPU profile is still required to distinguish actual on-CPU work in binding, hashing, traversal, GC, JIT, and synchronization. The current shortlist is based on timed phases, process CPU, and allocation evidence—not a verified native instruction-level profile. + +## 4. Rust candidate assessment + +| Priority | Candidate | Evidence and boundary decision | +| --- | --- | --- | +| Established replacement target | Bounded name/type reduction | Replacing Roslyn reduction is already agreed. A conservative generator-aware reducer could avoid general semantic work. Define the supported transformations and compare specialized C# and Rust under identical rules, including custom code, collisions, aliases, and fallback. | +| Additional hotspot | Reduce semantic reference-discovery work | Expensive public/all-reference map phases in full generation. Examine reuse of generator-owned identities/edges and repeated Roslyn queries in C#. A compact native graph kernel is only useful if graph computation itself, rather than extracting/discovering edges, becomes material. | +| 3 | Concurrency/allocation policy in Roslyn processing | High process CPU and safepoint/lock observations warrant a separate controlled C# scheduling/allocation experiment. This is not evidence that translating code to Rust removes contention. Sequential step profiling is not a clean concurrency comparison. | +| Lower for now | General CodeWriter/string processing, provider construction, input JSON | Present but smaller than Roslyn/reference discovery in these workloads. No current evidence warrants rewriting broad modules. | + +Applying already-proven-safe edits in Rust is a small semantic boundary, but cannot eliminate the measured upstream Roslyn binding by itself. Exporting the whole Roslyn/provider graph introduces copying, extraction, ownership, and interop costs. A native parser alone cannot preserve C# name binding. + +Before any prototype, follow the correctness and three-way comparison gates in the planning document. Keep C# orchestration and extensions; include normal-concurrency end-to-end time, FFI/encoding/copying, fallback, native allocations, and platform packaging. These data do not establish that Rust beats specialized C#. + +## 5. Missing tooling and manual WSL actions + +**Available here:** .NET 10 and `dotnet-trace`, used via `$HOME/.dotnet/tools/dotnet-trace` because that directory is not on this session's PATH. No host tools or kernel settings were installed/changed. + +**Needed for the requested WSL/native CPU follow-up:** + +1. Run in the intended WSL distro/host. This session is not WSL. +2. Install a usable Linux `perf` binary for that distro/kernel. WSL kernels may not have a matching `linux-tools-$(uname -r)` package; a distro tools package alone can leave an unusable wrapper. Verify the actual executable, or build `tools/perf` from the appropriate kernel source if required. +3. Have the administrator allow performance events / provide the necessary capabilities. Here `perf_event_paranoid=4` and container restrictions prevent assuming access. Changing only the container's tool installation may not be sufficient. +4. Smoke-test software events first; hardware PMU counters can remain unavailable under virtualization: + + ```sh + uname -r + perf --version + perf stat -e task-clock -- true + perf stat -e cycles,instructions -- true # only when supported + ``` + +5. Build before profiling, then profile the **actual generator/runner**, not a build or the BenchmarkDotNet parent process. For example, after adapting paths to the WSL checkout: + + ```sh + DOTNET_PerfMapEnabled=3 perf record -F 199 -e cpu-clock:u -g --call-graph dwarf \ + -o mtg.perf.data -- dotnet /path/to/Runner.dll 13 false /tmp/metrics.jsonl + perf report -i mtg.perf.data + ``` + + Confirm resolved managed names and useful mixed stacks before trusting attribution. Perf maps alone do not guarantee complete native/managed unwinding. + +**Optional for retained-memory attribution:** install `dotnet-gcdump` (not installed here), e.g. `dotnet tool install --global dotnet-gcdump`, and collect a separately labeled heap snapshot. `dotnet-dump` is another absent, optional diagnostic tool; neither was needed for the allocation-stack results above. Heap collection can induce GC/pauses and must not overlap clean timings. + +References: [.NET Linux profiling](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/trace-perfcollect-lttng), [dotnet-trace](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace), [perf-map configuration](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/debugging-profiling). + +## 6. Reproduction and local artifacts + +Stable local evidence and the exact investigation harness/parser sources are saved under: + +```text +/workspaces/typespec/.pi/benchmarks/mtg-20261022/ +``` + +This contains `input-manifest.json`, `binary-manifest.json`, `commands.json`, `environment.txt`, `summary.json`, per-invocation JSONL metrics, step CSVs, six stack-analysis JSON files, the output manifest/diff, BDN reports/log, and `runner/` / `analyze/` source projects. `.pi` is local/untracked; it is not a published artifact store. + +Original raw `.nettrace` / resolved `.etlx` files and preserved generated trees remain at **`/tmp/mtg-hotspots-20261022/`** (about 599 MB including all artifacts). `/tmp` is ephemeral; preserve those separately if auditing these exact traces later. They are not required to generate new traces. + +Build target, from `packages/http-client-csharp/generator`: + +```sh +dotnet build Microsoft.TypeSpec.Generator/perf/Microsoft.TypeSpec.Generator.Tests.Perf.csproj \ + -c Release --no-restore +``` + +To reconstruct this local investigation layout after losing `/tmp`, from the TypeSpec root at the recorded revision (scripts currently contain these checkout paths): + +```sh +mkdir -p /tmp/mtg-hotspots-20261022 +cp -R .pi/benchmarks/mtg-20261022/runner .pi/benchmarks/mtg-20261022/analyze /tmp/mtg-hotspots-20261022/ +cp .pi/benchmarks/mtg-20261022/{prepare.py,run_suite.py,summarize.py} /tmp/mtg-hotspots-20261022/ +python3 /tmp/mtg-hotspots-20261022/prepare.py +dotnet build /tmp/mtg-hotspots-20261022/runner/Runner.csproj -c Release +dotnet build /tmp/mtg-hotspots-20261022/analyze/Analyze.csproj -c Release +python3 /tmp/mtg-hotspots-20261022/run_suite.py measure +python3 /tmp/mtg-hotspots-20261022/run_suite.py profile +python3 /tmp/mtg-hotspots-20261022/run_suite.py post +python3 /tmp/mtg-hotspots-20261022/run_suite.py inventory +python3 /tmp/mtg-hotspots-20261022/run_suite.py analyze +python3 /tmp/mtg-hotspots-20261022/summarize.py +``` + +These commands use separate child processes per collection, fixed reference-map mode, explicit EventPipe markers, and full fixture copies. Do not run the suites concurrently. Existing package restore/build prerequisites still apply. + +Official BDN command, from this `perf` directory: + +```sh +dotnet ../../artifacts/bin/Microsoft.TypeSpec.Generator.Tests.Perf/Release/net10.0/Microsoft.TypeSpec.Generator.Tests.Perf.dll \ + --filter '*FullGenerationBenchmark*' --artifacts /tmp/mtg-hotspots-20261022/bdn \ + --iterationCount 5 --warmupCount 1 --launchCount 1 --invocationCount 1 --unrollFactor 1 +``` + +For an individual trace, the recorded commands use `dotnet-trace collect --profile dotnet-sampled-thread-time` or `--profile gc-verbose`, plus `--providers MTG-Profile:0xFFFFFFFFFFFFFFFF:4 --buffersize 512`, launching the compiled runner after `--`. Do not instead trace the BDN host and assume its benchmark child is included. + +## Next: AppService production-service validation + +The requested service is `sdk/websites/Azure.ResourceManager.AppService` in sibling `/workspaces/azure-sdk-for-net`, observed at `97b9032786d85215b83d87ea8f2859f44bd10662`. Its spec pin is `a61a447dd6ecb9063b7ec7da5b9cd74c4d4b4e6a`, `specification/web/resource-manager/Microsoft.Web/AppService`. + +Use the official scoped local management-generator workflow with exact `-Services Azure.ResourceManager.AppService -Parallel 1 -SaveInputs`; never omit `-Services` because the default regenerates all management SDKs. Snapshot existing service files, restore locked dependencies first (installed September packages currently differ from the October lockfile), preserve fresh saved inputs plus custom/back-compat context, then profile Release generator-only replay separately from TypeSpec/build work. Restore only benchmark-created changes after preserving evidence. + +Record the **actual resolved core and management generator versions**: the SDK's local management-generator build normally uses its pinned MTG dependencies, not automatically this sibling TypeSpec source. Reusing the profiling method does not make differing generator versions a valid direct A/B. Treat AppService as a separate workload/report. Before actual generation, the repository's TypeSpec-to-SDK workflow requests a high-level plan and yes/no confirmation; that confirmation has been requested. No AppService generation or dependency restore has started yet. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/LatestMainProfilingResults.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/LatestMainProfilingResults.md new file mode 100644 index 00000000000..979d35fead8 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/LatestMainProfilingResults.md @@ -0,0 +1,70 @@ +# Benchmark refresh against latest main + +Date: 2026-10-22. Workloads and reproduction details: [README.md](README.md). + +## Source and environment + +- Main: `721154b2ab55bfc78ab4850e86c40487f66241f2`. +- Merged into `benchmark-native-reducer-plan`, previously `7eae3f31c`. +- Generator implementation and tests taken from main, with opt-in phase/document timing hooks and the new profiling tests. Old legacy/shadow implementations were not carried forward. This is a **main-based instrumentable build**, not an unmodified-main versus candidate comparison. +- Release; .NET SDK **10.0.402**, runtime **10.0.13**, concurrent workstation GC; BenchmarkDotNet **0.13.4**, Roslyn **4.8.0**. +- Linux/WSL2, Intel Xeon Platinum 8370C; 16 logical / 8 physical cores reported. This is not the Codespaces host used for the historical reports. +- Full generation: current `TestProjects/Local/Sample-TypeSpec`, copied into a fresh temporary directory per invocation, preserving its configuration/custom sources/project. Build artifacts are excluded. Uses the base generator, **not the management plugin**. +- Model SHA-256: `d9a6ccce7676fdca5b3cda979540ede872f7e240a830bdbf26f1ae156480305a`. +- Original configuration SHA-256: `519de546b62c923276725163bf695bae0fc9399f65529154d747c724d48432a9`. Only `disable-roslyn-reduce` is overridden in memory for each case. +- Saved generated corpus: **178 C# files / 1,184,088 bytes**. The ×5 case has **890 documents** with rewritten namespaces. Corpus SHA-256: `4651e60f35fff14f3ef94885666ca48783ba92cb4c72844cdef9febeb24d0bc8` (concatenate sorted relative POSIX paths, NUL, then file bytes). + +## Unprofiled results + +Step profiling was **disabled**. All six cases ran sequentially without concurrent builds. Job: one warmup iteration, 250 ms target iteration time, 15–20 measurement iterations; MemoryDiagnoser enabled. Operations slower than the target still run to completion. Error is BenchmarkDotNet's half-width of the 99.9% confidence interval; allocation units below are binary MiB. + +| Workload | Roslyn reduction | Mean | Error | StdDev | Allocated/op | +| --- | --- | ---: | ---: | ---: | ---: | +| Saved corpus ×1 | On | 1,187.2 ms | 226.06 ms | 251.27 ms | 275.31 MiB | +| Saved corpus ×1 | Off | 274.2 ms | 49.32 ms | 56.80 ms | 25.11 MiB | +| Saved corpus ×5 | On | 4,797.4 ms | 279.79 ms | 322.20 ms | 1,090.56 MiB | +| Saved corpus ×5 | Off | 830.8 ms | 36.02 ms | 41.48 ms | 121.69 MiB | +| Full base generation | On | 2,174.9 ms | 61.93 ms | 68.84 ms | 75.88 MiB | +| Full base generation | Off | 2,061.2 ms | 48.34 ms | 53.73 ms | 33.83 MiB | + +The unprofiled run completed in **318.88 s**, excluding its preceding build and smoke run. + +### Interpretation + +- Disabling reduction lowered mean saved-corpus elapsed time by about **77% at ×1** and **83% at ×5**, and allocations by about **91%** and **89%**. These are point estimates for this workload, not a guaranteed replacement speedup. +- Full-generation elapsed time changed by only about **5%**, while allocations fell about **55%**. Separate profiled invocations attribute roughly **1.96 s** to main's project/package-reference setup. That setup is inside the benchmark; do not interpret the full-generation mean as provider processing alone or infer a production bottleneck from this small fixture. +- The ×1 measurements are noisy; BenchmarkDotNet flagged a possible multimodal distribution for a saved-corpus case. Retain the reported error/StdDev rather than treating small changes as significant. +- Turning reduction off changes generated source presentation; it is an attribution control, **not** a validated replacement. The saved corpus excludes handwritten sources and is not a compilation/SDK correctness test. No native reducer, production generator optimization, management SDK regeneration, or pipeline run was performed. +- These data are elapsed-time and managed-allocation measurements, **not method-level on-CPU rankings**. The hardware, input model, saved corpus, and generator pipeline differ from the historical reports, so direct old/new speedup claims would be invalid. + +## Validation and benchmark repairs + +- Release benchmark build: **0 warnings, 0 errors**. +- Focused tests: **83 passed**, covering `CSharpGenTests`, `ProviderReferenceMapAnalyzerTests`, and the new `ProfilingTests`. +- New tests compare profiled/unprofiled document text and filenames with reduction both enabled and disabled, verify both documents are processed, and verify simplifier profile rows respect the option. +- **All 47 parameterized cases** completed with a real Dry job, including all 36 context-definition combinations, the writer benchmark, four method-provider cases, and six generation/document-processing cases. Dry wall time: **89.76 s**. These cold single-iteration numbers are execution checks, not performance baselines. +- All six unprofiled generation/document-processing cases completed successfully. Long-run timing was not recollected for the 41 microbenchmark cases. +- Fixed stale microbenchmark setup: avoid MEF scanning unrelated test-platform assemblies; initialize the base generator/source-input model directly. Replaced the obsolete PascalCase model JSON with a minimal current-schema namespace fixture because these microbenchmarks construct their measured providers in code. +- Updated the context-definition reflection delegate to main's `IReadOnlyList` return type. +- Fixed runner job selection: the custom default settings previously overrode `--job Dry`; explicit CLI jobs now use their own settings. The first attempted smoke run therefore used full iterations with profiling enabled; its numbers are **not** the unprofiled results above. +- Optional profiles preserve main's document concurrency. Their durations/allocations overlap across concurrent documents and nested scopes; never sum them as exclusive costs. + +## Reproduction and local evidence + +From this directory: + +```sh +dotnet build -c Release +dotnet run -c Release --no-build -- --filter '*' --job Dry +# Leave POSTPROCESSING_BENCHMARK_PROFILE_STEPS unset: +dotnet run -c Release --no-build -- \ + --filter '*FullGenerationBenchmark*' '*PostProcessingBenchmark*' \ + --artifacts BenchmarkDotNet.Artifacts/main-baseline +``` + +Raw local results (ignored): + +- `BenchmarkDotNet.Artifacts/main-baseline/results/BenchmarkRun-joined-2026-10-22-15-50-19-report.{csv,html}` and `-report-github.md`. +- `BenchmarkDotNet.Artifacts/final-dry/`: complete execution-check log and reports. + +The historical reports remain preserved and explicitly marked historical; use this refresh for the current main-based workloads. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ModelReaderWriterContextDefinitionBenchmark.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ModelReaderWriterContextDefinitionBenchmark.cs index 0704e9d5b2c..3c0fc082a56 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ModelReaderWriterContextDefinitionBenchmark.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ModelReaderWriterContextDefinitionBenchmark.cs @@ -19,7 +19,7 @@ namespace Microsoft.TypeSpec.Generator.ClientModel.Perf public class ModelReaderWriterContextDefinitionBenchmark { private ModelReaderWriterContextDefinition? _contextDefinition; - private Func>? _privateDelegate; + private Func>? _privateDelegate; [Params(1, 10, 100)] public int ModelCount; @@ -38,7 +38,7 @@ public void Setup() _contextDefinition = generator.Object.OutputLibrary.TypeProviders.OfType().FirstOrDefault(); var methodInfo = typeof(ModelReaderWriterContextDefinition).GetMethod("BuildAttributes", BindingFlags.Instance | BindingFlags.NonPublic); - _privateDelegate = CreateDelegate>>(methodInfo!); + _privateDelegate = CreateDelegate>>(methodInfo!); } [GlobalCleanup] @@ -48,7 +48,7 @@ public void Cleanup() } [Benchmark] - public IReadOnlyList CollectBuildableTypes() + public IReadOnlyList CollectBuildableTypes() { return _privateDelegate!(_contextDefinition!); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/NativeReductionPlan.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/NativeReductionPlan.md new file mode 100644 index 00000000000..b6de221ad66 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/NativeReductionPlan.md @@ -0,0 +1,136 @@ +# Profiling and selective native name reduction + +> Historical pre-main-merge investigation. Measurements and legacy/shadow phase names below do not describe the current generator. See [README.md](README.md) for the updated workloads and measurement caveats. + +## Status and scope + +This is an investigation plan for benchmark PR #10885, not an approved production implementation or a claim that Rust is faster. + +Keep C# as the generator and orchestrator. Investigate replacing only a measured hotspot with a Rust component, starting with namespace/type-name reduction currently performed by Roslyn. Do not rewrite the generator, provider model, plugin system, or general C# semantic analysis. + +## Current reduction path + +In `src/PostProcessing/GeneratedCodeWorkspace.cs`: + +- `UpdateProject()` annotates the generated syntax root with `Simplifier.Annotation`. +- `ProcessDocument()` obtains the syntax root and semantic model, removes members, applies custom rewriters, and calls `Simplifier.ReduceAsync()` unless `Configuration.DisableRoslynReduce` is set. +- Formatting runs separately when custom rewriters are present. +- This benchmark branch already records `GetSyntaxRootAsync`, `GetSemanticModelAsync`, `Roslyn.Simplifier.ReduceAsync`, custom rewriters, formatting, and total document-processing time. + +**Profiling changes execution on this branch:** enabling `POSTPROCESSING_BENCHMARK_PROFILE_STEPS=true` makes document processing sequential; ordinary execution uses `Task.WhenAll`. Use these profiles for attribution, not as the production-concurrency timing baseline. Step allocation counters are process-wide estimates, and nested step totals must not be added together as independent costs. + +## Questions to answer + +1. How much normal end-to-end generation time is attributable to reduction today? +2. Within reduction, is the cost semantic binding, syntax traversal/rebuilding, text processing, allocation/GC, or something else? +3. Which transformations does Roslyn actually make on representative generated output? Namespace shortening is the initial target, not an assumption that all simplification is namespace trimming. +4. Can generator-owned type identity and scope information replace enough general-purpose semantic work without changing name binding? +5. Does Rust beat an equally specialized C# implementation after conversion, interop, copying, and result integration? + +## Phase 1: establish the baseline + +Use the existing `FullGenerationBenchmark` and saved `Sample-TypeSpec` inputs first. Hold reference-map mode, input files, dependency versions, generator revision, and output cleanup policy constant. Do not mix reference-map changes with reducer measurements. + +Record the commit, input hashes, .NET/runtime version, OS/kernel, CPU, build configuration, benchmark parameters, commands, elapsed time, and artifact locations. + +Collect separately: + +- Uninstrumented BenchmarkDotNet full-generation time and managed allocations. +- Existing sequential step profiles for cost attribution. +- Normal-concurrency generation with reduction enabled versus disabled, using the existing configuration switch. Disabling reduction is an experimental bound, not a proposed production fix; it changes output and does not avoid the earlier semantic-model acquisition. +- Cold CLI startup/generation and warm repeated generation, reported separately. +- Output diffs with reduction enabled/disabled to inventory transformations. + +Existing step-profile invocation, from this directory: + +```sh +POSTPROCESSING_BENCHMARK_PROFILE_STEPS=true \ +POSTPROCESSING_BENCHMARK_PROFILE_DIR=/tmp/typespec-reduction-profiles \ + dotnet run -c Release --framework net10.0 --filter '*FullGenerationBenchmark*' +``` + +Run clean timing benchmarks separately with step profiling disabled. Inspect benchmark setup/cleanup costs before interpreting end-to-end measurements; the harness copies inputs and deletes temporary output on each invocation. + +### Linux perf collection + +Build first. Profile the actual compiled generator process, not an invocation that also builds it. The command below is a template: replace the DLL and arguments with the established generator invocation for the saved fixture. + +```sh +DOTNET_PerfMapEnabled=3 \ + perf record -F 199 -g --call-graph dwarf -o generator.perf.data \ + -- dotnet + +perf report -i generator.perf.data +``` + +Check `perf` availability, kernel permissions/container restrictions, managed method names, and useful stack unwinding before trusting the profile. Perf maps supply managed names; they do not by themselves guarantee complete mixed managed/native stacks. Configure matching .NET symbols and native debug information where needed. Keep Rust release optimizations enabled when collecting native profiles. + +For short-lived runs, gather sufficient samples across repeated equivalent invocations. Keep profiling runs separate from clean timing runs. Use `perf stat` in separate runs for supported counters such as cycles, instructions, and cache misses; interpret them alongside wall time rather than treating them as direct evidence for a rewrite. + +Use .NET tracing when allocation sources or GC require attribution. BenchmarkDotNet managed allocations exclude Rust heap allocations, so a native prototype also needs process-memory/native-allocation measurements. + +References: + +- [.NET perf-map configuration](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/debugging-profiling) +- [.NET Linux perf tracing](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/trace-perfcollect-lttng) + +## Phase 2: define a bounded reducer + +Only proceed if reduction is a material hotspot. Reuse existing fixtures; add focused cases only for behavior not already covered. + +Namespace reduction must preserve C# binding, not merely remove matching text. For example, shortening `global::System.Collections.Generic.List` to `List` is unsafe if the target scope binds `List` differently. Account for custom code, aliases, nested types, generic parameters, member/type collisions, imported namespaces, and rewritten source. + +Evaluate two boundaries: + +1. **C# supplies proven-safe replacement spans; Rust applies edits in batches.** Small semantic risk, but likely limited benefit if Roslyn binding dominates. Compare with applying the same edits in C#. +2. **C# supplies generator-owned type identities and explicit scope/conflict information; a specialized reducer decides which names to shorten.** Greater potential to avoid general Roslyn work, but requires a documented safety policy. A parser alone is not a C# semantic resolver. + +Initially support only explicitly understood generator-owned constructs. Keep fully qualified names for uncertain cases. Use Roslyn fallback for unsupported documents where required, and measure fallback frequency and cost. Do not use regex-only rewriting of arbitrary C# or change custom files. Comments, literals, trivia, directives, and XML documentation must remain untouched unless a separate supported rule explicitly covers them. + +Inspect transformations outside namespace/type-name shortening and explicitly retain or defer them. Any intentionally different generated source must be reviewed as an output-policy change, not silently accepted as an optimization. + +### Native interface candidate + +Prefer one coarse call per document or batch through a Rust `cdylib` with a small C ABI, rather than a process launch or one call per name. + +Specify input/output buffer encoding, span coordinate units (UTF-16 versus UTF-8), ownership/freeing, error propagation, thread safety, and ABI versioning. Rust panics must not unwind across the ABI. Avoid exchanging Roslyn/provider object graphs; their extraction cost can erase the benefit. Measure text encoding, copying, native allocations, and any reparsing needed by the remaining C# pipeline. + +Keep reduction at the current pipeline position after member removal and custom rewriters. Preserve extension behavior. A separate Rust executable is an alternative only if isolation justifies startup and serialization costs. + +## Phase 3: correctness and performance gates + +Compare three implementations on identical inputs and rules: + +| Implementation | Purpose | +| --- | --- | +| Current Roslyn reduction | Correctness and performance baseline | +| Specialized C# reducer | Isolate the benefit of changing the algorithm | +| Specialized Rust reducer | Measure the additional benefit of native implementation | + +Required correctness checks: + +- Existing generated-output fixtures and compilation checks. +- Focused collisions involving custom types, nested types, aliases, generic parameters, imports, and members. +- Global qualification, generic/nullable/array names, and unsupported syntax handled conservatively. +- Custom rewriters and fallback behavior, including already-rewritten source. +- Binding equivalence for supported reductions; compilation alone is insufficient because a different valid symbol can still compile. +- Deterministic output and safe concurrent execution with the normal document-processing path. + +Required performance checks: + +- Normal-concurrency end-to-end time, not just the isolated Rust kernel. +- Separate reducer time, input preparation, FFI/conversion, result integration, and fallback time. +- Managed allocations, native memory, process peak memory, and GC activity. +- Cold and warm results, repeatability, and representative fixture sizes. + +Do not sum concurrent document durations to estimate wall-clock savings. Use the measured reducible fraction to bound potential end-to-end improvement before expanding the experiment. + +After a small prototype is stable, validate only necessary representative SDKs and required affected suites. No full SDK regeneration or broad generator redesign is part of this plan. + +## Decision gate and deliverables + +Produce a reproducible baseline, hotspot profile, transformation inventory, documented semantic boundary, differential correctness tests, and a three-way performance comparison. + +Adopt Rust only if it provides a repeatable, worthwhile end-to-end improvement beyond the specialized C# version, preserves required behavior, and justifies native packaging/maintenance across supported OS and architectures. Define the acceptance threshold before prototype comparisons. If semantic analysis dominates or interop erases the benefit, retain C# and pursue the measured algorithmic optimization instead. + +Native packaging, RID coverage, supported platforms, toolchain pinning, library loading, license/security review, and managed fallback are production prerequisites, not work authorized by this planning document. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs new file mode 100644 index 00000000000..fb4a1ff19b7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.TypeSpec.Generator.Primitives; + +namespace Microsoft.TypeSpec.Generator.Perf +{ + public class PostProcessingBenchmark + { + private const string GeneratedDirectoryEnvironmentVariable = "POSTPROCESSING_BENCHMARK_GENERATED_DIR"; + private const string ProfileEnvironmentVariable = "POSTPROCESSING_BENCHMARK_PROFILE_STEPS"; + private const string ProfileOutputDirectoryEnvironmentVariable = "POSTPROCESSING_BENCHMARK_PROFILE_DIR"; + private static readonly Regex NamespaceDeclarationRegex = new( + @"\bnamespace\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)", + RegexOptions.Compiled); + + [Params(1, 5)] + public int CorpusMultiplier { get; set; } + + [Params(false, true)] + public bool DisableRoslynReduce { get; set; } + + private (string Name, string Content)[] _generatedFiles = []; + private bool _profileSteps; + + [GlobalSetup] + public void GlobalSetup() + { + InitializeGenerator(DisableRoslynReduce); + + var generatedDirectory = FindGeneratedDirectory(); + var sourceFiles = Directory.GetFiles(generatedDirectory, "*.cs", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .ToArray(); + + if (sourceFiles.Length == 0) + { + throw new InvalidOperationException($"No generated C# files found under '{generatedDirectory}'."); + } + + var declaredNamespaces = GetDeclaredNamespaces(sourceFiles); + _generatedFiles = BuildCorpus(generatedDirectory, sourceFiles, declaredNamespaces); + _profileSteps = string.Equals( + Environment.GetEnvironmentVariable(ProfileEnvironmentVariable), + "true", + StringComparison.OrdinalIgnoreCase); + } + + [Benchmark] + public async Task ProcessSampleTypeSpecGeneratedFiles() + { + var profile = _profileSteps ? new GeneratedCodeWorkspacePostProcessingProfile() : null; + GeneratedCodeWorkspace.PostProcessingProfile = profile; + + var stopwatch = Stopwatch.StartNew(); + try + { + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + foreach (var file in _generatedFiles) + { + await workspace.AddGeneratedFile(new CodeFile(file.Content, file.Name)); + } + + var totalLength = 0; + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + totalLength += file.Text.Length; + } + + return totalLength; + } + finally + { + stopwatch.Stop(); + if (profile != null) + { + WriteProfile( + profile, + $"post-processing-profile-reduce-{!DisableRoslynReduce}-x{CorpusMultiplier}-{DateTime.UtcNow:yyyyMMddHHmmssfff}.csv", + $"Roslyn reduction enabled: {!DisableRoslynReduce}{Environment.NewLine}" + + $"Corpus multiplier: {CorpusMultiplier}{Environment.NewLine}" + + $"File count: {_generatedFiles.Length}{Environment.NewLine}" + + $"Benchmark invocation elapsed ms: {stopwatch.Elapsed.TotalMilliseconds:F3}{Environment.NewLine}"); + } + + GeneratedCodeWorkspace.PostProcessingProfile = null; + } + } + + private static void WriteProfile(GeneratedCodeWorkspacePostProcessingProfile profile, string fileName, string header) + { + var profileDirectory = GetProfileOutputDirectory(); + Directory.CreateDirectory(profileDirectory); + File.WriteAllText(Path.Combine(profileDirectory, fileName), header + profile.GetSummary()); + } + + private static string GetProfileOutputDirectory() + { + var configuredPath = Environment.GetEnvironmentVariable(ProfileOutputDirectoryEnvironmentVariable); + return string.IsNullOrWhiteSpace(configuredPath) + ? Path.Combine(Path.GetTempPath(), "typespec-post-processing-profiles") + : Path.GetFullPath(configuredPath); + } + + private (string Name, string Content)[] BuildCorpus(string generatedDirectory, string[] sourceFiles, IReadOnlyList declaredNamespaces) + { + var generatedFiles = new List<(string Name, string Content)>(sourceFiles.Length * CorpusMultiplier); + for (var i = 0; i < CorpusMultiplier; i++) + { + var namespaceSuffix = CorpusMultiplier == 1 ? string.Empty : $".BenchmarkCopy{i}"; + var folderPrefix = CorpusMultiplier == 1 ? string.Empty : $"BenchmarkCopy{i}"; + foreach (var path in sourceFiles) + { + var relativePath = Path.GetRelativePath(generatedDirectory, path); + var content = File.ReadAllText(path); + if (CorpusMultiplier > 1) + { + content = MakeNamespacesUnique(content, declaredNamespaces, namespaceSuffix); + } + + generatedFiles.Add((Path.Combine(folderPrefix, relativePath), content)); + } + } + + return generatedFiles.ToArray(); + } + + private static IReadOnlyList GetDeclaredNamespaces(string[] sourceFiles) + { + var declaredNamespaces = sourceFiles + .SelectMany(static path => NamespaceDeclarationRegex.Matches(File.ReadAllText(path))) + .Select(static match => match.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return declaredNamespaces + .Where(ns => !declaredNamespaces.Any(candidate => + !string.Equals(ns, candidate, StringComparison.Ordinal) && + ns.StartsWith(candidate + ".", StringComparison.Ordinal))) + .OrderByDescending(static ns => ns.Length) + .ToArray(); + } + + private static string MakeNamespacesUnique(string content, IReadOnlyList declaredNamespaces, string namespaceSuffix) + { + foreach (var declaredNamespace in declaredNamespaces) + { + var escapedNamespace = Regex.Escape(declaredNamespace); + content = content.Replace($"global::{declaredNamespace}.", $"global::{declaredNamespace}{namespaceSuffix}.", StringComparison.Ordinal); + content = Regex.Replace( + content, + $@"(? GetMetadataReferencePaths() + { + HashSet referencePaths = new(StringComparer.OrdinalIgnoreCase); + + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string trustedPlatformAssemblies) + { + foreach (var referencePath in trustedPlatformAssemblies.Split(Path.PathSeparator)) + { + if (referencePaths.Add(referencePath)) + { + yield return referencePath; + } + } + } + + foreach (var referencePath in Directory.GetFiles(AppContext.BaseDirectory, "*.dll", SearchOption.TopDirectoryOnly)) + { + if (referencePaths.Add(referencePath)) + { + yield return referencePath; + } + } + } + + private sealed class BenchmarkCodeModelGenerator : CodeModelGenerator + { + public BenchmarkCodeModelGenerator(string outputPath, bool disableRoslynReduce) + : base(new GeneratorContext(Configuration.Load(outputPath, + $"{{\"package-name\":\"Sample.TypeSpec\",\"disable-xml-docs\":false,\"disable-roslyn-reduce\":{disableRoslynReduce.ToString().ToLowerInvariant()}}}"))) + { + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Program.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Program.cs index f94a18d4b02..d3bbefb4d9d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Program.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Program.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; +using System.Linq; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; using BenchmarkDotNet.Jobs; @@ -13,29 +15,25 @@ public class Program { public static void Main(string[] args) { - // To see the list of benchmarks that can be run - // dotnet run -c Release --framework net8.0 --list flat - - // To run a specific benchmark class - // dotnet run -c Release --framework net8.0 --filter System.ClientModel.Tests.Internal.Perf.ResourceProviderDataModel* - - // To run a specific benchmark method - // dotnet run -c Release --framework net8.0 --filter *ResourceProviderDataModel.Read_PublicInterface - // or - // dotnet run -c Release --framework net8.0 --filter System.ClientModel.Tests.Internal.Perf.ResourceProviderDataModel.Read_PublicInterface - - // To run a specific benchmark class and category - // dotnet run -c Release --framework net8.0 --anyCategories PublicInterface --filter System.ClientModel.Tests.Internal.Perf.ResourceProviderDataModel* + // List workloads: dotnet run -c Release -- --list flat + // Run a class: dotnet run -c Release -- --filter '*FullGenerationBenchmark*' + // Smoke test: dotnet run -c Release -- --filter '*PostProcessingBenchmark*' --job Dry + // See README.md for workload boundaries and optional step profiling. var config = ManualConfig.Create(DefaultConfig.Instance); config.Options = ConfigOptions.JoinSummary | ConfigOptions.StopOnFirstError; config = config.AddDiagnoser(MemoryDiagnoser.Default); - config.AddJob(Job.Default - .WithWarmupCount(1) - .WithIterationTime(TimeInterval.FromMilliseconds(250)) - .WithMinIterationCount(15) - .WithMaxIterationCount(20) - .AsDefault()); + // In this BDN version a custom default job overrides --job Dry/Short. + // Let explicitly selected CLI jobs use their own iteration settings. + if (!args.Any(static arg => arg is "--job" or "-j" || arg.StartsWith("--job=", StringComparison.Ordinal))) + { + config.AddJob(Job.Default + .WithWarmupCount(1) + .WithIterationTime(TimeInterval.FromMilliseconds(250)) + .WithMinIterationCount(15) + .WithMaxIterationCount(20) + .AsDefault()); + } BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Projects/Model/tspCodeModel.json b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Projects/Model/tspCodeModel.json index 753baa601d9..5040b6aff92 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Projects/Model/tspCodeModel.json +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/Projects/Model/tspCodeModel.json @@ -1,2852 +1,8 @@ { - "$id": "1", - "Name": "SampleTypeSpec", - "ApiVersions": [], - "Enums": [ - { - "$id": "2", - "Kind": "enum", - "Name": "Thing_requiredLiteralString", - "ValueType": { - "$id": "3", - "Kind": "string", - "IsNullable": false - }, - "Values": [ - { - "$id": "4", - "Name": "accept", - "Value": "accept", - "Description": "accept" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_requiredLiteralString", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "5", - "Kind": "enum", - "Name": "Thing_requiredLiteralInt", - "ValueType": { - "$id": "6", - "Kind": "int32", - "IsNullable": false - }, - "Values": [ - { - "$id": "7", - "Name": "123", - "Value": 123, - "Description": "123" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_requiredLiteralInt", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "8", - "Kind": "enum", - "Name": "Thing_requiredLiteralFloat", - "ValueType": { - "$id": "9", - "Kind": "float32", - "IsNullable": false - }, - "Values": [ - { - "$id": "10", - "Name": "1.23", - "Value": 1.23, - "Description": "1.23" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_requiredLiteralFloat", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "11", - "Kind": "enum", - "Name": "Thing_optionalLiteralString", - "ValueType": { - "$id": "12", - "Kind": "string", - "IsNullable": false - }, - "Values": [ - { - "$id": "13", - "Name": "reject", - "Value": "reject", - "Description": "reject" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_optionalLiteralString", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "14", - "Kind": "enum", - "Name": "Thing_optionalLiteralInt", - "ValueType": { - "$id": "15", - "Kind": "int32", - "IsNullable": false - }, - "Values": [ - { - "$id": "16", - "Name": "456", - "Value": 456, - "Description": "456" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_optionalLiteralInt", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "17", - "Kind": "enum", - "Name": "Thing_optionalLiteralFloat", - "ValueType": { - "$id": "18", - "Kind": "float32", - "IsNullable": false - }, - "Values": [ - { - "$id": "19", - "Name": "4.56", - "Value": 4.56, - "Description": "4.56" - } - ], - "Namespace": "SampleTypeSpec", - "Description": "The Thing_optionalLiteralFloat", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "20", - "Kind": "enum", - "Name": "StringFixedEnum", - "ValueType": { - "$id": "21", - "Kind": "string", - "IsNullable": false - }, - "Values": [ - { - "$id": "22", - "Name": "One", - "Value": "1" - }, - { - "$id": "23", - "Name": "Two", - "Value": "2" - }, - { - "$id": "24", - "Name": "Four", - "Value": "4" - } - ], - "Namespace": "", - "Description": "Simple enum", - "IsExtensible": false, - "IsNullable": true, - "Usage": "RoundTrip" - }, - { - "$id": "25", - "Kind": "enum", - "Name": "StringExtensibleEnum", - "ValueType": { - "$id": "26", - "Kind": "string", - "IsNullable": false - }, - "Values": [ - { - "$id": "27", - "Name": "One", - "Value": "1" - }, - { - "$id": "28", - "Name": "Two", - "Value": "2" - }, - { - "$id": "29", - "Name": "Four", - "Value": "4" - } - ], - "Namespace": "", - "Description": "Extensible enum", - "IsExtensible": true, - "IsNullable": true, - "Usage": "RoundTrip" - }, - { - "$id": "30", - "Kind": "enum", - "Name": "IntExtensibleEnum", - "ValueType": { - "$id": "31", - "Kind": "int32", - "IsNullable": false - }, - "Values": [ - { - "$id": "32", - "Name": "One", - "Value": 1 - }, - { - "$id": "33", - "Name": "Two", - "Value": 2 - }, - { - "$id": "34", - "Name": "Four", - "Value": 4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "Int based extensible enum", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "35", - "Kind": "enum", - "Name": "FloatExtensibleEnum", - "ValueType": { - "$id": "36", - "Kind": "float32", - "IsNullable": false - }, - "Values": [ - { - "$id": "37", - "Name": "OneDotOne", - "Value": 1.1 - }, - { - "$id": "38", - "Name": "TwoDotTwo", - "Value": 2.2 - }, - { - "$id": "39", - "Name": "FourDotFour", - "Value": 4.4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "Float based extensible enum", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "40", - "Kind": "enum", - "Name": "FloatExtensibleEnumWithIntValue", - "ValueType": { - "$id": "41", - "Kind": "float32", - "IsNullable": false - }, - "Values": [ - { - "$id": "42", - "Name": "One", - "Value": 1 - }, - { - "$id": "43", - "Name": "Two", - "Value": 2 - }, - { - "$id": "44", - "Name": "Four", - "Value": 4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "float fixed enum", - "IsExtensible": true, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "45", - "Kind": "enum", - "Name": "FloatFixedEnum", - "ValueType": { - "$id": "46", - "Kind": "float32", - "IsNullable": false - }, - "Values": [ - { - "$id": "47", - "Name": "OneDotOne", - "Value": 1.1 - }, - { - "$id": "48", - "Name": "TwoDotTwo", - "Value": 2.2 - }, - { - "$id": "49", - "Name": "FourDotFour", - "Value": 4.4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "float fixed enum", - "IsExtensible": false, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "50", - "Kind": "enum", - "Name": "FloatFixedEnumWithIntValue", - "ValueType": { - "$id": "51", - "Kind": "int32", - "IsNullable": false - }, - "Values": [ - { - "$id": "52", - "Name": "One", - "Value": 1 - }, - { - "$id": "53", - "Name": "Two", - "Value": 2 - }, - { - "$id": "54", - "Name": "Four", - "Value": 4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "float fixed enum", - "IsExtensible": false, - "IsNullable": false, - "Usage": "RoundTrip" - }, - { - "$id": "55", - "Kind": "enum", - "Name": "IntFixedEnum", - "ValueType": { - "$id": "56", - "Kind": "int32", - "IsNullable": false - }, - "Values": [ - { - "$id": "57", - "Name": "One", - "Value": 1 - }, - { - "$id": "58", - "Name": "Two", - "Value": 2 - }, - { - "$id": "59", - "Name": "Four", - "Value": 4 - } - ], - "Namespace": "SampleTypeSpec", - "Description": "int fixed enum", - "IsExtensible": false, - "IsNullable": false, - "Usage": "RoundTrip" - } - ], - "Models": [ - { - "$id": "60", - "Kind": "Model", - "Name": "Thing", - "Namespace": "SampleTypeSpec", - "Description": "A model with a few properties of literal types", - "IsNullable": false, - "Usage": "RoundTrip", - "Properties": [ - { - "$id": "61", - "Name": "name", - "SerializedName": "name", - "Description": "name of the Thing", - "Type": { - "$id": "62", - "Kind": "string", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "63", - "Name": "requiredUnion", - "SerializedName": "requiredUnion", - "Description": "required Union", - "Type": { - "$id": "64", - "Kind": "Union", - "Name": "Union", - "UnionItemTypes": [ - { - "$id": "65", - "Kind": "string", - "IsNullable": false - }, - { - "$id": "66", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$id": "67", - "Kind": "string", - "IsNullable": false - }, - "IsNullable": false - }, - { - "$id": "68", - "Kind": "int32", - "IsNullable": false - } - ], - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "69", - "Name": "requiredLiteralString", - "SerializedName": "requiredLiteralString", - "Description": "required literal string", - "Type": { - "$id": "70", - "Kind": "constant", - "ValueType": { - "$ref": "2" - }, - "Value": "accept", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "71", - "Name": "requiredLiteralInt", - "SerializedName": "requiredLiteralInt", - "Description": "required literal int", - "Type": { - "$id": "72", - "Kind": "constant", - "ValueType": { - "$ref": "5" - }, - "Value": 123, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "73", - "Name": "requiredLiteralFloat", - "SerializedName": "requiredLiteralFloat", - "Description": "required literal float", - "Type": { - "$id": "74", - "Kind": "constant", - "ValueType": { - "$ref": "8" - }, - "Value": 1.23, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "75", - "Name": "requiredLiteralBool", - "SerializedName": "requiredLiteralBool", - "Description": "required literal bool", - "Type": { - "$id": "76", - "Kind": "constant", - "ValueType": { - "$id": "77", - "Kind": "boolean", - "IsNullable": false - }, - "Value": false, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "78", - "Name": "optionalLiteralString", - "SerializedName": "optionalLiteralString", - "Description": "optional literal string", - "Type": { - "$id": "79", - "Kind": "constant", - "ValueType": { - "$ref": "11" - }, - "Value": "reject", - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "80", - "Name": "optionalLiteralInt", - "SerializedName": "optionalLiteralInt", - "Description": "optional literal int", - "Type": { - "$id": "81", - "Kind": "constant", - "ValueType": { - "$ref": "14" - }, - "Value": 456, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "82", - "Name": "optionalLiteralFloat", - "SerializedName": "optionalLiteralFloat", - "Description": "optional literal float", - "Type": { - "$id": "83", - "Kind": "constant", - "ValueType": { - "$ref": "17" - }, - "Value": 4.56, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "84", - "Name": "optionalLiteralBool", - "SerializedName": "optionalLiteralBool", - "Description": "optional literal bool", - "Type": { - "$id": "85", - "Kind": "constant", - "ValueType": { - "$id": "86", - "Kind": "boolean", - "IsNullable": false - }, - "Value": true, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "87", - "Name": "requiredBadDescription", - "SerializedName": "requiredBadDescription", - "Description": "description with xml <|endoftext|>", - "Type": { - "$id": "88", - "Kind": "string", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "89", - "Name": "optionalNullableList", - "SerializedName": "optionalNullableList", - "Description": "optional nullable collection", - "Type": { - "$id": "90", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$id": "91", - "Kind": "int32", - "IsNullable": false - }, - "IsNullable": true - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "92", - "Name": "requiredNullableList", - "SerializedName": "requiredNullableList", - "Description": "required nullable collection", - "Type": { - "$id": "93", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$id": "94", - "Kind": "int32", - "IsNullable": false - }, - "IsNullable": true - }, - "IsRequired": true, - "IsReadOnly": false - } - ] - }, - { - "$id": "95", - "Kind": "Model", - "Name": "RoundTripModel", - "Namespace": "SampleTypeSpec", - "Description": "this is a roundtrip model", - "IsNullable": false, - "Usage": "RoundTrip", - "Properties": [ - { - "$id": "96", - "Name": "requiredString", - "SerializedName": "requiredString", - "Description": "Required string, illustrating a reference type property.", - "Type": { - "$id": "97", - "Kind": "string", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "98", - "Name": "requiredInt", - "SerializedName": "requiredInt", - "Description": "Required int, illustrating a value type property.", - "Type": { - "$id": "99", - "Kind": "int32", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "100", - "Name": "requiredCollection", - "SerializedName": "requiredCollection", - "Description": "Required collection of enums", - "Type": { - "$id": "101", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$ref": "20" - }, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "102", - "Name": "requiredDictionary", - "SerializedName": "requiredDictionary", - "Description": "Required dictionary of enums", - "Type": { - "$id": "103", - "Kind": "Dictionary", - "Name": "Dictionary", - "KeyType": { - "$id": "104", - "Kind": "string", - "IsNullable": false - }, - "ValueType": { - "$ref": "25" - }, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "105", - "Name": "requiredModel", - "SerializedName": "requiredModel", - "Description": "Required model", - "Type": { - "$ref": "60" - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "106", - "Name": "intExtensibleEnum", - "SerializedName": "intExtensibleEnum", - "Description": "this is an int based extensible enum", - "Type": { - "$ref": "30" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "107", - "Name": "intExtensibleEnumCollection", - "SerializedName": "intExtensibleEnumCollection", - "Description": "this is a collection of int based extensible enum", - "Type": { - "$id": "108", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$ref": "30" - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "109", - "Name": "floatExtensibleEnum", - "SerializedName": "floatExtensibleEnum", - "Description": "this is a float based extensible enum", - "Type": { - "$ref": "35" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "110", - "Name": "floatExtensibleEnumWithIntValue", - "SerializedName": "floatExtensibleEnumWithIntValue", - "Description": "this is a float based extensible enum", - "Type": { - "$ref": "40" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "111", - "Name": "floatExtensibleEnumCollection", - "SerializedName": "floatExtensibleEnumCollection", - "Description": "this is a collection of float based extensible enum", - "Type": { - "$id": "112", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$ref": "35" - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "113", - "Name": "floatFixedEnum", - "SerializedName": "floatFixedEnum", - "Description": "this is a float based fixed enum", - "Type": { - "$ref": "45" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "114", - "Name": "floatFixedEnumWithIntValue", - "SerializedName": "floatFixedEnumWithIntValue", - "Description": "this is a float based fixed enum", - "Type": { - "$ref": "50" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "115", - "Name": "floatFixedEnumCollection", - "SerializedName": "floatFixedEnumCollection", - "Description": "this is a collection of float based fixed enum", - "Type": { - "$id": "116", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$ref": "45" - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "117", - "Name": "intFixedEnum", - "SerializedName": "intFixedEnum", - "Description": "this is a int based fixed enum", - "Type": { - "$ref": "55" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "118", - "Name": "intFixedEnumCollection", - "SerializedName": "intFixedEnumCollection", - "Description": "this is a collection of int based fixed enum", - "Type": { - "$id": "119", - "Kind": "Array", - "Name": "Array", - "ElementType": { - "$ref": "55" - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "120", - "Name": "stringFixedEnum", - "SerializedName": "stringFixedEnum", - "Description": "this is a string based fixed enum", - "Type": { - "$ref": "20" - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "121", - "Name": "requiredUnknown", - "SerializedName": "requiredUnknown", - "Description": "required unknown", - "Type": { - "$id": "122", - "Kind": "any", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "123", - "Name": "optionalUnknown", - "SerializedName": "optionalUnknown", - "Description": "optional unknown", - "Type": { - "$id": "124", - "Kind": "any", - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "125", - "Name": "requiredRecordUnknown", - "SerializedName": "requiredRecordUnknown", - "Description": "required record of unknown", - "Type": { - "$id": "126", - "Kind": "Dictionary", - "Name": "Dictionary", - "KeyType": { - "$id": "127", - "Kind": "string", - "IsNullable": false - }, - "ValueType": { - "$id": "128", - "Kind": "any", - "IsNullable": false - }, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "129", - "Name": "optionalRecordUnknown", - "SerializedName": "optionalRecordUnknown", - "Description": "optional record of unknown", - "Type": { - "$id": "130", - "Kind": "Dictionary", - "Name": "Dictionary", - "KeyType": { - "$id": "131", - "Kind": "string", - "IsNullable": false - }, - "ValueType": { - "$id": "132", - "Kind": "any", - "IsNullable": false - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": false - }, - { - "$id": "133", - "Name": "readOnlyRequiredRecordUnknown", - "SerializedName": "readOnlyRequiredRecordUnknown", - "Description": "required readonly record of unknown", - "Type": { - "$id": "134", - "Kind": "Dictionary", - "Name": "Dictionary", - "KeyType": { - "$id": "135", - "Kind": "string", - "IsNullable": false - }, - "ValueType": { - "$id": "136", - "Kind": "any", - "IsNullable": false - }, - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": true - }, - { - "$id": "137", - "Name": "readOnlyOptionalRecordUnknown", - "SerializedName": "readOnlyOptionalRecordUnknown", - "Description": "optional readonly record of unknown", - "Type": { - "$id": "138", - "Kind": "Dictionary", - "Name": "Dictionary", - "KeyType": { - "$id": "139", - "Kind": "string", - "IsNullable": false - }, - "ValueType": { - "$id": "140", - "Kind": "any", - "IsNullable": false - }, - "IsNullable": false - }, - "IsRequired": false, - "IsReadOnly": true - }, - { - "$id": "141", - "Name": "modelWithRequiredNullable", - "SerializedName": "modelWithRequiredNullable", - "Description": "this is a model with required nullable properties", - "Type": { - "$id": "142", - "Kind": "Model", - "Name": "ModelWithRequiredNullableProperties", - "Namespace": "SampleTypeSpec", - "Description": "A model with a few required nullable properties", - "IsNullable": false, - "Usage": "RoundTrip", - "Properties": [ - { - "$id": "143", - "Name": "requiredNullablePrimitive", - "SerializedName": "requiredNullablePrimitive", - "Description": "required nullable primitive type", - "Type": { - "$id": "144", - "Kind": "int32", - "IsNullable": true - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "145", - "Name": "requiredExtensibleEnum", - "SerializedName": "requiredExtensibleEnum", - "Description": "required nullable extensible enum type", - "Type": { - "$ref": "25" - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "146", - "Name": "requiredFixedEnum", - "SerializedName": "requiredFixedEnum", - "Description": "required nullable fixed enum type", - "Type": { - "$ref": "20" - }, - "IsRequired": true, - "IsReadOnly": false - } - ] - }, - "IsRequired": true, - "IsReadOnly": false - }, - { - "$id": "147", - "Name": "requiredBytes", - "SerializedName": "requiredBytes", - "Description": "Required bytes", - "Type": { - "$id": "148", - "Kind": "bytes", - "IsNullable": false, - "Encode": "base64" - }, - "IsRequired": true, - "IsReadOnly": false - } - ] - }, - { - "$ref": "142" - }, - { - "$id": "149", - "Kind": "Model", - "Name": "Friend", - "Namespace": "SampleTypeSpec", - "Description": "this is not a friendly model but with a friendly name", - "IsNullable": false, - "Usage": "RoundTrip", - "Properties": [ - { - "$id": "150", - "Name": "name", - "SerializedName": "name", - "Description": "name of the NotFriend", - "Type": { - "$id": "151", - "Kind": "string", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - } - ] - }, - { - "$id": "152", - "Kind": "Model", - "Name": "ProjectedModel", - "Namespace": "SampleTypeSpec", - "Description": "this is a model with a projected name", - "IsNullable": false, - "Usage": "RoundTrip", - "Properties": [ - { - "$id": "153", - "Name": "name", - "SerializedName": "name", - "Description": "name of the ModelWithProjectedName", - "Type": { - "$id": "154", - "Kind": "string", - "IsNullable": false - }, - "IsRequired": true, - "IsReadOnly": false - } - ] - }, - { - "$id": "155", - "Kind": "Model", - "Name": "ReturnsAnonymousModelResponse", - "Namespace": "SampleTypeSpec", - "IsNullable": false, - "Usage": "Output", - "Properties": [] - } - ], - "Clients": [ - { - "$id": "156", - "Name": "UnbrandedTypeSpecClient", - "Description": "This is a sample typespec project.", - "Operations": [ - { - "$id": "157", - "Name": "sayHi", - "ResourceName": "SampleTypeSpec", - "Description": "Return hi", - "Parameters": [ - { - "$id": "158", - "Name": "unbrandedTypeSpecUrl", - "NameInRequest": "unbrandedTypeSpecUrl", - "Type": { - "$id": "159", - "Kind": "uri", - "IsNullable": false - }, - "Location": "Uri", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": true, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Client" - }, - { - "$id": "160", - "Name": "headParameter", - "NameInRequest": "head-parameter", - "Type": { - "$id": "161", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "162", - "Name": "queryParameter", - "NameInRequest": "queryParameter", - "Type": { - "$id": "163", - "Kind": "string", - "IsNullable": false - }, - "Location": "Query", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "164", - "Name": "optionalQuery", - "NameInRequest": "optionalQuery", - "Type": { - "$id": "165", - "Kind": "string", - "IsNullable": false - }, - "Location": "Query", - "IsRequired": false, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "166", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "167", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "168", - "Type": { - "$ref": "167" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "169", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/hello", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "170", - "Name": "helloAgain", - "ResourceName": "SampleTypeSpec", - "Description": "Return hi again", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "171", - "Name": "p1", - "NameInRequest": "p1", - "Type": { - "$id": "172", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "173", - "Name": "contentType", - "NameInRequest": "content-type", - "Type": { - "$id": "174", - "Kind": "constant", - "ValueType": { - "$id": "175", - "Kind": "string", - "IsNullable": false - }, - "Value": "text/plain", - "IsNullable": false - }, - "Location": "Header", - "DefaultValue": { - "$id": "176", - "Type": { - "$ref": "174" - }, - "Value": "text/plain" - }, - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant" - }, - { - "$id": "177", - "Name": "p2", - "NameInRequest": "p2", - "Type": { - "$id": "178", - "Kind": "string", - "IsNullable": false - }, - "Location": "Path", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "179", - "Name": "action", - "NameInRequest": "action", - "Type": { - "$ref": "95" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "180", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "181", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "182", - "Type": { - "$ref": "181" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "183", - "StatusCodes": [200], - "BodyType": { - "$ref": "95" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/againHi/{p2}", - "RequestMediaTypes": ["text/plain"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "184", - "Name": "noContentType", - "ResourceName": "SampleTypeSpec", - "Description": "Return hi again", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "185", - "Name": "p1", - "NameInRequest": "p1", - "Type": { - "$id": "186", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "187", - "Name": "p2", - "NameInRequest": "p2", - "Type": { - "$id": "188", - "Kind": "string", - "IsNullable": false - }, - "Location": "Path", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "189", - "Name": "action", - "NameInRequest": "action", - "Type": { - "$ref": "95" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "190", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "191", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "192", - "Type": { - "$ref": "191" - }, - "Value": "application/json" - } - }, - { - "$id": "193", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "194", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "195", - "Type": { - "$ref": "194" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "196", - "StatusCodes": [200], - "BodyType": { - "$ref": "95" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/noContentType/{p2}", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": false - }, - { - "$id": "197", - "Name": "helloDemo2", - "ResourceName": "SampleTypeSpec", - "Description": "Return hi in demo2", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "198", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "199", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "200", - "Type": { - "$ref": "199" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "201", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/demoHi", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "202", - "Name": "createLiteral", - "ResourceName": "SampleTypeSpec", - "Description": "Create with literal value", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "203", - "Name": "body", - "NameInRequest": "body", - "Type": { - "$ref": "60" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "204", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "205", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "206", - "Type": { - "$ref": "205" - }, - "Value": "application/json" - } - }, - { - "$id": "207", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "208", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "209", - "Type": { - "$ref": "208" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "210", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/literal", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "211", - "Name": "helloLiteral", - "ResourceName": "SampleTypeSpec", - "Description": "Send literal parameters", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "212", - "Name": "p1", - "NameInRequest": "p1", - "Type": { - "$id": "213", - "Kind": "constant", - "ValueType": { - "$id": "214", - "Kind": "string", - "IsNullable": false - }, - "Value": "test", - "IsNullable": false - }, - "Location": "Header", - "DefaultValue": { - "$id": "215", - "Type": { - "$ref": "213" - }, - "Value": "test" - }, - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant" - }, - { - "$id": "216", - "Name": "p2", - "NameInRequest": "p2", - "Type": { - "$id": "217", - "Kind": "constant", - "ValueType": { - "$id": "218", - "Kind": "int32", - "IsNullable": false - }, - "Value": 123, - "IsNullable": false - }, - "Location": "Path", - "DefaultValue": { - "$id": "219", - "Type": { - "$ref": "217" - }, - "Value": 123 - }, - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant" - }, - { - "$id": "220", - "Name": "p3", - "NameInRequest": "p3", - "Type": { - "$id": "221", - "Kind": "constant", - "ValueType": { - "$id": "222", - "Kind": "boolean", - "IsNullable": false - }, - "Value": true, - "IsNullable": false - }, - "Location": "Query", - "DefaultValue": { - "$id": "223", - "Type": { - "$ref": "221" - }, - "Value": true - }, - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant" - }, - { - "$id": "224", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "225", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "226", - "Type": { - "$ref": "225" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "227", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/helloLiteral/{p2}", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "228", - "Name": "topAction", - "ResourceName": "SampleTypeSpec", - "Description": "top level method", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "229", - "Name": "action", - "NameInRequest": "action", - "Type": { - "$id": "230", - "Kind": "utcDateTime", - "IsNullable": false, - "Encode": "rfc3339", - "WireType": { - "$id": "231", - "Kind": "string", - "IsNullable": false - } - }, - "Location": "Path", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "232", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "233", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "234", - "Type": { - "$ref": "233" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "235", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/top/{action}", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "236", - "Name": "topAction2", - "ResourceName": "SampleTypeSpec", - "Description": "top level method2", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "237", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "238", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "239", - "Type": { - "$ref": "238" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "240", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/top2", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": false - }, - { - "$id": "241", - "Name": "patchAction", - "ResourceName": "SampleTypeSpec", - "Description": "top level patch", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "242", - "Name": "body", - "NameInRequest": "body", - "Type": { - "$ref": "60" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "243", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "244", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "245", - "Type": { - "$ref": "244" - }, - "Value": "application/json" - } - }, - { - "$id": "246", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "247", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "248", - "Type": { - "$ref": "247" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "249", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "PATCH", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/patch", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": false - }, - { - "$id": "250", - "Name": "anonymousBody", - "ResourceName": "SampleTypeSpec", - "Description": "body parameter without body decorator", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "251", - "Name": "Thing", - "NameInRequest": "Thing", - "Description": "A model with a few properties of literal types", - "Type": { - "$ref": "60" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "252", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "253", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "254", - "Type": { - "$ref": "253" - }, - "Value": "application/json" - } - }, - { - "$id": "255", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "256", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "257", - "Type": { - "$ref": "256" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "258", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/anonymousBody", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "259", - "Name": "friendlyModel", - "ResourceName": "SampleTypeSpec", - "Description": "Model can have its friendly name", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "260", - "Name": "Friend", - "NameInRequest": "NotFriend", - "Description": "this is not a friendly model but with a friendly name", - "Type": { - "$ref": "149" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "261", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "262", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "263", - "Type": { - "$ref": "262" - }, - "Value": "application/json" - } - }, - { - "$id": "264", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "265", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "266", - "Type": { - "$ref": "265" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "267", - "StatusCodes": [200], - "BodyType": { - "$ref": "149" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/friendlyName", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "268", - "Name": "addTimeHeader", - "ResourceName": "SampleTypeSpec", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "269", - "Name": "repeatabilityFirstSent", - "NameInRequest": "Repeatability-First-Sent", - "Type": { - "$id": "270", - "Kind": "utcDateTime", - "IsNullable": false, - "Encode": "rfc7231", - "WireType": { - "$id": "271", - "Kind": "string", - "IsNullable": false - } - }, - "Location": "Header", - "IsRequired": false, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "272", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "273", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "274", - "Type": { - "$ref": "273" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "275", - "StatusCodes": [204], - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "276", - "Name": "projectedNameModel", - "ResourceName": "SampleTypeSpec", - "Description": "Model can have its projected name", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "277", - "Name": "ProjectedModel", - "NameInRequest": "ModelWithProjectedName", - "Description": "this is a model with a projected name", - "Type": { - "$ref": "152" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "278", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "279", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "280", - "Type": { - "$ref": "279" - }, - "Value": "application/json" - } - }, - { - "$id": "281", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "282", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "283", - "Type": { - "$ref": "282" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "284", - "StatusCodes": [200], - "BodyType": { - "$ref": "152" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/projectedName", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "285", - "Name": "returnsAnonymousModel", - "ResourceName": "SampleTypeSpec", - "Description": "return anonymous model", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "286", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "287", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "288", - "Type": { - "$ref": "287" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "289", - "StatusCodes": [200], - "BodyType": { - "$ref": "155" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/returnsAnonymousModel", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "290", - "Name": "getUnknownValue", - "ResourceName": "SampleTypeSpec", - "Description": "get extensible enum", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "291", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "292", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "293", - "Type": { - "$ref": "292" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "294", - "StatusCodes": [200], - "BodyType": { - "$id": "295", - "Kind": "string", - "IsNullable": false - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/unknown-value", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - }, - { - "$id": "296", - "Name": "internalProtocol", - "ResourceName": "SampleTypeSpec", - "Description": "When set protocol false and convenient true, then the protocol method should be internal", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "297", - "Name": "body", - "NameInRequest": "body", - "Type": { - "$ref": "60" - }, - "Location": "Body", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "298", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "299", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "300", - "Type": { - "$ref": "299" - }, - "Value": "application/json" - } - }, - { - "$id": "301", - "Name": "contentType", - "NameInRequest": "Content-Type", - "Type": { - "$id": "302", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": true, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "303", - "Type": { - "$ref": "302" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "304", - "StatusCodes": [200], - "BodyType": { - "$ref": "60" - }, - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false, - "ContentTypes": ["application/json"] - } - ], - "HttpMethod": "POST", - "RequestBodyMediaType": "Json", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/internalProtocol", - "RequestMediaTypes": ["application/json"], - "BufferResponse": true, - "GenerateProtocolMethod": false, - "GenerateConvenienceMethod": true - }, - { - "$id": "305", - "Name": "stillConvenient", - "ResourceName": "SampleTypeSpec", - "Description": "When set protocol false and convenient true, the convenient method should be generated even it has the same signature as protocol one", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "306", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "307", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "308", - "Type": { - "$ref": "307" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "309", - "StatusCodes": [204], - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false - } - ], - "HttpMethod": "GET", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/stillConvenient", - "BufferResponse": true, - "GenerateProtocolMethod": false, - "GenerateConvenienceMethod": true - }, - { - "$id": "310", - "Name": "headAsBoolean", - "ResourceName": "SampleTypeSpec", - "Description": "head as boolean.", - "Parameters": [ - { - "$ref": "158" - }, - { - "$id": "311", - "Name": "id", - "NameInRequest": "id", - "Type": { - "$id": "312", - "Kind": "string", - "IsNullable": false - }, - "Location": "Path", - "IsRequired": true, - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Method" - }, - { - "$id": "313", - "Name": "accept", - "NameInRequest": "Accept", - "Type": { - "$id": "314", - "Kind": "string", - "IsNullable": false - }, - "Location": "Header", - "IsApiVersion": false, - "IsResourceParameter": false, - "IsContentType": false, - "IsRequired": true, - "IsEndpoint": false, - "SkipUrlEncoding": false, - "Explode": false, - "Kind": "Constant", - "DefaultValue": { - "$id": "315", - "Type": { - "$ref": "314" - }, - "Value": "application/json" - } - } - ], - "Responses": [ - { - "$id": "316", - "StatusCodes": [204], - "BodyMediaType": "Json", - "Headers": [], - "IsErrorResponse": false - } - ], - "HttpMethod": "HEAD", - "RequestBodyMediaType": "None", - "Uri": "{unbrandedTypeSpecUrl}", - "Path": "/headAsBoolean/{id}", - "BufferResponse": true, - "GenerateProtocolMethod": true, - "GenerateConvenienceMethod": true - } - ], - "Protocol": { - "$id": "317" - }, - "Creatable": true, - "Parameters": [ - { - "$ref": "158" - } - ] - } - ], - "Auth": { - "$id": "318", - "ApiKey": { - "$id": "319", - "Name": "my-api-key" - } - } + "name": "SampleTypeSpec", + "apiVersions": [], + "constants": [], + "enums": [], + "models": [], + "clients": [] } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerPrototype.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerPrototype.md new file mode 100644 index 00000000000..94d9fa16204 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerPrototype.md @@ -0,0 +1,137 @@ +# Provider-aware name reducer prototype + +> Historical combined-provider prototype. See [ProviderReducerStatus.md](ProviderReducerStatus.md) +> for the current structural checkpoint, applied rules, AppService gap and validation. + +This is an **opt-in, incomplete proof of concept**, not a production replacement +for Roslyn's complete simplifier. Roslyn remains the default reduction pipeline. +The optional-default emission fix below applies in both modes. + +## Enable + +Set `TYPESPEC_PROVIDER_REDUCER=true` before constructing writers/workspaces. +Keep `disable-roslyn-reduce=false` to exercise reduction. Unset the environment +variable to use the original Roslyn simplifier pipeline. + +The writer records qualified type-reference spans and identities, including named +array elements. The reducer validates those records and uses shared compilation +symbols plus conservative document-wide scope checks to shorten supported names. +Decisions are cached per immutable document scope and named type definition; +sorted, disjoint namespace-prefix deletions are applied in one string pass. +The normal provider path avoids annotations, AST replacement and per-document +semantic models. If another rewrite changes the root, original writer offsets +are not applied. Uncertain names or +documents keep their original qualification; **there is no Roslyn simplifier +fallback in provider mode**. Custom rewriters retain their semantic-model contract. +The shared generated-source compilation has not been eliminated. + +## Optional-default emission + +The original PoC failed real generated-library compilation because optional +`CancellationToken` defaults retained the writer's expression: + +```csharp +CancellationToken cancellationToken = ((global::System.Threading.CancellationToken)default) +``` + +The compiler rejects this with CS1736. Source comparison found 166 instances +across copied internal helpers. The writer now emits `default(T)` directly for +an optional parameter whose default is a cast of bare `default` to exactly its +declared type. Casts elsewhere, mismatched cast types and other defaults remain +unchanged. Raw-compilation regressions reproduce and fix the original struct +failures without a simplifier fallback. + +Assignment statements also omit the outer parentheses of a binary assignment; +transparent scoped wrappers are inspected only at that statement boundary. +Nested expressions keep their original grouping. This repairs CS0201 from the +actual `AddAndAssign` helper without broad expression/cast simplification. + +## Other incomplete simplifications + +The prototype is deliberately more conservative about qualified names, nested +and inherited types, casts, static receivers, attributes, XML cref and unsupported +syntax/layouts. It also retains explicit `this.`, generic invocation arguments, +framework names instead of keyword aliases, attribute suffixes, parentheses and +pattern grouping that Roslyn may simplify. These differences require separate +acceptance/semantic review; textual parity is not established. + +## Validation of the original PoC (7b4113e4f) + +- 195 targeted core/writer/binding/integration tests passed. +- 2,983 full core-generator tests passed. Most existing tests use default mode; + dedicated provider tests explicitly exercise the opt-in path. +- Binding oracles compiled both trees and reparsed emitted text and compared type + bindings, including array composition/collisions and inherited-scope guards. +- Official real test-project regeneration in Roslyn mode succeeded; baseline + Spector tests: 956 passed, 10 skipped. +- Provider regeneration failed Sample compilation with CS1736; finishing Spector + generation likewise produced 158 reported CS1736 diagnostics before tests ran. + The aggregate Spector project does not reference every generated library. +- Real-output comparison: 3,007 changed C# files, no added/removed files. A + diagnostic syntax taxonomy accounted for the differences, but does not prove + binding/runtime equivalence. + +## Cached/text iteration (current worktree) + +- 420 affected statement/expression/default/binding/no-fallback tests passed. + All original reference identities and parse shapes are checked in actual + emitted/reparsed text, including nested edits and retained references. +- Raw default compiler regression: four CS1736 failures before the writer fix; + struct, nullable, primitive, enum and reference controls pass after it. +- Final full suites: 3,015 core and 2,509 client-model tests passed. +- Independent planner review found no blockers. Statement review caught a missing + scoped-wrapper path; the faithful helper regression reproduced it before the + correction, and follow-up review found no remaining blockers. +- Official unfiltered real-implementation regeneration succeeded. Sample builds + successfully (its scaffold has no discoverable tests); real Spector tests: + 956 passed, 10 skipped. Generated source restoration was hash-verified. +## Actual AppService pipeline comparison + +Local saved-input management-generator execution, matched Roslyn5.9, four fresh +processes in ABBA order (two samples per variant). Complete generation includes +parsing, index/scope planning and materialization, not precomputed edit timing. +Frozen unchanged SDK/Input/ClientModel layers used the rebuilt compatible core. +This is not fresh TypeSpec compilation or an entirely rebuilt SDK distribution. + +| Mean | Roslyn5.9 | Cached/text provider | +|---|---:|---:| +| Elapsed | 171.44 s | 61.94 s | +| Managed allocation | 30.57 GiB | 11.05 GiB | +| Peak working set | 2.37 GiB | 1.37 GiB | + +Observed **2.77× elapsed ratio**, **63.9% less elapsed/allocation**. Both variants +emitted2,301files, deterministic within each variant. Both independently rebuilt +netstandard2.0/net8.0/net10.0 and passed configured ApiCompat against AppService1.5.0. +All2,235changedfiles were syntactically categorized with zero parse errors or +unclassified differences; this is not proof of semantic identity. + +## SDK-gap reduction: static type prefixes and attributes + +A follow-up bounded pass shortens recorded static TYPE prefixes (keeping the +member receiver), and independently proves attribute prefix/suffix reductions. +Expression lookup conservatively blocks query and implicit accessor names; +lookup kind participates in document caches. Contextual-keyword attribute short +names retain suffixes, including cached ordinary/async occurrences. + +Against the same Roslyn output, categorized differences fall **147,326→129,348** +(**17,978 fewer;12.20%**):11,874staticqualifications,4,695attributesuffixes and +1,409attributequalifications removed. Changed files2235→2167; identical66→134. +No parse errors or unclassified differences. File/category counts overlap. + +Fresh matched providerV1↔V2 ABBA means: **57.78→57.42s**, **11.028→11.024GiB** +allocation, **1.377→1.372GiB**peak. Treat this as **essentially flat/no observed +performance regression**, not a demonstrated sub1%speedup. Do not compare with +historical61.94s and claim an extra4s gain from this cleanup. + +Final3,041core/2,509client tests pass;97targetedcases include type/member/attribute +constructor/nameof binding oracles. Unfiltered real regeneration and956Spector +passes/10skips;both SDKvariants rebuild3TFMs+ApiCompat successfully. Sample builds, +but scaffoldhas0discoverabletests. Fresh sources restored/hashverified. Default(T), +ordinary type/cast guards, conditional opt-out, this/parentheses/cref/inference +remain unchanged by this pass. + +These are two local samples per variant on one management SDK, not a production +performance guarantee. The candidate includes valid-emission repairs as well as +cached planning/text edits. Additional representative SDK/binding/runtime coverage +is required before default enablement. Fresh test-project and SDK service backups +were restored/hash-verified. This iteration remains uncommitted and opt-in. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md new file mode 100644 index 00000000000..8b45a13144d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md @@ -0,0 +1,58 @@ +# Provider reducer: current draft status + +The accepted prototype checkpoint is **C9**. `TYPESPEC_PROVIDER_REDUCER=structural` enables compiler-backed structural reduction; Roslyn remains the default. The older `true` provider prototype is retained for comparison. Writer grammar/default-emission fixes apply in both modes. This remains a draft, not a default rollout or complete Roslyn-simplifier replacement. + +## Applied rules + +- Record original writer type identities/spans and whole-type cref provenance. Share original-compilation facts and integrate reduction with member cleanup in one rewrite traversal and final serialization; uncertain cases retain their spelling rather than fall back to Roslyn. +- Shorten supported declaration/type names, namespace prefixes and aliases, including constructed generic heads and nested/array/nullable forms where certified. Conditional documents retain configuration/header/type-scope guards. +- Shorten supported static type receivers, including the bounded conditional-static certificate; this does not generally remove a complete member receiver. +- Remove certified field/property `this.` receivers with binding/conversion/source-observation guards. General method-call receivers remain outside that rule. +- Reduce certified attribute names/qualification and suffixes without rewriting attribute arguments. +- Reduce recorded whole-type XML crefs with serialized binding and import/diagnostic certificates, including C9's immediate-namespace permission. Exception provenance and broader imported/ancestor cases are not silently expanded. +- Simplify eligible optional-parameter defaults using import-use witnesses; emit valid `default(T)` forms where the writer must preserve a typed optional default. +- Remove certified expression parentheses, including conditional/statement and bounded cast-payload cases, while retaining grammar/context, trivia/visibility, caller-source and conversion safeguards. Assignment-expression statements remain legal unparenthesized C# statements. + +C3 cast-type qualification, C6 `nameof`, C7 method-call `this`, and C10 SDK-shaped generic inference are **not included as accepted enhancements**. The newer cast investigation is also not included. + +## Saved AppService diff gap + +Same saved AppService input and saved Roslyn 5.9 reference, **2,301 emitted files**. Counts are deduplicated syntax-taxonomy events, not Git lines or independent edits. + +| Checkpoint | Residual events | +|---|---:| +| Initial structural slice | 165,725 | +| Current accepted C9 | 29,336 | +| Reduction | **136,389 (82.3%)** | + +The earlier combined/legacy provider prototype had 147,326 residual events; that is a different starting rule set, not the initial structural checkpoint. Rejected/deferred experiments are excluded from the current count. C9 alone removes 4,275 cref events across 894 files; its remaining cref count is 4,974. + +## Remaining AppService pattern groups + +| Potential group | Events | +|---|---:| +| Cast/generic-argument type qualifications | 7,050 | +| Static helper/type receiver qualifications | 6,045 | +| XML cref qualifications | 4,974 | +| Method-call `this.` receivers | 3,834 | +| Qualified `nameof` operands | 2,399 | +| Complete member/type receivers | 1,678 | +| Attribute qualifications | 817 | +| Factory `(T)default` casts | 784 | +| Expression parentheses | 665 | +| Explicit generic call arguments | 560 | +| Object-creation type qualifications | 496 | +| Small keyword/pattern/null/default/declaration remainder | 34 | +| **Total** | **29,336** | + +Important concentrations: 4,798 events are interface/model-argument qualifications in 2,399 `IPersistableModel` casts; static receivers include 2,279 Optional checks, 1,047 ModelReaderWriter uses, 1,047 serialization-context uses and 1,254 JSON fallback calls. Exception crefs account for 2,574 events. Overall, 20,064 events occur in serialization files; 18,871 occur in conditional documents, but only 1,255 are inside branches. These cross-cuts overlap and must not be added together. Counts are investigation opportunities, **not safe-edit estimates** or authorization to revive deferred rules. + +## Validation and limits + +The accepted C9 source epoch passed 75 targeted and 869 focused tests, 3,813 Core and 2,509 client tests, plus official unfiltered LOCAL/Spector (956 passed, 10 skipped). The sample discovered zero tests and is not runtime coverage. Source proof covered all 2,301 files in six configurations (27,612 parses), with independent UTF-8/BOM/UTF-16 reconstruction. Semantic output-consumer checks matched 25,650 bindings, complete ordinary/unfiltered compiler/emit diagnostic multisets and 90,444 independently projected locations; 23 controls passed. + +Authentic original reconstruction uses 2,699 documents and 20 references. The explicit output-consumer view has 2,563 documents after symmetrically collapsing 136 original same-name/source/metadata-identical duplicate IDs. Original binding/diagnostic anchors run before that construction. Its existing failed emits are diagnostic evidence, not positive XML/runtime proof. + +A successful candidate-only net10 build verified actual GA AppService 1.5.0 API compatibility and byte-identical whole emitted XML against authenticated saved accepted-baseline XML. All 2,677 SDK and 2,186 local/launch files were exactly restored. No fresh baseline build was required. Fresh netstandard2.0/net8.0 validation and comparative profiling remain deferred. The approximately 119-second C9 whole-generation run is a rough single-run observation, not a reducer-only measurement or comparative speed claim. + +Earlier architecture and measurement histories remain in [ProviderReducerPrototype.md](ProviderReducerPrototype.md) and [StructuralTypeNameReduction.md](StructuralTypeNameReduction.md); their historical scope limits and measurements are not current rollout claims. Large captured SDK inputs, outputs and local validation logs are intentionally not checked in. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/README.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/README.md new file mode 100644 index 00000000000..5779350f05b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/README.md @@ -0,0 +1,40 @@ +# Generator benchmarks + +Current main-based results and validation: [LatestMainProfilingResults.md](LatestMainProfilingResults.md). + +Run from this directory with the .NET SDK required by `../../Directory.Build.props` (currently .NET 10). + +```sh +dotnet run -c Release -- --list flat +dotnet run -c Release -- --filter '*FullGenerationBenchmark*' '*PostProcessingBenchmark*' +# Execution check only; Dry results are not performance baselines: +dotnet run -c Release -- --filter '*' --job Dry +``` + +## Workloads + +- `FullGenerationBenchmark`: copies `TestProjects/Local/Sample-TypeSpec` into a fresh temporary directory and runs the current **base generator**. Compares the supported `disable-roslyn-reduce` option; main's provider reference-map processing is always used. Includes fixture copying, configuration, generation, output-byte counting, and cleanup. This is not management-plugin generation, process startup, or TypeSpec compilation. +- `PostProcessingBenchmark`: loads saved generated C# from `Sample-TypeSpec/src/Generated`, then measures workspace creation, document loading, and main's document-processing pipeline. Compares Roslyn reduction on/off, with corpus multipliers 1 and 5. The larger corpus uses rewritten namespaces, not independently generated SDKs. It does not measure provider reachability or full generation. Override the saved corpus with `POSTPROCESSING_BENCHMARK_GENERATED_DIR`. +- `CodeWriterBenchmark`, `MethodProviderBenchmark`, and `ModelReaderWriterContextDefinitionBenchmark`: existing focused microbenchmarks. These are not end-to-end generator measurements. Initialization constructs the base generator directly (no test-assembly plugin scan), and the context benchmark uses main's current `MethodBodyStatement` attribute representation. + +`Projects/Model/tspCodeModel.json` is a minimal, current-schema namespace fixture for microbenchmark initialization. The writer/method benchmarks construct their measured providers in code; they do not generate the old saved model. The full-generation benchmark still uses the complete, current `Sample-TypeSpec` fixture. + +The complete Dry matrix has 47 parameterized cases. Explicit `--job`/`-j` selection uses BenchmarkDotNet's selected job settings rather than the runner's custom default iteration counts. + +For timing and allocation comparisons, leave step profiling **off**, use a Release build, and do not run other builds/benchmarks concurrently. BenchmarkDotNet reports and raw logs are written to `BenchmarkDotNet.Artifacts` (ignored). Record the main revision, local profiling changes, SDK/runtime, corpus identity, and job settings with results. + +## Optional step attribution + +```sh +POSTPROCESSING_BENCHMARK_PROFILE_STEPS=true \ +POSTPROCESSING_BENCHMARK_PROFILE_DIR=/tmp/typespec-step-profiles \ +dotnet run -c Release -- --filter '*FullGenerationBenchmark*' '*PostProcessingBenchmark*' --job Dry +``` + +The hooks are disabled unless a benchmark explicitly sets a profile. Enabled profiles retain main's document concurrency; they do not switch to sequential execution. Per-document durations and process-wide allocation deltas can overlap across concurrent documents, and parent/child phases overlap. **Do not sum them or interpret them as exclusive CPU time or allocations.** Use BenchmarkDotNet's unprofiled elapsed time and memory diagnoser for comparisons. Profile emission itself is inside the benchmark invocation and adds overhead. + +The full-generation profile separates provider reference-map preparation, C# writing, document processing, and disk writes. The saved-corpus profile attributes document processing only. CSV files include reduction mode and corpus size in their filenames. + +## Historical reports + +`HotspotProfilingResults.md`, `AppServiceHotspotProfilingResults.md`, and `NativeReductionPlan.md` preserve the earlier investigation and plan. Their measurements used the old branch implementation, including legacy post-processing/shadow modes and sequential step profiling. They are **not a baseline for current main**. In particular, removed `PostProcessor` phase costs do not describe main's provider-based reference-map pipeline. Recollect measurements before using those numbers to prioritize new work. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/StructuralTypeNameReduction.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/StructuralTypeNameReduction.md new file mode 100644 index 00000000000..b702b1fa205 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/StructuralTypeNameReduction.md @@ -0,0 +1,1367 @@ +# Structural type-name reduction: rules and test matrix + +> Historical design/experiment log. See [ProviderReducerStatus.md](ProviderReducerStatus.md) +> for the current accepted rules, AppService gap, validation and remaining groups. +> Scope exclusions and measurements below describe their individual historical stages. + +**Status: opt-in generator integration implemented and validated, not a default +replacement.** Sections 11–12 record the initial slice; section 13 records the +expanded type-only qualification coverage and its measured cost. Section 14 records +namespace experiment A, validated/measured and accepted as B's baseline. Section 15 +records B; section16 records accepted C, and section17 records D's opportunity +screen (no production change). Section 10 records the executable rule +prototype; section 11 documents its promoted shared selector and the bounded +production integration. Attribute reduction is outside this mode. + +## Goal and non-goals + +Choose a less-qualified spelling of a **known type reference** by resolving a +structural name in its real scope. Do not progressively trim strings or run +independent cleanup passes. Attribute names/suffixes are outside this design; +attribute arguments can contain ordinary type references and use the ordinary +rules where their provenance/context is known. Do not introduce aliases/imports, +remove member receivers, infer method arguments, change casts/defaults, or chase +Roslyn textual parity. Existing primitive-keyword emission is not redesigned. + +The previous combined prototype has valuable correctness anchors but is not the +intended architecture: its measured additional cost does not authorize repeating +its document-wide scans or emitted-source prefix edits here. + +## 1. Decision contract + +Given a reference R to target T, select a ranked candidate N only if: + +1. T's identity is established in the current compilation/reference universe. +2. N resolves to T under the language lookup rules for R's scope and lookup mode. +3. N preserves the surrounding syntactic interpretation and identifier spelling. +4. Every supported output configuration relevant to R agrees on these facts. + +Otherwise use a more-qualified candidate or preserve the original reference. +A retained name is an explicit decision, not an invitation to a semantic fallback. + +Target identity includes the named definition, containing-type construction and +argument identities. Type parameters use declaring-owner identity + ordinal, not +just their names. Resolve runtime/provider provenance into the compilation's +canonical identity once; raw runtime assembly names are not a substitute for +reference-assembly/type-forwarding resolution. Missing, ambiguous, or unestablished +identity means retention. Namespace/name/arity strings alone are not sufficient. + +### Structural inputs (conceptual, not proposed API signatures) + +- **Reference:** target identity, scope/lookup-region ID, lookup mode, grammar role, + configuration-set ID, and provenance. Existing spelling may be preserved. +- **Name form:** root kind (unrooted/global namespace/existing namespace alias/ + existing type alias), namespace symbols, containing named-type chain with its + arguments, terminal named type, and identifier-escaping information. +- **Scope:** enclosing namespace/type/method/block regions, type parameters, + applicable imports/aliases, and declarations visible under the relevant lookup + mode. Source partials and inherited members contribute through shared indexes. + +`global::` is the global-root name form in C# syntax; no textual prefix removal +rule is needed. Namespace aliases and type aliases are different root kinds. +A namespace alias can use alias qualification; a type alias is not a namespace. + +## 2. Candidate construction and selection + +Construct candidates from the target's containment graph and existing alias +bindings. Do not discover boundaries by splitting fully qualified source text. +The finite candidate set consists of: + +- terminal type alone; +- successive suffixes of the containing-type chain, with required constructions; +- namespace-qualified paths that retain the complete required type chain; +- existing aliases whose bound targets structurally match a usable root; +- unrooted and globally rooted complete paths, where those roots are meaningful; +- the original spelling as a preservation outcome. + +Each candidate is independently resolved from the use site. A failed simple name +can lead to `Outer.Inner` or `B.Widget`, not necessarily directly to the longest +name. Preserve all generic argument lists; argument references are separate typed +children, not text embedded in the outer candidate. + +Rank legal candidates by qualification-segment count. Alias introduction is not +implied: preserve an existing explicit alias on ties; otherwise prefer ordinary +unaliased spelling before an alternative alias, with a stable structural tie-break. +This policy is proposed and must be pinned by tests before implementation; do not +add source imports or change namespace-wide policy to make one candidate work. + +## 3. Reduction rules + +| ID | Scenario / candidate | Required proof | Fallback | +|---|---|---|---| +| R0 | Any recorded named reference | Canonical target, well-formed provenance, known scope/context; original reference is usable | Preserve original on missing/ambiguous identity or opaque provenance | +| R1 | Current/enclosing namespace or imports: `Widget` | Ordered C# type lookup in the actual scope selects T; ordinary/global imports contribute at their proper lookup level | Continue to owner/namespace candidates | +| R2 | Nested type: `Inner`, `Outer.Inner` | Containing and inherited type lookup, accessibility, and containing constructions resolve the complete chain to T | Keep required owner(s); never drop an owner solely because the terminal name is unique somewhere | +| R3 | Namespace suffix: `B.Widget` | Resolve first segment and each subsequent namespace/type segment; exact target at the end | Try a longer namespace path | +| R4 | Existing alias: `DTO`, `S::Widget` | Alias binding and its scope are known; root kind is legal; bound construction is identical | Do not invent an alias; retain another legal form | +| R5 | Unrooted full path: `A.B.Widget` | First segment cannot be captured by a nearer type/value/alias/namespace under this lookup mode | Retain global root | +| R6 | Generic / nested-generic name | Preserve definition arity, containing substitutions, argument order and identities; independently resolve argument references in type mode | Retain qualification without changing construction | +| R7 | Type reference as an expression receiver | Expression lookup selects the type rather than a value; unchanged access still denotes the intended member/overload where a proof is available | Preserve type qualification; do not reduce the complete member receiver | +| R8 | `nameof` type prefix | Same referenced symbol (or applicable symbol set) AND same constant, not merely equal terminal text | Preserve qualified operand; do not turn a member reference into an unqualified identifier | +| R9 | Writer-owned whole-type cref | Documentation-specific scope, syntax and identity are preserved | Leave raw/member/prose/unrecorded cref alone | +| R10 | Every candidate in its grammar role | Escaping, lexical boundaries and surrounding parse shape remain valid; keep wrappers/operators unchanged | Choose a more-qualified form or original spelling | +| R11 | Conditional source / external declarations | Same resolution and grammar result across the explicit supported configuration set | Retain affected reference/scope when proof is unavailable; global imports may legitimately affect all scopes | + +Lookup precedence matters. An outer namespace/import collision does not override +an already winning nearer declaration. Conversely, uniqueness across a flattened +set of visible names does not prove the C# lookup result. Accessibility filtering +must be applied at the appropriate lookup stage, not by treating an inaccessible +nearer declaration as if it never existed. + +## 4. Lookup modes and syntax roles are separate + +| Mode | Examples | Distinguishing rule | +|---|---|---| +| Type | return/parameter/field types, `new T`, casts, `typeof(T)`, type patterns, generic arguments | Use namespace/type lookup, not a document-wide set of value names | +| Expression type receiver | `T.Member`, `T.Method(...)` | Values can capture the left side; retain the member access and selected operation | +| Nameof | `nameof(T)`, recorded type prefix in `nameof(T.Member)` | Preserve symbol identity/set and constant | +| Whole-type cref | `cref="T"`, generic type descriptions | Documentation lookup and identifier/generic rendering rules | + +A lookup mode alone is not enough for the cache. Grammar roles distinguish, for +example, a variable declaration type from a return type or a cast operand. + +**R10 examples checked by the scenario oracle:** the preliminary example +`(global::N.T)-x` is NOT a valid starting cast: qualification does not fix it. +The valid cast fixture preserves its negative operand's parentheses: +`(global::N.T)(-x)` can shorten to `(T)(-x)`. A separate oracle control, `(int)-x` +versus `(global::System.Int32)-x`, checks that equal type identity is insufficient +for grammar preservation; it is NOT a proposed reduction or a keyword-emission +change. Likewise a qualified `await` still needs escaping inside an async method: +use `global::N.@await` as the valid original and `@await` as its candidate. + +Shortening a qualified contextual-keyword name must not introduce inference or +async parsing behavior. Use language token/grammar rules and contextual escaping, +not a private replacement-name map. Retain original syntax if a bounded grammar +proof is unavailable. Parser/binder checks belong in regression oracles; this +design does not prescribe per-reference production reparsing or speculative binding. + +## 5. Rule-and-test matrix + +This is the **full planned coverage catalog**, not a claim that every combination +has been tested. An executable candidate-oracle subset is described in section 9. +The final column identifies pre-existing anchors worth preserving, not exhaustive +coverage of that row. + +| Test family | Positive / reduction expected | Negative / retention or longer candidate expected | Existing anchors / gaps | +|---|---|---|---| +| Current/imported type | Current namespace; enclosing namespace; ordinary and global imports | Duplicate imports; nearer type collision; inaccessible nearer declaration | Existing basic/imported/reference-binding tests; explicit precedence/global-import tests still required | +| Partial namespace | Ambiguous simple `Widget`, but unambiguous `B.Widget` | First namespace segment captured by a type or alias; both suffixes ambiguous | Existing ancestor collision retention; positive partial-path selection is new | +| Global-root-only | Complete unrooted namespace path is safe | Namespace/type/alias captures its first segment | Existing global qualification guards; explicit intermediate-root selection is new | +| Nested owner | Outside owner needs `Outer.Inner`; inside owner can use `Inner` | Competing `Inner`; inherited nested type; private owner; multilevel chains | Preserve `NestedFrameworkTypeStaysQualifiedInFirstPrototype`; new rule needs independent desired-reduction fixtures, not silently changing that anchor | +| Generics | Same definition/arity with independently shortened arguments; nested generic owner | Same simple name with different arity; differently constructed owner; different type-parameter owner | Existing generic/array binding anchors; constructed declaring-type and arity competition need new tests | +| Wrappers | Named leaf inside array/nullable/tuple/pointer forms without wrapper changes | Unknown leaf target; wrong generic/nullable construction | Existing nested-generic/array fixtures cover some leaves, not nested CLR declaring types | +| Aliases | Preserve explicit type alias; use a proven existing namespace alias where selected | Alias points to another type/construction; alias hidden/out of scope | Existing alias-collision retention; alias-selected reductions are new | +| Extern/assembly identity | Preserve established explicit assembly-alias form | Same metadata name in different assemblies; unresolved forwarder; unsupported extern-alias shortening | New identity tests; keep unsupported extern-alias forms initially | +| Type versus value scope | Local `Widget` elsewhere must not block a type-only reference; sibling methods independently resolve | Local/parameter/property/query/pattern variable captures `Widget.Member`; method type parameter captures a type | `TypeAndShadowedExpressionDecisionsDoNotShareCacheEntries` exists; same-method lookup-mode and sibling/nested-region positives still required | +| Partial/inherited declarations | Benign partial/base declarations do not block unrelated names | Collision in another part/base/interface; nested generic owner substitutions | Existing partial/inherited/interface/global-versus-namespace anchors | +| Nameof | Shorten proven type prefix while preserving member and constant | Same string but different symbol; shadowing; overload/symbol-set change | `NameofStaticMemberKeepsItsTargetAndConstant`; `NameofConstantAloneDoesNotProveTargetIdentity` | +| Whole-type cref | Same documentation ID/assembly with shorter type path | Method/type parameters capture a name; member/prose/unrecorded reference; nested construction unknown | Existing cref OriginalDefinition DocID/assembly oracles; alias/type-parameter collision cases remain | +| Grammar/spelling | Safe type-position shortening; correctly escaped identifiers | Cast versus subtraction/delegate ambiguity; contextual identifiers; token joining; changed wrappers | Preserve ambiguous-cast and combined-delimiter/assignment anchors; add role-specific name-shortening cases | +| Configuration | Identical identity across explicit framework/project-flag environments | External conditional type, global alias/import, changed base/header, semantic directive state, branch-local capture | Existing conditional partial, NET5 alias, original NET9-only branch and nullable anchors; cross-project-flag safety is NOT established | +| Unknown/raw source | Original spelling stays unchanged | Stale/missing site identity, unsupported source shape, customization changes reference ownership | Existing malformed-site/changed-root offset/provenance anchors | +| Cache isolation | Repeated identical target/environment shares a decision | Different region, context, grammar role, construction or configuration shares no unsafe decision | Existing type/expression cache isolation; add nested-region/construction/configuration isolation | + +The baseline combined prototype retains a fixed-project-flags limitation. The new +rules must name their supported **output build configurations** explicitly. Do +not call a DEBUG/HAS-dependent declaration absent merely because the generator's +current parse options omit that flag. Unknown configuration effects require +retention at the conservatively affected lookup scope. This is a design requirement, +not a claim that the current prototype already implements such dependency tracking. + +## 6. Work-sharing and emission constraints + +1. **One shared environment.** Declaration identity, imports, aliases, partial/base + contributions, scopes and references must not be reconstructed independently + for type names, expression receivers, nameof and cref. +2. **One collection per required input view.** Reuse the existing compilation, + syntax trees and existing provider/writer traversal where sufficient. Do not + separately scan the same tree for aliases, locals, references and each rule. + Distinct necessary active configurations are distinct views, cached and reused. + Do not reparse a variant for header proof and again for site collection. +3. **Carry context instead of rediscovering ancestors.** Capture a scope/region ID, + lookup mode and grammar role once. Writer brace depth/temporary-name allocation + alone is not a complete C# lookup scope graph. +4. **Share target structure.** Intern namespace/type containment and type identity; + do not build/split fully-qualified strings per occurrence or walk symbol trees + for every candidate. Query referenced-assembly namespaces/types lazily and cache. +5. **Cache complete decisions.** Key by canonical constructed target, immutable + lookup environment/region, mode, grammar role and configuration set. Include + original-form/alias policy or original-syntax facts if they affect selection or + preservation. A document ID or simple type spelling is insufficient. Cached + retention outcomes also need explicit reasons. +6. **Plan names, then emit once.** A decision is a structural name form, not a list + of substring replacements. The existing type/identifier renderer emits it once + with its arguments, wrappers and correct escaping. No qualified output → trim + → attribute rewrite → secondary name rewrite chain, and no new string-edit pass + as the default architecture. +7. **Preserve uncertainty without rescanning.** Record reason codes such as + unknown identity/provenance, ambiguous lookup, value capture, grammar risk or + configuration dependence while deciding. Diagnostics should aggregate those + decisions, not traverse output again to infer why names remained qualified. + +There may still be distinct necessary stages for collecting scope facts and final +emission. The constraint is to avoid duplicated discovery/repeated rewriting, not +to claim that future declarations are already known at a streaming write site. + +## 7. Integration choice remains open + +Observed current hooks: + +- `TypeReferenceSite` records a span, CSharpType and code/whole-cref role, but not + the complete lookup-region/grammar context proposed above. +- `CodeWriter.AppendType` emits qualification immediately, before final source + processing; it also registers namespace uses. Import registration and import + visibility must not become circular inputs that change during candidate lookup. +- `CodeWriter` has brace scopes, but raw writes and method/body semantics mean + those scopes cannot simply be equated with language lookup scopes. +- `GeneratedCodeWorkspace` already obtains a compilation and processes source + trees; current `ProviderNameReducer` combines matching, document blockers and + edit planning. Reusing this information is different from adding another pass. + +Before implementing, choose whether complete facts can be supplied from existing +provider/structured-body construction before writing, or whether named references +must be represented as deferred structural fragments until scope facts are known. +Raw/custom syntax without sufficient facts may remain opaque. If existing member +removal/customization changes ownership or scope, freeze/refresh the shared facts +at the correct boundary rather than replaying stale decisions. + +Do **not** silently choose span rewriting to avoid resolving this integration +question, and do not introduce a second general AST/code-model traversal without +an explicit account of what the existing traversal cannot provide. + +## 8. Proposed staging and acceptance gates + +1. Agree the identity, lookup/context, grammar and configuration contracts above. +2. Pin positive/negative tests for the simplest type-only and static-receiver + rules; preserve all original regression anchors. Reproduce desired missing + reductions against the current/base implementation before production edits. +3. Select the integration that provides facts once and emits each chosen name once; + explicitly account for raw writes, imports, member removal and customizations. +4. Implement only the agreed initial subset. Record retention reasons and counters + for environment construction, unique decisions/cache hits, variant parses, + retained references by reason, and name emission. These are future acceptance + requirements, not instrumentation installed by this document. +5. Validate emitted/reparsed type identity and the enclosing operation as relevant; + nameof constant/symbol and cref documentation-ID oracles stay separate. Tests + must cover both successful reduction and safe retention, including custom/raw + and cross-file/configuration cases. +6. After focused checks stabilize, run only the broader suites/generation/build + gates invalidated by implementation. Measure the actual full generation path + against the frozen baseline; do not infer gains from lookup microbenchmarks. + A new matched Roslyn comparison remains deferred until requested. + +No production implementation, new postprocessing pass, new string rewriting, +changed existing regression expectations, attribute rollback, SDK regeneration or +benchmark follows merely from writing this matrix. + +## 9. Executable candidate-oracle tests + +`test/StructuralTypeNameRuleOracleTests.cs` supplies explicit original and candidate +spellings in small source fixtures; it does not call `ProviderNameReducer` or +implement automatic candidate selection. Fixture text substitution is test-data +construction only, not a production rewriting strategy. + +For each specified configuration, the compiler oracle requires a valid original +and evaluates candidate diagnostics, canonical reference identity (including +constructed owners/arguments/nullability), enclosing symbol/type/constant results, +and reparsed grammar outside the substituted name. Method-local type-parameter +owners need structural source-declaration identities: documentation IDs can collide +between a member method and a local function. An oracle integrity control for that +collision was independently reproduced before correcting the helper; a positive +control checks owner identity remains stable when a containing name is shortened. +Conditional counterexamples assert candidate validity in each individual flag +configuration, not just an aggregate failure. Negative controls cover +compiling-but-wrong targets, equal nameof constants with different symbols, +argument/owner construction changes, value capture, and conditional external +collisions. Additional controls reject invalid original examples and demonstrate +that a different overload is detected even with an identical referenced type. + +These tests establish that specific proposed candidates are admissible or must +be rejected. They do NOT prove that the prose rules handle all C# cases, that the +production reducer follows them, or that a candidate is the shortest legal form. +Once a production selector exists, reuse these cases for selection/conformance +checks and pass its emitted candidate through the independent oracle. The enclosing +operation checks cover selected ancestor syntax categories, not a complete +IOperation tree, all operators/indexers, or every implicit conversion. Extend the +oracle when adding those scenarios. Cache behavior, provenance, assembly-forwarding/ +extern-alias cases and the full configuration universe still require separate +fixtures. + +The first 39-case run passed 36 and rejected three invalid ORIGINAL fixtures. +Those invalid examples remain as diagnostic controls; the corrected cast/async +examples above supersede the inaccurate preliminary examples, not the existing +production regression anchors. The local-function owner collision subsequently +reproduced an actual false-positive oracle result (45 passed / 1 failed); the +structural declaration identity correction and owner-stability control close it. + +Initial oracle-only milestone: **44 explicit candidate scenarios + 3 oracle/fixture +controls = 47 new tests passed**, alongside **137 existing regression anchors**, +for **184 passed / 0 failed / 0 skipped**. Section 10 records the subsequent expanded +suite and automatic selection results. Independent static review informed the owner +collision control; the reproduced failure and passing correction are retained. +No production reducer or existing test expectation was changed. That initial +oracle milestone did not exercise an automatic selector; section 10 documents the +subsequent selection implementation and verification. No SDK generation, benchmark +or full generator suite was run for this test-only work. Execution receipts are under +`.pi/benchmarks/provider-reducer-structural/` and `.pi/structural-scenarios*.log`. + +## 10. Executable structural selector (test-only) + +`test/StructuralTypeNameRuleEvaluator.cs` now executes candidate construction, +resolution and ranking. `StructuralTypeNameRuleEvaluatorTests.cs` supplies expected +selections and runs each chosen spelling through the separate compiler oracle. +The evaluator receives only original references/configurations: expected spellings +and the oracle's manually supplied candidates are not evaluator inputs. + +### What is executable + +- Namespace and containing-type chains produce simple, owner-qualified, partial- + namespace, global-root and existing-alias name forms structurally. +- Root and member lookup facts are supplied by Roslyn's ordinary lookup APIs, + cached within one evaluation. This is **not yet a generator-owned scope index**. +- Each path segment must have one binding at that lookup stage. Later suffixes + cannot remove an ambiguous owner and retroactively validate the path. +- Constructed target identity is checked separately in every configured view; + nameof spelling/constant constraints also apply in every view, not just the first. +- Ranking uses structural component count and preserves an explicit original alias + on a tie. Namespace aliases and type aliases have distinct syntax/root behavior. +- Identifier escaping uses language token facts. Only the selected form is emitted + as an AST name. No Simplifier, speculative binding, candidate parsing, fully- + qualified-string splitting/replacement, or whole-source traversal is used by + the evaluator. Candidate recompilation is confined to the independent oracle. +- Source-name parts/root-alias and enclosing context are captured once per view; + lookup facts are shared across candidate forms. Tests check actual snapshot + counters (including repeated unsuccessful alias paths), not elapsed microtimings. + +### Safe retention and limits + +Non-named/predefined inputs, unbound types, significant reference trivia, +unsupported cast forms, generic crefs and incompatible configured type-chain +shapes are retained with explicit reasons. Generic argument syntax is preserved; +recursive argument-name reduction is **not yet implemented**. A heterogeneous +configuration can therefore have a legal shorter name that this prototype +intentionally does not select. It is not a proof of global shortest-name selection. + +Fact caches are scoped to **one evaluation**, not shared across all references in +a generator region. Replacing compiler lookup services with shared generator-owned +facts, integrating scope discovery/imports/customization/member removal, and +emitting directly in the production writer remain later design/implementation +work. No production efficiency or SDK coverage improvement is inferred here. + +### Regression history and results + +The preserve-only starting stub failed 41 selection tests while 48 other checks +passed, reproducing the missing behavior before implementation. The first selector +passed 42 automatic selections plus 47 oracle tests. Additional alias tests caught +loss of an explicit namespace alias on a ranking tie; root-alias identity is now +captured during the same structural source-part collection. + +Independent review then supplied three concrete boundaries. All reproduced before +correction (105 passed / 3 failed): + +1. `Outer.Inner` cannot disambiguate two imported `Outer` types merely because only + one declares `Inner`. +2. A type alias valid in a first `typeof` configuration can change a nameof constant + in a second configuration. +3. A reference whose containing-type chain changes between configurations must + not index another view's chain by the first view's ordinal and throw. + +The selector now rejects intermediate ambiguity, checks role-dependent constraints +in every view, and explicitly retains incompatible containment shapes. Original +counterexamples and safe alternatives remain independently compiler-checked. + +**Current validation: 50 automatic selection cases + 55 independent oracle/fixture +cases + 3 cache/retention controls + 137 pre-existing anchors = 245 passed, +0 failed, 0 skipped.** Receipts: `.pi/structural-evaluator-final.log` and +`.pi/benchmarks/provider-reducer-structural/evaluator-final.trx`. + +Only test/specification files were changed for that rule-prototype stage; no +production rewrite pass, attribute changes, SDK generation, fresh Roslyn comparison +or benchmark was run then. Section 11 describes the subsequently authorized +production integration. + +## 11. Initial generator integration: `structural` mode + +The user approved implementing a bounded slice after the rule tests. The shared +rule engine now lives in `src/PostProcessing/StructuralTypeNameSelector.cs`; the +old test evaluator is only an input adapter. Expected selections and the independent +compiler oracle remain test-only. Production code does not depend on test helpers. + +### Chosen boundary and mode routing + +Use `TYPESPEC_PROVIDER_REDUCER=structural` for the new experimental mode. Unset/off +still uses the existing Roslyn path; `true` still selects the previous provider +implementation. There is no default rollout or attribute rollback. + +Complete compilation facts become available in `GeneratedCodeWorkspace` after +all source files have been added. `StructuralTypeNameReducer` supplies a separate +stateful subclass of `MemberRemoverRewriter` for each eligible document. Name +selection/replacement occurs DURING that existing traversal; base namespace/ +compilation-unit pruning still runs, followed by the existing final serialization. +No preliminary reference-matching walk or extra whole-tree rewrite pass is added. + +**The qualified CodeWriter draft remains analysis input.** This does not claim +direct first-write name emission or elimination of all parsing. The reused global +conditional header proof still inspects/parses configuration views. Its conditional- +tree classification is now cached once per tree instead of rescanned per mode call. + +### Initial supported slice + +- Writer-recorded Code references, exact original spans and structurally verified + global namespace/containing-type paths. +- Named targets with no generic terminal or generic containing owner. +- Declaration type positions: field/local variable, property/indexer/event, + method/local-function/delegate return and parameter types, including supported + array/nullable/pointer wrappers and ordinary declaration generic arguments. +- Generic containing-type-segment arguments are conservatively retained; explicit + positive/retention fixtures pin that limitation. +- Attributes, cref, new/cast/typeof/nameof/pattern/receiver/base/constraint and body + invocation-generic contexts are not reduced by this integration. +- Conditional TARGET documents remain opaque. Global header safety still includes + other trees, under the existing explicit fixed-project-flags contract—not a + universal proof across later DEBUG/HAS changes. + +### Shared facts, caches and provenance + +Canonical metadata lookup includes namespace + CLR declaring-type chain and is +cached at compilation level. This string is metadata identity input, not emitted +source matching or rewriting. Every occurrence independently validates its span, +role, original global root, full structural spelling and context before any +cache hit. The first target in a lookup region also binds to the expected canonical +compilation symbol; uncertain/ambiguous metadata is retained. + +Traversal state, semantic model and caches are document-local. Original type, +method, local-function and delegate declarations establish separate type-lookup +regions. One region shares its compiler-backed lookup facts and target decisions; +requested root/member names are queried lazily and cached with the same region/ +accessibility context. A once-per-compilation alias-NAME index visits namespace/ +using headers, not type or method bodies; actual alias binding remains per region. Cached replacement +syntax carries no occurrence trivia; each use preserves its own trivia. Significant +interior trivia, duplicate/invalid spans, wrong roles and wrong identities retain +original names. No Simplifier fallback or source-string edit chain is used. + +Custom-rewriter projects retain their semantic-model/formatting contract without +structural selection. Disabled reduction and absent usable metadata do not fall +back to Roslyn simplification in structural mode. + +### Initial-slice validation state + +Seven production-mode gaps were reproduced before integration. The first working +slice passed the rule suites and all but one overqualified test expectation; that +new expectation was corrected to the already verified safe `System.DateTimeOffset` +form, retaining a mode-specific assertion and emitted-symbol oracle. + +Static production review found no confirmed unsafe rewrite. It identified omitted +local/delegate types in the integration oracle; two deliberate type changes then +reproduced false passes (17 pass / 2 fail). The oracle now includes local variable +and delegate return types and uses declaration ordinals instead of colliding local +function documentation IDs for declaration keys. It checks the actual emitted +source against original declaration identities after recompilation. + +**282 focused checks passed**, including existing anchors, promoted selector/ +compiler-oracle cases and 24 actual generator integration cases. Six additional +alias cases cover another-tree global aliases, file-scoped/nested/inner namespaces, +invisible sibling aliases and same alias spellings with different targets. They cover scope +restoration, independent documents, metadata namespace separation, cache-hit +provenance/trivia/roles, empty-namespace pruning, excluded contexts, aliases, +nongeneric nested owners, generic-owner retention and conditional/disabled/custom +mode routing. Real scoped Sample generation/library build passed; 2,186 original +files were hash-restored. Its test scaffold still discovers zero tests. + +Final validation: **3,226 core / 2,509 client tests**, official unfiltered structural +generation, **956 Spector passed / 10 skipped**, and independent SDK three-TFM +Rebuild/ApiCompat all passed. Test-project and SDK snapshots were hash-restored. +Exact production source matches the measured build. No commit/push/default rollout. + +## 12. Requested-name collection and measured boundary + +The first integrated collector enumerated all visible scope/container symbols. +Although correctness gates passed, its actual AppService generation took 104.317s +versus a fresh 73.588s combined-provider baseline, with nearly double allocation. +That measurement is rejected as a performance result; preserved in +`.pi/benchmarks/provider-reducer-structural/full-generation/`. + +A bounded correction queries only requested names, memoized by region/mode/name +and region/container/mode/name. Alias names are collected once through compilation- +unit and namespace using declarations; source type/method bodies are not visited. +This IS an extra shared namespace/header index, not a claim of zero extra discovery. +`ScopeSnapshots` counts lazy lookup-choice views, not Roslyn calls; `RootQueries` +and `MemberQueries` are the actual query counters. Using-alias discovery does not +include extern aliases or previous script submissions, which remain unsupported. +Named lookup APIs can expose non-source-referenceable symbols omitted by the old +all-name API; no unsafe path was found in this slice, but unconditional semantic +identity between those APIs is not claimed. Alias-name union work can still scale +with regions times distinct aliases. All caches remain region/document-owned except +the weak compilation-keyed alias-name index. + +Corrected full actual-generation ABBA (two samples each), both Roslyn 5.9: + +| Mean | Frozen combined provider | Structural declaration-type slice | +|---|---:|---:| +| Elapsed | 73.106s | 74.008s (+1.23%) | +| Allocated | 13,089,303,376 B | 13,158,104,792 B (+0.53%) | +| Peak working set | 1,621,039,104 B | 1,590,112,256 B (−1.91%) | + +The corrected structural output is byte-identical to its initial structural output +in all 2,301 files. The local paired time is roughly flat, not evidence of a speedup +or statistical neutrality. Cleanup scopes differ: the combined baseline additionally +cleans expressions/receivers/attributes/crefs and conditional target documents; +the initial structural slice intentionally does not. Do not describe it as already +matching that broader cleanup coverage. + +Saved-output taxonomy versus Roslyn: 165,725 events, including 69,509 type-name +qualification events; zero parse errors or unclassified residuals. This is larger +than the combined prototype's gap, as expected from the narrower integration. On +the same corpus, raw `global::` occurrences fall 157,120 → 118,130 against the +preserved no-plan writer draft (a raw occurrence count, not semantic proof). + +Full receipts/report: `.pi/benchmarks/provider-reducer-structural/RESULTS.md`, +`summary.json`, `full-lazy/`, and `full-generation-lazy-facts/`. No new Roslyn timing +comparison was run; that remains deferred at the user's request. + +## 13. Expanded type-only qualification coverage + +The same opt-in `structural` route now additionally admits recorded **nongeneric** +named targets/owners in object creation, `typeof`, typed `default`, explicit array +creation, catch declarations, base lists and constraints. Casts are admitted only +when the original operand is already parenthesized. Ordinary generic-argument +leaves can reduce in these positions; generic targets/containing segments remain +retained. This does not add general expression/receiver or attribute reduction. + +Base lists and constraint clauses have independent original-syntax lookup regions +and decision caches: a simple name available in a class header may bind to a nested +class in its body. Constructor/type operation checks supplement declaration checks. + +Conditional target documents reuse the existing header/body eligibility proof, +with an additional conservative veto for **any generic local function in any +supported variant**. A conditional local-function wrapper around a common physical +reference can otherwise change its type-parameter owner while both parses compile. +Detection shares the existing declaration collector; legacy `TryGetRoots` eligibility +is unchanged. Only active original nodes are rewritten; disabled text is byte- +identical. This still uses the fixed-project-flags contract, not arbitrary future +DEBUG/HAS flags or all possible framework combinations. + +`CallerArgumentExpression` observes source spelling, not just type/overload identity. +The visitor conservatively retains invocation, argument, conditional/indexed access, +initializer, interpolation and collection-expression subtrees before cache reuse. +Finally-restored traversal state avoids per-name ancestor discovery or another call- +binding pass. This intentionally leaves safe-looking names qualified even when the +particular callee does not capture text. Attribute subtrees remain excluded. The +qualified analysis draft and existing member-removal/final serialization boundary +remain unchanged; there is no new full-tree rewrite or source-string edit chain. + +### Actual AppService coverage and performance + +Fresh ABBA compared the **frozen initial structural** payload with expanded +structural, both env `structural` / Roslyn 5.9, with identical saved AppService +input and other generator layers. Baseline outputs replay the frozen initial +outputs byte-for-byte. No new TypeSpec compilation or fresh Roslyn timing. + +| Mean, two processes each | Initial structural | Expanded structural | Change | +|---|---:|---:|---:| +| Elapsed | 66.718s | 72.374s | +8.48% | +| Managed allocation | 13,109,776,020 B | 13,691,010,948 B | +4.43% | +| Peak working set | 1,578,563,584 B | 1,639,206,912 B | +3.84% | +| Generated bytes | 24,358,750 | 23,306,277 | −4.32% | + +This measures added coverage at added cost, **not a speedup** or per-family causal +cost isolation. Do not compare 72.374s directly with earlier 73/74s or historical +Roslyn timings: use this paired baseline and its two-sample limitation. + +Remaining type-name qualification events versus saved Roslyn output fall +**69,509 → 34,749 (−50.01%)**. Raw `global::` occurrences fall **118,130 → 81,060**. +Static receiver 25,478 / cref 9,249 / attribute qualification 5,742 / nameof 2,399 +are unchanged. Total syntax events fall 165,725 → 130,965; zero parse errors or +unclassified residuals. These are syntactic accounting events, not distinct types +or semantic-equivalence proof. The initial/expanded comparison emits 41,220 forward +and 4,150 reverse type-qualification normalization events; generic outer/argument +normalization can produce canceling events, not literal newly-qualified source. + +Common remaining spellings include generic `IDictionary`, +`ChangeTrackingDictionary`, `ChangeTrackingList`, +`Azure.Response` and `ValueTask`. These are diagnostic examples, +not an exact attribution of every remaining occurrence to an eligibility reason. + +### Final validation and receipts + +**307 focused / 3,251 full core / 2,509 client** checks passed. Official unfiltered +generation passed; **956 Spector passed / 10 skipped**. Sample builds and launches +its host but discovers zero tests (not runtime assertion coverage). Initial and +expanded AppService SDKs independently passed forced Rebuild/native compiler +invocation and configured ApiCompat against 1.5.0 for netstandard2.0/net8.0/net10.0, +with zero warnings/errors. Test-project 2,186 / SDK 2,677 files were hash-restored; +production source matches the measured build. A harness-only wrapper-reference +failure occurred after the full suites but before regeneration; corrected and +remaining gates resumed without rerunning unchanged suites. + +Detailed evidence: `.pi/benchmarks/provider-reducer-structural-qualified/RESULTS.md`, +`summary.json`, `full/`, `full-generation/`. The earlier sections remain the initial +baseline history. No commit/push/default rollout or further feature expansion. + +## 14. Namespace experiment A — accepted checkpoint + +Inventory A–F is recorded in +`.pi/benchmarks/provider-reducer-structural-qualified/MISSING-NAMESPACE-LOGIC.md`. +A alone generalizes the **same namespace qualifier operation** over terminal named- +type syntax; B–F and other cleanup families remain untouched. + +Exact recorded identifier-prefix spans now match the writer contract before type +arguments. Path/arity/canonical-definition checks replace the nongeneric-only +terminal restriction. The original constructed symbol proves lexical type-parameter/ +nullable identity; error/unbound argument graphs are retained. Qualifier plans are +syntax-free, cached by constructed symbol with IncludeNullability, and render with +the current occurrence's terminal token and visited arguments. `Evaluate` and +`Choose` share the same candidate-selection engine. Nongeneric fast behavior remains +an internal materialization optimization, not another semantic rule or visitor. +Children and parent compose during the existing traversal; outer retention does +not discard child changes. Closed-type aliases consuming the terminal/argument +list are not selected by this prefix-only operation. + +Generic-containing owners remain a disclosed boundary. Existing attribute, +source-observability, role, trivia and conditional guards remain intact. There is +one rewrite traversal, but per-name trivia and constructed-type-graph checks can +still revisit overlapping substructure; no separate optimization was bundled in. + +Fresh actual saved-input AppService ABBA, frozen section-13 checkpoint vs A, both +`structural` / Roslyn5.9, two processes each: + +| Mean | Checkpoint | A | Change | +|---|---:|---:|---:| +| Elapsed | 73.544s | 74.705s | +1.58% | +| Allocation | 13,704,702,396 B | 13,908,459,432 B | +1.49% | +| Peak working set | 1,629,364,224 B | 1,646,417,920 B | +1.05% | +| Generated bytes | 23,306,277 | 22,916,090 | −1.67% | + +Remaining type-name qualification versus saved Roslyn: **34,749 → 21,280**, a +reduction of **13,469 (38.76%)**. Raw `global::`: **81,060 → 67,591**. Every other +syntax category is unchanged; checkpoint/A differs only by 13,469 type-name events +in 2,014 files. Both comparisons have zero parse errors/unclassified residuals. +Baseline replay matches the frozen checkpoint byte-for-byte across 2,301 files. +These are observed two-sample means and syntax accounting, not proof of statistical +neutrality, semantic equivalence or a same-output speed comparison. No fresh Roslyn +timing/TypeSpec compilation or comparison to earlier absolute timings is implied. + +**331 focused / 3,275 core / 2,509 client** passed; scoped and official unfiltered +generation passed; **956 Spector / 10 skipped**. Sample builds but discovers zero +tests. Both outputs independently passed netstandard2.0/net8.0/net10.0 forced +Rebuild/native csc and ApiCompat1.5.0, zero warnings/errors. 2,186 test-project / +2,677 SDK files hash-restored; exact production source matches the measured build. + +Receipts: `.pi/benchmarks/provider-reducer-namespace-a/RESULTS.md`, `summary.json`, +source/payload manifests, TRX, `full/`, `full-generation/`. **User accepted A and +authorized B**, using A's exact measured payload as reference. No commit/push/default +rollout or automatic promotion of B is authorized. + +## 15. Namespace experiment B — accepted checkpoint + +B changes only the reducer's type-role adapter. The custom terminal whitelist is +replaced with PUBLIC compiler `SyntaxFacts.IsInTypeOnlyContext` (available in4.8 +and measured5.9). Classify the ORIGINAL named slot; normalize complete wrappers +only to apply policy. Calling the classifier on a complete array/stackalloc wrapper +would let GetStandaloneExpression promote it into a value expression, losing its +role. No duplicate array/stackalloc type-position switch is required. + +Wrapper normalization includes ref/scoped, tuple/function-pointer type slots and +alias-qualified heads. Explicit-interface headers establish independent facts/ +decision regions: method type parameters can capture a name in the return/body +but are not in scope in the interface-specifier portion. The same namespace +selector, prefix/identity checks, caches and existing traversal remain unchanged. + +New eligible type slots include operator/conversion returns, ref/tuple/function- +pointer, foreach, as/sizeof/stackalloc, explicit lambda returns, primary-constructor +bases, explicit-interface members and declaration/recursive patterns. No expression, +cast, pattern, lambda or extension-call transformation is added. Existing policies +still retain unprotected casts, bare type/is patterns, cref/incomplete/__refvalue, +typed From/Join, source-observable subtrees and generic-containing segments. + +A review found that missing AliasQualifiedName normalization could hide an enclosing +policy under `global::Box`. Six warm-cache failures were reproduced (cast/query/ +containing-owner/bare-pattern/tuple-wrapped roles), then fixed with the shared alias +climb. All six pass accepted A; two new interface-positive controls fail A. Exact B +source was restored after this comparison. A deliberate oracle control also exposed +missing legacy-is observation; the role oracle now snapshots that binding along +with modern pattern facts, new type slots and implemented interface members. + +Fresh actual saved-input AppService ABBA, accepted A vs B, both `structural` / +Roslyn5.9, two processes each: + +| Mean | Accepted A | B | Change | +|---|---:|---:|---:| +| Elapsed | 74.351s | 74.620s | +0.36% | +| Allocation | 13,744,524,268 B | 13,852,904,588 B | +0.79% | +| Peak working set | 1,641,897,984 B | 1,636,218,880 B | −0.35% | +| Generated bytes | 22,916,090 | 22,569,018 | −1.51% | + +Remaining type-name qualification: **21,280 → 12,917 (−8,363 / −39.30%)**. +Checkpoint/B differs ONLY by 8,363 type-name events in951files; every other category +is unchanged. Raw `global::`67,591→59,228; total syntax117,496→109,133. Both +comparisons zero parse errors/unclassified residuals; baseline replay byte-identical +to accepted A across2,301files. Two-sample means do not establish significance or +statistical neutrality. No fresh Roslyn timing/TypeSpec compilation, per-role +causal cost isolation or historical-time substitution is implied. + +**358 focused / 3,302 core / 2,509 client** passed; official scoped/unfiltered +generation passed (full652.45s), **956 Spector /10skipped**, Sample builds/host zero +tests. A and B SDKs independently passed three-TFM Rebuild/native csc/ApiCompat1.5.0, +zero warnings/errors. 2,186test/2,677SDKfiles hash-restored; production matches final +full-suite/measured source. Same fixed-project-flags and other retained boundaries. + +Receipts: `.pi/benchmarks/provider-reducer-namespace-b/RESULTS.md`, `summary.json`, +TRX/logs/source/payload manifests, `full/`, `full-generation/`. +**User accepted B and authorized C**, using B's exact measured payload as the +reference. No commit/push/default rollout or automatic C promotion is authorized. + +## 16. Namespace experiment C — accepted checkpoint + +C replaces blanket normal-invocation/recognized constructor-argument exclusion +with lazy ORIGINAL-call source-observability facts. A shared method-definition +metadata summary proves absence of CallerArgumentExpression without GetOperation; +CAE-bearing calls inspect actual omitted DefaultValue/parameter-name argument maps. +Reduced extension receiver Value.Syntax is protected, explicit capture-text overrides +and unrelated uncaptured arguments can reduce. All enclosing call frames participate +before namespace caches, preserving nested captured source. Unknown/dynamic/missing/ +expanded mappings retain qualification. No extra body visitor or operation-tree scan. + +Hard source contexts and conditional TARGET call/argument subtrees remain excluded: +header/type-only invariance does not prove alternate receiver-local types bind the +same call/CAE contract. New C prefix edits cannot remove physical line breaks and +shift CallerLineNumber. Existing namespace selector, binding/provenance/role/trivia/ +configuration proofs and A's terminal/argument-preserving rendering remain reused. +Explicit method type-argument namespace spelling can reduce; no arguments are inferred +or removed. Other cleanup families remain unchanged; D–F are not implemented here. + +Fresh actual saved-input AppService ABBA, accepted B→C, bothstructural/Roslyn5.9, +two processes each, immutable baseline replay matches all2,301files: + +| Mean | Accepted B | C | Change | +|---|---:|---:|---:| +| Elapsed | 74.746s | 76.618s | +2.50% | +| Allocation | 13,895,511,152 B | 13,927,340,632 B | +0.23% | +| Peak working set | 1,640,622,080 B | 1,662,398,464 B | +1.33% | +| Generated bytes | 22,569,018 | 22,445,322 | −0.55% | + +Remaining type-name gap **12,917→9,880 (−3,037/−23.51%)**; rawglobal59,228→56,188. +DirectB/C comparison has3,040type-name events only in734files, no reverse/other +category changes. Decomposition against savedRoslyn falls by3,037, not necessarily +additive across different baselines. Both comparisons zero parse/unclassified +residuals. Two-sample means are not significance/statistical-neutrality claims; +more work at more cost, no fresh Roslyn timing/TypeSpec compilation or historical- +time substitution. No per-helper causal cost isolation. + +**394focused /3,338core /2,509client** passed; official scoped/unfiltered generation +(full654.44s),956Spector10skip;Sample builds/hostzero tests. Both outputs independent +native3TFMRebuild/ApiCompat1.5.0PASS0warnings/errors;2,186test/2,677SDKhashrestored; +exact production source matches full-suite/measured build. Captured/unknown/call- +state/named/partial/delegate/constructor/extension/lambda/conditional/line-number +controls plus implicit-argument negative oracles preserved. Static review found no +confirmed unsafe rewrite; advisory review is separate from executed gate evidence. + +Receipts `.pi/benchmarks/provider-reducer-namespace-c/RESULTS.md`,summary.json,TRX, +source/binary/output manifests,full/,full-generation/. **User accepted C and +authorized D**, using this exact measured payload; no commit/push/default rollout +or automatic D promotion is authorized. + +## 17. D opportunity screen — no AppService document-veto targets + +Before introducing additional conditional scope proof, a read-only inventory of +accepted C's saved AppService output used the existing ProviderConditionalSyntax +and supported parsed variants. Of2,301generatedfiles,596containconditionalbranches. +All596pass current header/body eligibility AND type-only document eligibility; +none has a generic local function in any supported variant. No generated target +document in this corpus is blocked by the D document/scope-veto refinement. + +Recommendation: defer this part of D rather than add unmeasurable proof machinery. +This is a saved-output syntax opportunity screen, NOT a new generation/benchmark +or universal claim about other SDK/custom/shared sources. No production code was +changed; accepted C source hashes remain exact. The explicit NUnit diagnostic was +archived under `.pi/benchmarks/provider-reducer-namespace-d/` and removed from normal +test discovery. No unnecessary full suites/regeneration/timing were rerun. + +Conditional CALL capture protection and inactive-branch text remain separate +restrictions; do not relax either based on this document-eligibility result. +Receipts: `namespace-d/conditional-inventory.json`, `inventory.log`, archived test, +`RESULTS.md`. **User deferred D, not dropped: revisit during a future authorized +full regeneration over broader SDK/custom/shared sources.** User subsequently +authorized E separately; no full regeneration is implied. + +## 18. E feasibility screen — one inactive type prefix, already at Roslyn parity + +User authorized E against accepted C. A read-only saved AppService syntax inventory +found2inactive `global::` occurrences across628disabled-trivia leaves in596conditional +files (2,301files total). Only1is grammar type-only and survives a conservative +source-ancestor/nongeneric screen: BinaryData construction in GetUtf8Bytes's NET9 +branch. The other occurrence participates in a static receiver and is not in scope. +The full constructor return line is byte-identical in saved Roslyn and C, so the +one screened site does not contribute to the current9,880first-category gap. + +Independent architecture review supports only a bounded initial slice with explicit +all-variant scope/lookup bridging, writer provenance, source/caching safeguards and +one coordinated structured disabled-trivia emission. Alternate nodes are not bound +occurrences in the original semantic model; generic construction cannot be guessed +from metadata. No blind alternate-root substitution or chained source rewrites. + +Recommend defer E production architecture for a broader corpus rather than add this +machinery for one site already at Roslyn parity. No production change/new generation/ +timing/suite/SDK mutation occurred. Exact accepted-C Core/src hashes remain unchanged. +These counts are syntax-screen overapproximations, not validated reducible sites. +The diagnostic's first build used an absent assembly path; corrected local references, +retry exited0. Receipts `.pi/benchmarks/provider-reducer-namespace-e/{RESULTS.md, +ARCHITECTURE.md,summary.json,inactive-inventory.json,inventory-retry.log}`. +**User deferred E, retaining its design/inventory for a broader corpus, and +separately authorized F.** No full regeneration is implied. + +## 19. F boundary-comment preservation — accepted checkpoint + +F permits only caller-owned boundary comment trivia through the shared descriptor. +Describe defaults conservative; one production opt-in allows four ordinary/XML +comment kinds wholly outside name.Span, because BOTH existing rewrite paths restore +that original occurrence's complete outer trivia. Internal significant trivia, +directives and diagnostics remain vetoes. Binding/provenance/path/arity/role/source +checks still precede caches; generic plans render current arguments and nongeneric +caches strip boundaries. Two production files only, no new visitor or cleanup family. + +Ownership is per recorded reference. An argument comment internal to an outer name +still retains that outer qualifier, while an independently recorded child can shorten +if the same comment is its boundary trivia and is restored exactly. No blanket +permission to delete/move argument comments. Default selector callers remain strict. + +Fresh matched actual AppService saved-input ABBA C→F, bothstructural/Roslyn5.9: + +| Mean | Accepted C | F | Change | +|---|---:|---:|---:| +| Elapsed | 86.335s | 84.585s | −2.03% observed | +| Allocation | 13,937,183,976 B | 13,934,880,536 B | −0.017% | +| Peak | 1,662,412,800 B | 1,666,248,704 B | +0.23% | +| Output bytes | 22,445,322 | 22,362,013 | −0.37% | + +Remaining type-name gap9,880→**7,550 (−2,330/−23.58%)**; directC/F2,330type-name +normalization events ONLY,859changed/1,442identicalfiles. Rawglobal56,188→53,858; +all other saved-Roslyn categories unchanged, zero parse/unclassified residuals. +Two-process means are not confidence/significance or proven causal speedup; more +coverage is not a same-output algorithm comparison. No fresh TypeSpec/Roslyn timing +or historical absolute-time comparison. C replay byte-matches accepted2,301files. + +**411focused /3,355core /2,509client PASS**, official scoped/unfiltered generation +(full718.36s),956Spector10skip, Samplezero discoveredtests. Both outputs independent +native3TFMRebuild/ApiCompat1.5.0PASS0warnings/errors. Exact2,186test/2,677SDK restored; +production hashes match suites/measuredbuild. No existing regression expectation +changed; corrected added child fixture provesCretention/Fshortening after actual +ownership assertion. Supplementary2,301file/fiveconfiguration check (11,505paired +parses) confirms byte-exact significant trivia and exact line-ending sequences. +Design/final static reviews found no concrete blocker. + +Receipts `.pi/benchmarks/provider-reducer-namespace-f/{RESULTS.md,summary.json, +full-generation/,final-focused-retry.log,child-fixture-base.log,trivia-inventory.json}`. +**User accepted F as the current checkpoint**, gap7,550; source, measured binaries +and output files reverified in namespace-f/accepted-checkpoint.json. No commit/push +or default rollout; D/E remain deferred and retained. + +## 20. Static-receiver qualification — accepted checkpoint + +Subsequent user-approved experiment shortens only a recorded TYPE receiver and +preserves its complete member/call/arguments. Same existing traversal; shared +canonical descriptor/selector, explicit expression mode and actual-position lookup +facts rather than declaration-wide decisions. Original and one selected speculative +receiver must bind to the same named construction/nullability, not equal value type. +Supported static member/callee unchanged; unknown/dynamic/conditional/source-sensitive +contexts remain conservative. Real expression-node rendering, no text-edit chain or +whole-call speculative model. Raw contextual root exposure retains qualification; +escaped terminal tokens remain exact, including compatibility with C#14 field syntax. + +Fresh matched AppService ABBA acceptedF→candidate, bothstructural/Roslyn5.9: +static25,478→**11,733 (−13,745/−53.95%)**; type7,550 and all other saved-Roslyn +category counts unchanged. Direct14,048static-only events/911changed1,390samefiles; +rawglobal53,858→39,810. Different-baseline normalization decomposition is not additive; +no distinct-symbol or exact303site claim. Both comparisons zero parse/unclassified. + +Mean elapsed85.034s→88.409s(+3.97%), allocation13,801,459,672→14,178,262,252B(+2.73%), +peak1,663,766,528→1,676,615,680B(+0.77%),bytes22,362,013→21,996,187(−1.64%). +Two processes each, not significance/same-output speed comparison/freshRoslyn timing. +Coverage has a measurable cost, not a speedup over F's smaller workload. + +**444focused /3,388core /2,509client PASS**, official scoped/unfiltered736.32s, +956Spector10skip, Samplezero discoveredtests. Both outputs independent native3TFM +Rebuild/ApiCompat1.5.0PASS0warnings/errors;2,186test/2,677SDK restored exactly. AcceptedF +byte replay/source/suite/measured identities verified. Actual-rewriter5.9 field +before/after and2,301file/fiveconfig trivia/endings checks pass (11,505pairedparses). + +Final review found a genuine field parser hole despite correct constructed-AST +binding. Old pipeline stopped before unfiltered/measurement, faithful valid-original +repro CS1061 recorded, general contextual-root guard fixed it, independent closure +review and fresh final gates passed. Interrupted receipts preserved. Fixture repairs +(metadata availability/forced alias/original await validity) are separately disclosed; +only2approvedstatic-only retention expectations changed after exactF8FAIL evidence. + +Recommendkeepif+3.97%time/+2.73%allocation acceptablefor53.95%staticgapreduction. +**User accepted static qualification as the current checkpoint**, source/binary/output +identities reverified in accepted-checkpoint.json; user separately authorized the +expression-parentheses experiment. No this, commit/push/default rollout. Receipts `.pi/benchmarks/provider-reducer-static-receiver/RESULTS.md` +and summary/source/binary/output/parser/trivia/restoration artifacts. + +## 21. Non-reassociating expression parentheses — accepted checkpoint + +User authorized parentheses after accepting static qualification. Same rewrite +visitor handles atomic direct chains, finalpayload/originalparent grammar, exact +all-delimiter trivia transfer and per-delimiter source guards. Conservative syntax/ +association only; cast operands, receivers/targets, defaults/casts/unknown payloads, +optional defaults/patterns/interpolation/conditional documents retain. No semantic +reassociation, this or other cleanup family. Shared legacy precedence/token facts +are unchanged; legacy admission/text planner remains separate. Lazy original-list +relational facts avoid scans without candidates; other local trivia/subtree queries +are disclosed. Syntax-only generated cleanup no longer requires unrelated type +records, but empty records never authorize name changes and all configuration/ +custom/header/diagnostic gates remain. + +Fresh matched accepted-static/candidate saved AppService ABBA, bothstructural5.9: +Parentheses.Other23,414→**11,997 (−11,417/−48.76%)**, direct11,417samecategoryONLY, +941changed1,360samefiles. All other category counts EXACTunchanged; total90,021→78,604; +zero parse/unclassified. Rawglobal39,810unchanged;22,834bytes fewer. + +Mean elapsed88.716s→88.797s(+0.09%observed),allocation14,047,626,852→14,100,536,684B +(+0.38%),peak1,682,599,936→1,692,583,936B(+0.59%). Two-process local means are not +significance or proof of neutrality; different work is not a same-output comparison. +No historical absolute-time/freshRoslyn/TypeSpec timing substitution. + +**495focused /3,439core /2,509client PASS**, officialscoped/unfiltered739.81s, +956Spector10skip, Samplezero discoveredtests. Both independentnative3TFMRebuild/ +ApiCompat1.5.0PASS0warnings/errors; exact2,186test/2,677SDK restored. Baseline replay/ +source/measurement identities verified. Actual-rewriter5.9 field+parentheses composed +output parses/compiles safely. All2,301files/fiveconfigurations (11,505pairedparses) +preserve significant trivia byte-exactly and exact line-ending sequences. Independent design/implementation/closure reviews +found no final blocker, oracle weakening or missing mandatory control. + +Oracle integrity corrections independently reproduced: wholeMethodoperations replace +unmapped arrow wrapper snapshots; normalize only explicitArgument.IsImplicit flag, +retain conversions/kinds/types/constants/member/operator/callee/grouping. Compiler +captures samex+yfromCapture((x+y))/Capture(x+y), so source-differencecontrol corrected +toequivalenttypeofspellings with genuinely differentcapture. Production capture +admission not widened. Sole old paren-retention assertion changed after exact-base +positive FAIL; all other accepted assertions preserved. + +Receipts `.pi/benchmarks/provider-reducer-parentheses/RESULTS.md` and summary/source/ +binary/output/operation/parser/restoration artifacts. **User accepted parentheses**, +identities reverified in accepted-checkpoint.json, and separately authorized bounded +this fields/properties. No methods/events expansion/commit/push/default rollout. +D/E retained. + +## 22. Bounded instance `this.` data members — accepted + +Same visitor/original-model proof for instance fields/properties only. Actual-position +speculation of a fresh detached identifier must match the exact original member +including nullability. Shared syntax parser facts, source/line/trivia/hidden/dynamic +checks; invariant member/type metadata caches, never lexical binding decisions. + +Two independently reproduced defects corrected without weakening expectations: +attached member.Name speculation can promote back to this.Member and falsely accept +shadows; same inherited field identity can nevertheless newly emit CS9179 for a +primary-constructor parameter, breaking warnings-as-errors. Fresh root ownership and +original source-type primary-parameter metadata guard these separately. Delegates' +invocation targets remain qualified through parentheses. Methods/events excluded. + +Fresh matched accepted-parentheses/candidate structural5.9 ABBA, two processes each: +ThisReceiver19,647→3,834 (−15,813/−80.49%); direct15,813same-category-only events, +548changed/1,753same files. Every other category exact unchanged; zero parse or +unclassified events. Total78,604→62,791; rawglobal39,810 unchanged;79,065bytes fewer. +Elapsed mean90.617700→91.331594s (+0.787808%observed), allocation+0.140289%, +peak+0.018153%. Two local samples do not establish significance/neutrality; different +coverage is not a same-output algorithm comparison. No historical timing reused. + +573focused/3,517core/2,509client PASS; officialunfiltered738.19s;956Spector10skip; +Samplezero discoveredtests. Both native3TFMRebuild/ApiCompat1.5.0 PASS0warnings/errors. +Exact2,186test/2,677SDK restoration,142-file payload and2,301-output replay/source +identities verified. Actual5.9preview all9serialized/parser/member/CS9179-error cases +PASS; independent implementation/closure reviews no remaining concrete blocker. +Supplementary all2,301files/fiveconfigurations (11,505pairedparses) PASS after timing: +significant trivia byte-exact, line-ending sequences exact; not semantic binding proof. + +Receipts `.pi/benchmarks/provider-reducer-this-receiver/RESULTS.md`, summary.json and +source/binary/output/parser/restoration artifacts. The pipeline wrapper exit1 was a +summarizer focused-log filename typo AFTER successful substantive gates; corrected +report assertions pass, original error retained. **User accepted**; source, payload +and all2,301 outputs reverified in accepted-checkpoint.json. No next family, +commit/push/default rollout/full SDK regeneration authorized. D/E retained. + +## 23. Conditional syntax-only parentheses — accepted + +One production file, existing visitor and cached supported-configuration roots. +Only common active-source grammar-preserving parentheses outside enclosing call/ +source-protected contexts. Every variant proves exact original span/subtree/direct +parent role, ancestor exclusions, grammar against FINAL visited payload, trivia/ +visibility and variant token boundaries. Original output only; no root installation +or per-variant semantic models. Conditional call/static/this permissions unchanged. +No new cast/default/unary/receiver grammar, reassociation or inactive branch edits. + +Fresh acceptedThis/candidate matched structural5.9 ABBA,2processeseach: +Parentheses.Other11,997→2,443 (−9,554/−79.6366%); direct9,554samecategoryONLY, +596changed/1,705samefiles. Allothercategoriesexactunchanged;0parse/unclassified. +Totalgap62,791→53,237;rawglobal39,810unchanged;19,108bytes fewer. +Mean87.410772→88.051617s (+0.733142%observed), allocation14,276,517,028→ +14,421,715,464B (+1.017044%),peak1,698,285,568→1,695,875,072B (−0.141937%). +Two-process local means are not significance/neutrality proof; changedcoverage is +not a same-output algorithm comparison. No freshTSP/historicaltiming substitution. + +606focused/3,550core/2,509client PASS; officialunfiltered715.08s;956Spector10skip; +Samplezero discoveredtests. Independentbothnative3TFMRebuild/ApiCompatGA1.5.0 +PASS0warnings/errors; exact2,186test/2,677SDK restoration. All142payloadfiles and +2,301outputs/sourceidentities replayexact; baselineacceptedThis matches. Actual5.9 +27rewrites/135pairedconfigurationcompiles+operationgraphs PASS; all2,301files×5configs +11,505pairedsignificant-trivia/line-ending checks PASS aftertiming. Design/actual-code +source reviews no remaining blocker; not build/test substitutes. + +One old conditional common-arrow retention policy assertion updated ONLY after +samefixture desiredpositive1FAIL on exactacceptedThis baseline; disabled/custom/ +header/source/inactive/target safeguards retained. Finalpayloadnamespace children, +caller-source/lines, hidden/order, noncanonicalflags, branchonlyoracle corruption, +otherheader/genericlocals/fragment gates covered. + +Evidence `.pi/benchmarks/provider-reducer-conditional-parentheses/RESULTS.md`, summary +and manifests. **User accepted**; source, payload and all2,301outputs reverified in +accepted-checkpoint.json. No newfamily/commit/push/defaultrollout/fullSDKregen +implicitly authorized. D/E retained as separate deferrals. + +## 24. Whole-type XML cref — accepted + +Three production files, existing traversal/finalserialization, structured-doc opt-in. +Writer-proven canonical whole-type nongeneric named crefs/owners in eligible +unconditionaldocs only. Exact original named target and per-occurrence source/ +provenance/trivia/line/visibility checks; shared candidate plans with qualified +fallback. Parse actual prospective XML, detach complete parser-selected CrefSyntax, +require original-position exact target including nullability/no candidates. No +binding-decision cache, alternateDocroot/stringrewritechain/per-edit compilation. + +Cref-specific alias grammar: alias::T is XMLIDtext, notCref; alias.T is parsed and +bound. Ordinary C# alias::T/global:: unchanged. Factory method/type collisions, +terminal escaping, unsupported/unrecorded/member/generic/conditional/inactive/array/ +nullable boundaries retained. No accepted old expectations changed;35newcontrols. + +Fresh acceptedconditionalParenth/candidate matched structural5.9 ABBA,2processeseach: +XmlCref9,249→3,549 (−5,700/−61.6283%); direct5,700XmlCrefONLY;895changed1,406samefiles. +Everyothercategoryexactunchanged;0parse/unclassified/reverse. Total53,237→47,537; +rawglobal39,810→34,110;236,305bytes fewer. Mean82.936580→84.302036s(+1.646386% +observed),allocation14,343,625,188→14,616,974,548B(+1.905720%),peak1,692,815,360→ +1,746,944,000B(+3.197551%). Two-local-processmeans not confidence/significance/ +neutrality/causality proof; differentcoverage notsame-output comparison. + +641focused/3,585core/2,509clientPASS,956Spector10skip;officialunfilteredLOCAL680.58s; +Samplezero discoveredtests. Bothnative3TFMRebuild/ApiCompatGA1.5.0PASS0warnings/errors. +Actual5.9preview14wholeCrefserialization/target/outside-source/capturegraphcasesPASS. +All2,301files×5configs11,505pairedchecksAFTERtiming: ONLYactualCrefspansnormalized; +ALLsurroundingcode/XML/trivia andwhole-filelineends exact. Thisisnotwhole-document +triviabyteidentity/semanticproof. Both142payloads/all2,301outputs/sourceidentities +replayed; exact2,186test/2,677SDKsourcesandpropsrestored, SDKtrackedtreeclean. +Independentdesign/code/aliasclosure source reviews no remaining concrete blocker. + +Fixture/oraclecorrections separately documented (writerimports/costties/missingrefs, +malformedarraycref/customformatter/cref-declaredtypeparamidentity). Genuine alias +coveragegap reproduced with valid dottedpositiveFAIL before adapter; unchanged +wholebinding/sourceproofs. Receipts `.pi/benchmarks/provider-reducer-cref/RESULTS.md`, +summary and manifests. **User accepted**; source, payload and all2,301outputs +reverified in accepted-checkpoint.json. No newfamily/commit/push/defaultrollout/ +fullSDKregen authorized. D/E retained. + +## 25. C1 attribute-name qualification/suffix — accepted + +Two production files, existing visitor/original models/finalserialization. Only +canonicalrecordednongeneric attribute NAMES inunconditionalgeneratedcontexts. +Arguments remainexcludedandoriginalArgumentList reused. Wholepublicspeculative +attribute model with exactconstructor/type/nullability andfail-closedoperation +contract(orderedchildren/args/maps/conversions/implicitconstants/namedmembers). +Actualnamegrammarroundtrip; suffixescaping/Unicode/keyword checks; peroccurrence +source/provenance/line/visibility/CAEproof, metadata-onlyeligibilitycache. Unknown +shapesretain. No argument/conditional/generic/inactive/otherfamily expansion. + +Fresh acceptedCref/candidate matchedstructural5.9 ABBA,2processeseach: +Qualification.Attribute5,742→817(−4,925/−85.7715%);AttributeSuffix4,926→1 +(−4,925/−99.9797%). SAME4,925actualattributeNAMES,not9,850independentedits; +1067changed1234samefiles. AllothercategoriesEXACTunchanged,0parse/unclassified/ +reverse. Total47,537→37,687;rawglobal34,110→29,185;233,634bytes fewer. +Mean86.330179→86.858275s(+0.611716%observed),allocation14,626,430,672→ +14,800,799,144B(+1.192146%),peak1,756,737,536→1,744,484,352B(−0.697497%). +Localtwo-processmeans notconfidence/significance/neutrality/causality orsame-output +algorithmcomparison; no historical/freshTSP/freshRoslyn timing substitution. + +685focused/3,629core/2,509clientPASS,956Spector10skip;officialunfilteredLOCAL705.21s; +Samplezero discoveredtests. Bothnative3TFMRebuild/ApiCompatGA1.5.0PASS0warnings/errors. +Actual5.9preview18serializedwholeAttribute/typedmetadata/outsideNAME/capturegraph +casesPASS. AFTERtiming2,301files×5configs11,505pairedchecks normalizeONLYNAMEspans: +ALLarguments/code/XML/triviaoutside andwholefilelineends exact.24625changednamepairs +=4925×5; lexicalchecknotsemanticproof/whole-docbyteidentity. Both142payloads/all2301 +outputs/sourceidentities replayed;2186test/2677SDKsources/propsrestored,SDKtrackedclean. +Design/actualcodesource reviews no blocker; operationcomparer notgeneraldiagproof. + +OldqualifiedObsoleteassert updatedONLYafterSAMEfixtureexactacceptedCrefdesiredpositive +1FAIL0PASS andguardedrestore;allotherprofile/nameof/static/visit/sourceanchorspreserved. +44newcontrols includecontexts/mappings/escape/capture/callerdefaults/arrays/enums/ +provenance/visibility/header andstrongsamector/samecompilation/defaultmapdifferent +callerconstantcorruptioncontrol. Receipts `.pi/benchmarks/provider-reducer-attributes/RESULTS.md`, +summary/manifests. **User accepted**; source,payload,all2301outputs reverified in +accepted-checkpoint.json. BoundedC2optionaldefault experiment next; no broaderfamily/ +commitpush/defaultrollout/fullSDKregen/D/Ereopening. + +## 26. C2 optional-parameter defaults (accepted) + +Accepted C1 baseline versus bounded direct `default(T) → default` cleanup in the +same structural visitor. One production reducer delta; original parameter symbol +and operand type must match including nullability/construction/type-parameter +identity. Optional/default metadata, supported recursive graph, declaration-owner, +unconditional document, C#7.1+, source-observation, line/trivia/token guards remain. +No whole-call model, framework patch, new traversal or argument/cast expansion. + +Remaining optional typed defaults **2,310 → 0** (−100%); 248 files changed, 2,053 +identical. Only this category changes; every other category count exact. Total +remaining events 37,687 → 35,377; zero parse/unclassified. Output 21,405,241 → +21,361,351 bytes (−43,890); raw `global::` unchanged at 29,185. + +Fresh matched complete saved-input AppService ABBA, two processes each, both +structural/Roslyn5.9: elapsed mean 86.417750 → 87.337550 s (**+1.064365%**), +allocations 14,752,513,116 → 14,729,173,524 B (**−0.158208%**), peak working set +1,738,219,520 → 1,749,604,352 B (**+0.654971%**). Local means only, not significance, +causality, neutrality or same-output algorithm comparison; not fresh TSP/Roslyn timing. + +726 focused/3,670 core/2,509 client passed; 956 Spector passed, 10 skipped. Official +unfiltered LOCAL regeneration 687.521 s; Sample zero discovered tests is not runtime +coverage. Both native3TFM Rebuild/ApiCompat GA1.5.0 passed, zero warnings/errors. +Actual Roslyn5.9/preview 20 serialized parameter/default/reflection/callee/source +cases passed. Null/Missing/DBNull remain distinct. Two anticipated nullable forms +remain typed under exact parameter/operand annotation mismatch; permanent diagnostic +and metadata controls retained, no production normalization or old accepted assertion +change. Independent design/implementation source reviews found no blocker. + +After timing, 2,301 files × 5 configurations/11,505 paired checks normalize ONLY +actual parameter default-value spans: all outside code/XML/trivia and whole-file +line endings exact, not whole-document byte identity or semantic proof alone. +Closure replay verifies source/payload142/output2301/both runs, exact accepted-C1 +baseline, props/test2,186/SDK2,677 restored. Full receipts: +`.pi/benchmarks/provider-reducer-optional-defaults/RESULTS.md` and `closure-replay.json`. +**User accepted: “keep”.** Fresh source/payload/output replay passed before +`accepted-checkpoint.json` recorded the new optional-defaults candidate baseline. +No next family, default rollout, commit/push, full SDK regeneration or D/E reopening +authorized. + +## 27. C3 cast-type qualification (deferred, not dropped) + +Accepted C2 baseline; two production files, reducer + StructuralCastTypeRules. +Cast-owned postorder proof preserves the final visited operand and cast punctuation. +Outer named heads only, original generic arguments and array/nullable wrappers; +exact complete-type detached rebinding, full cast and bounded ordinary-argument +parser certificates, original source/config/provenance/line/trivia/visibility guards. +No cast removal, reassociation, conditional policy widening or new full-tree pass. +Shared C2 graph body and original ordinary cast guard unchanged; no lexical caches. + +**1,127 actual prefix changes**, 261 changed/2,040 identical files, direct taxonomy +Qualification.TypeName only. Visible remaining type-name gap **7,550 → 7,180** +(−370/−4.900662%); total remaining35,377→35,007. Exact baseline-span overlap explains +757 other prefix edits inside Cast.Default differences already folded into outer +cast/parentheses events; those casts remain. Every other category count unchanged, +zero parse/unclassified. Rawglobal29,185→28,058; bytes21,361,351→21,320,858(−40,493). + +Fresh complete saved-input AppService ABBA, two processes each bothstructural5.9: +elapsed mean89.253692→91.028324s(+1.988301%), allocation14,664,309,488→14,710,036,600B +(+0.311826%), peak1,755,006,976→1,767,397,376B(+0.706003%). Local means only, not +significance/causality/neutrality or same-output algorithm comparison; not fresh TSP +or fresh Roslyn timing. Minor increments not blockers; overall perf review deferred. + +771focused/3,715core/2,509client passed;956Spector passed10skipped. Official unfiltered +LOCAL regeneration691.404s; Samplezero discovered tests not runtimecoverage. Both +native3TFM Rebuild/ApiCompatGA1.5.0 passed0warnings/errors. ActualRoslyn5.9preview25 +serialized type/conversion/operator/callee/argument/source/warning cases passed, +allbefore/afteremitted and7runtimepairs matched. Fivelexicalcorruptioncontrols passed. +Initial/argument-boundary/actual-code source reviews no blockers; source-only. +Oldpolicyassertions updated onlyafterSAMEfixture exactC2 desired-positiveFAIL and +guardedrestore; pattern/nameof/attribute/protected anchors andcache-order preserved. + +Aftertiming2,301×5configs=11,505pairedchecks normalizeONLYouter cast-type named-head +spans: allwrappers/operands/outsidecode/XML/trivia andwholelineends exact; conditional +documentsWHOLEbyteexact.5,635changedheadpairs=1,127×5. Not semanticproof/whole-docbyte +identity. Closure replay confirmsfocused/validation/measured/currentSRC, both142-file +payloads/all2,301outputsbothruns, acceptedC2baseline exact, props/test2,186/SDK2,677 +restored; SDKstatusonlyuntracked.pi. Constructorinitializers/memberreceivers/nested +argumenttypenames/conditionalcasts/unsupportedcontexts remainexcluded. + +Receipts `.pi/benchmarks/provider-reducer-cast-types/RESULTS.md`, coverage-overlap.json, +summary.json andclosure-replay.json. **User deferred C3**: small visible-gap benefit +relative to observed elapsed increment. All owned candidate source/tests archived +under deferred-source/; active production restored exactly to accepted C2, with +unrelated WIP preserved. deferred-checkpoint.json records restoration. No accepted +C3 checkpoint. C4 remaining-parentheses work separately authorized; no commit/push/ +defaultrollout/fullSDKregen or D/E reopening. + +## 28. C4 outer-cast grouping (accepted) + +Accepted C2 baseline; C3 remains deferred/absent. Production delta is cast payload +admission plus cast-only ORIGINAL delimiter visibility guard. Existing parent roles, +precedence, atomic chains/final visited payload, source/caller/trivia/token/config +proofs and cast-operand barriers unchanged. No cast removal/type-name change, +reassociation, new speculative model or extra full-tree rewrite pass. + +Parentheses.Other **2,443 → 665** (−1,778/−72.779370%); total remaining35,377→33,599. +Direct1,778group-pair removals,541changed/1,760identical files. Allothercategorycounts +exact,zero parse/unclassified. Rawglobal29,185 unchanged; bytes21,361,351→21,357,795 +(−3,556). Sixof1,784screenedcastgroups remain in non-admitted conditional-expression +roles;618coalesce/41other events remain separate. Countsnotprevalidation safe edits. + +Freshcomplete saved-input AppService ABBA,twoeachbothstructural5.9: meanelapsed +91.620686→91.121436s(−0.544910%); allocations14,687,955,204→14,693,338,716B(+0.036653%); +peak1,762,723,840→1,754,220,544B(−0.482395%). Localmeansnot significance/causality/ +neutrality/same-output algorithm comparison; not fresh TSP/Roslyn timing. Overall +performance review remainsdeferred; minorcandidate increments not blockers. + +768focused/3,712core/2,509client passed;956Spector passed10skipped. Official unfiltered +LOCAL regeneration718.844s passed; Samplezero discoveredtests not runtimecoverage. +Bothnative3TFM Rebuild/GA1.5.0ApiCompat passed0warnings/errors. Correctedactual5.9: +81rewrites/405pairedconfigs,allpairs emitted,18runtimeEntryrowsmatch. Sevenlexical +corruptioncontrols passed. All2,301outputs×5configs=11,505posttimingdelimiter/source/ +ASTchecks passed;8,890removedpairs=1,778×5. Onlyvisibleboundedoutercastgroup delimiter +spans normalized, allothercode/XML/trivia/castpunctuation/operandbarriers/inactive+ +wholelineends exact; normalizedASTassociation/type/operand shapes equivalent. Not +standalone semanticproof/whole-docbyteidentity or arbitraryfuturedefine proof. + +Initial source review found newly reachable unconditional hidden-source policy gap; +independentC2-vs-C4binarycounterexample and3permanentFAIL-before/pass-after controls +precededcast-onlyfix. Correctedreviewclosedblocker; broaderoldnoncasthidden-policy +behaviorseparatelytrackednotchanged. OldexpectationsupdatedonlyafterSAMEexactC2 +fixture desired-positiveFAIL/guardedrestore; inneroperand/QName/op/trivia/config +anchors preserved. Initialone-lineprototypeevidencekeptseparatefromfinalcandidate. + +Closure replay confirmsfocused/validation/measured/currentSRC,both142-filepayloads/ +all2,301outputsbothruns,acceptedC2exactbaseline,props/test2,186/SDK2,677 restored; +SDKstatusonlyuntracked.pi. Originaltaskregistryunavailableonresumption; existing +process monitored withoutrestart. Finalstageexitrecords/logs/summary/native/API and +closureevidence—notmonitoralone—establishcompletion. Receipts: +`.pi/benchmarks/provider-reducer-parentheses-c4/RESULTS.md`,summary.json andclosure- +replay.json. **User accepted: “keep it and proceed to next”.** accepted-checkpoint.json +records fresh owned-source/payload/output/service/test rechecks. Unrelated SDK +OperationTemplates test WIP observed after finalclosure is recorded separately in +acceptance-replay.json and preserved untouched. New baseline C4; C5 conditional +static type-receiver qualification separately authorized. No commit/push/default +rollout/fullSDKregen/D/Ereopening. C3 staysdeferred. + +## 29. C5 conditional static TYPE-receiver qualification — accepted + +**All required evaluation/summary/closure gates passed. User accepted: “keep. how many are left”.** +Fresh acceptance-time identity/restoration recheck passed; `accepted-checkpoint.json` +records C5 as the latest frozen baseline. No next-family implementation permission. + +C5 adds a dedicated conditional path in the existing visitor under unchanged global +header/type-only/generic-local guards. The complete outer method/constructor must +have identical active AST/raw-token positions across cached configurations. Unit +syntax invariance is cached, never occurrence binding/lexical acceptance. Original +models, exact constructed receiver/member/callee/nullability identity, final detached +speculation, recorded provenance, visibility/token/parser/contextual-field/cast and +all original ancestor explicit-call source observations remain mandatory. Implicit +foreach collection spans remain opaque across lambda ancestry; unrelated loop-body +sites may reduce. Await/deconstruction and other hard source contexts stay bounded. +No global source-flag relaxation, new rewrite pass or per-edit compilation. C4 +parentheses/cast-only visibility rules unchanged; deferred C3 helper absent. + +Fresh complete saved-input AppService generation, both structural/Roslyn5.9, frozen +C4 non-core payload reused; only core DLL/PDB differ: + +| Metric | Accepted C4 | C5 | Observed change | +|---|---:|---:|---:| +| Static-receiver residual events | 11,733 | 6,045 | −5,688 / **−48.47865%** | +| Total residual events | 33,599 | 27,911 | −16.92908% | +| Elapsed mean | 86.513 s | 88.896 s | **+2.75395%** | +| Allocated bytes mean | 14,697,696,748 | 14,961,065,768 | +1.79191% | +| Peak working-set mean | 1,747,759,104 | 1,757,675,520 | +0.56738% | +| Generated bytes | 21,357,795 | 21,123,347 | −234,448 | + +Direct C4→C5: **7,103 prefix edits**, 596 changed/1,705 unchanged files, static-receiver +qualification only. Every other residual category is unchanged; zero parse and +unclassified events. Prefix shortenings are not all eliminated Roslyn residuals: +some shorter relative qualifiers remain different from Roslyn and outer categories +fold inner differences. Raw elapsed ABBA: B85.5624/C89.0289/C88.7624/B87.4639s. +Two independent processes each do not establish significance, causality or runtime +neutrality; changed coverage is not a same-output comparison. Not fresh TypeSpec +compilation or Roslyn timing. Overall performance review remains deferred; C3's +explicit coverage/cost deferral still stands. + +Validation: **812** focused/45 C5 controls; full **3,756** core/**2,509** client; +official unfiltered LOCAL **694.134s**; Spector **956 passed/10 skipped**. Sample +regeneration succeeded, zero tests discovered—not runtime assertion coverage. +Actual5.9/preview consumer: 117 rows (114 valid/3 diagnostic controls), 608 valid +paired-configuration comparisons +16 diagnostic checks; 57 runtime-entry rows match. +Independent actual-source review found no confirmed blocker. Both native Rebuilds +and GA1.5.0 API checks passed netstandard2.0/net8.0/net10.0, **0 warnings/errors**. +Post-accounting proof: 2,301×5=11,505 paired parses /7,103×5=35,515 prefix-pair checks; +outside actual prefixes, member/call/argument/construction spelling, XML/trivia, +inactive text and line endings exact with visible-prefix and normalized AST checks. +Eight lexical corruption controls passed. This is not standalone semantic proof or +whole-file byte identity. + +Accounting initially exposed 40 partial-qualification gaps in 24 files. A separate +C5 diagnostic classifier reproduced the gap, then added a bounded name-only-prefix +rule for matching simple-member receiver roles; original27+new10 self-tests passed. +Independent assessment checked all40/200 configuration cases against Roslyn/C4/C5. +Accepted C4's complete event ledger and the direct C5→C4 ledger remain **byte-exact**; +only those40 categories changed, no spans/texts/configurations or generator outputs. +All original evidence/classifier preserved. This accounting-only change invalidated +no successful generation/native gates; affected accounting/corpus proof was rerun. + +Final closure authenticates reviewed/focused/consumer/validation/measured/current +source identity, both142-file payloads, all2,301 outputs in both runs, exact accepted +C4 fresh output replay, props restored, 2,186 TestProjects/launch and2,677 service +files restored. The common current SDK native/API context is recorded/checked for +both variants separately from immutable historical C4 payload/archive provenance. +No new SDK worktree/reset or claim to use the historical native checkout. + +Receipts: `.pi/benchmarks/provider-reducer-conditional-static-receiver/RESULTS.md`, +`summary.json`, `closure-replay.json`, `accounting-assessment.json`, +`accounting-verification.json`, `accepted-checkpoint.json`. **KEEP accepted** after +fresh identity recheck; no automatic commit/push/default rollout/full SDK generation/C6 work. + +## 30. Independent C6–C12 experiments against frozen C5 + +The user authorized independent evaluation without intermediate decisions. C6 was +not cleared due to reproduced diagnostic loss. C7 passed full gates and removed +2,638 `ThisReceiver` events across 714 files. C8, C9 and C10 passed full gates but +made **zero saved-corpus edits**; their changed-target corpus checks are vacuous. +C11 (predefined optional-null casts) and C12 (bounded int conjunction-pattern +grouping) also passed full validation/closure with **zero saved-corpus edits**; +broader constructor casts were not cleared, and C12 keyword-array/optional-default +subfamilies were not implemented. The batch is concluded; the user subsequently +**deferred C7 because of its observed time cost**. None is accepted or combined. +Complete Core source/tests were restored to C5 before the separately authorized +correctness work in §31. For exact scope, measurements, validation, known inherited +C5 defects and guarded restoration receipts, see +`.pi/benchmarks/provider-reducer-batch-c6-c12/{RESULTS.md,PLAN.md,PROGRESS.md,results.json}` +and `C6`–`C12/RESULTS.md`. Local ABBA measurements use two samples per variant and +are not significance, causality or runtime-neutrality proof. Historical C5 remains +the common frozen baseline; C3 and namespace D/E remain separately deferred. + +## 31. C5 handler/cref correctness correction — accepted baseline + +The user separately authorized repairing implicit interpolated-handler caller-source +capture and recorded whole-type-cref hidden-diagnostic loss. The repair adds the +shared original-call interpolation observation and conservative original-tree cref +import/diagnostic certificate, preserving generated classification. Import-sensitive +optional defaults require import independence or a proved surviving declaration; +pre-order whole-default decisions prevent newly vetoed erasure from exposing latent +child rewrites. Originally ineligible defaults retain independent child traversal. + +Final validation: **854 focused, 3,798 Core, 2,509 client**, official unfiltered +LOCAL regeneration, **956 Spector passed / 10 skipped**, actual Roslyn 4.8/5.9 +runtime/diagnostic/XML/default-metadata controls, concurrent shared-owner controls, +exact fresh frozen-C5 replay, native three-TFM/GA AppService 1.5.0 API checks, +byte-identical native emitted XML, **11,505 corpus source-proof parses**, and full +SDK/TestProjects/package restoration/closure. Sample regeneration discovered zero tests. + +The conservative certificate restores **5,700 cref qualifications across 895 files**: +saved residuals **27,911→33,611**, cref residuals **3,549→9,249**. No other saved +family changes; handler/C2 correctness changes have non-vacuous synthetic evidence +but no direct changed-target events here. Retention does not prove each withheld +cref reduction individually changes final SDK diagnostics. + +Two local ABBA samples per variant: mean generation **97.546→108.446 s (+11.17%)**, +allocation **+2.92%**. These are observations, not causal/significance evidence. +The user confirmed these correctness fixes are required; corrected C5 is now the +comparison baseline. The observed cost and restored gap are separate optimization +concerns, never justification to retain or reintroduce the bugs. + +Acceptance receipt: `.pi/benchmarks/provider-reducer-c5-correctness/accepted-checkpoint.json`. +Immutable validation receipt/report (recording their pre-acceptance status): +`.pi/benchmarks/provider-reducer-c5-correctness/{corrected-checkpoint.json,RESULTS.md}`. +Historical C5 remains immutable; the working tree holds the authorized repair. +**Baseline acceptance does not authorize optimization, commit/push, default enablement, +full SDK regeneration or C7/deferred-candidate adoption.** Bounded validation is not a theorem for every +inherited C5 family or arbitrary analyzers. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs index 94abf17e68e..328ceb2cda7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/CSharpGen.cs @@ -20,6 +20,8 @@ internal sealed class CSharpGen private static readonly string[] _filesToKeep = [ConfigurationFileName, CodeModelFileName]; + internal static GeneratedCodeWorkspacePostProcessingProfile? GenerationProfile { get; set; } + /// /// Executes the generator task with the instance. /// @@ -33,104 +35,143 @@ public async Task ExecuteAsync() // Resolve PackageReference items from the .csproj so custom code referencing external // NuGet types compiles correctly. - await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + using (GenerationProfile?.StartStep("Generation.AddPackageReferences")) + { + await GeneratedCodeWorkspace.AddPackageReferencesFromProject(); + } // Pre-walk the input library and resolve any external types that point at NuGet packages. // This populates ExternalTypeReferenceResolver's cache and registers each resolved assembly // as an additional metadata reference *before* the generated/custom code workspaces are // constructed, so their cached Roslyn projects pick the references up. - await ExternalTypeReferenceResolver.ResolveAllAsync(); + using (GenerationProfile?.StartStep("Generation.ResolveExternalTypeReferences")) + { + await ExternalTypeReferenceResolver.ResolveAllAsync(); + } // Initialize the workspace project AFTER all metadata references have been added so the // eagerly-cached project sees them. - GeneratedCodeWorkspace.Initialize(); - - GeneratedCodeWorkspace customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); + GeneratedCodeWorkspace customCodeWorkspace; + using (GenerationProfile?.StartStep("Generation.CreateCustomCodeWorkspace")) + { + GeneratedCodeWorkspace.Initialize(); + customCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: true); + } // The generated attributes need to be added into the workspace before loading the custom code. Otherwise, // Roslyn doesn't load the attributes completely and we are unable to get the attribute arguments. - List generateAttributeTasks = new(); - foreach (var attributeProvider in CodeModelGenerator.Instance.CustomCodeAttributeProviders) + using (GenerationProfile?.StartStep("Generation.AddCustomCodeAttributes")) { - generateAttributeTasks.Add(customCodeWorkspace.AddInMemoryFile(attributeProvider)); - } + List generateAttributeTasks = new(); + foreach (var attributeProvider in CodeModelGenerator.Instance.CustomCodeAttributeProviders) + { + generateAttributeTasks.Add(customCodeWorkspace.AddInMemoryFile(attributeProvider)); + } - await Task.WhenAll(generateAttributeTasks); + await Task.WhenAll(generateAttributeTasks); + } - CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel( - await customCodeWorkspace.GetCompilationAsync(), - await GeneratedCodeWorkspace.LoadBaselineContract(), - GeneratedCodeWorkspace.LoadApiCompatBaseline()); + using (GenerationProfile?.StartStep("Generation.CreateSourceInputModel")) + { + CodeModelGenerator.Instance.SourceInputModel = new SourceInputModel( + await customCodeWorkspace.GetCompilationAsync(), + await GeneratedCodeWorkspace.LoadBaselineContract(), + GeneratedCodeWorkspace.LoadApiCompatBaseline()); + } - GeneratedCodeWorkspace generatedCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + GeneratedCodeWorkspace generatedCodeWorkspace; + using (GenerationProfile?.StartStep("Generation.CreateGeneratedCodeWorkspace")) + { + generatedCodeWorkspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + } var output = CodeModelGenerator.Instance.OutputLibrary; Directory.CreateDirectory(Path.Combine(generatedSourceOutputPath, "Models")); List generateFilesTasks = new(); // Build all TypeProviders - foreach (var type in output.TypeProviders) + using (GenerationProfile?.StartStep("Generation.BuildTypeProviders")) { - type.EnsureBuilt(); + foreach (var type in output.TypeProviders) + { + type.EnsureBuilt(); + } } LoggingHelpers.LogElapsedTime("All generated type providers built"); // visit the entire library before generating files - foreach (var visitor in CodeModelGenerator.Instance.Visitors) + using (GenerationProfile?.StartStep("Generation.ApplyVisitors")) { - visitor.VisitLibrary(output); - } + foreach (var visitor in CodeModelGenerator.Instance.Visitors) + { + visitor.VisitLibrary(output); + } - FilterAllCustomizedMembers(output); + FilterAllCustomizedMembers(output); + } LoggingHelpers.LogElapsedTime("All visitors have been applied"); - foreach (var outputType in output.TypeProviders) + using (GenerationProfile?.StartStep("Generation.ProcessBackCompatibility")) { - // Ensure back-compatibility processing is done after all visitors have run - outputType.ProcessTypeForBackCompatibility(); + foreach (var outputType in output.TypeProviders) + { + // Ensure back-compatibility processing is done after all visitors have run + outputType.ProcessTypeForBackCompatibility(); + } } try { - using var referenceMapSession = ProviderReferenceMapAnalyzer.PrepareForGeneration(output.TypeProviders); - foreach (var outputType in output.TypeProviders) + ProviderReferenceMapSession session; + using (GenerationProfile?.StartStep("Generation.PrepareReferenceMap")) { - if (!referenceMapSession.ShouldWriteProvider(outputType)) - { - continue; - } - - if (outputType is ModelFactoryProvider && outputType.Methods.Count == 0) + session = ProviderReferenceMapAnalyzer.PrepareForGeneration(output.TypeProviders); + } + using var referenceMapSession = session; + using (GenerationProfile?.StartStep("Generation.WriteTypeProviders")) + { + foreach (var outputType in output.TypeProviders) { - continue; - } - - var writer = CodeModelGenerator.Instance.GetWriter(outputType); - generateFilesTasks.Add(generatedCodeWorkspace.AddGeneratedFile(writer.Write())); + if (!referenceMapSession.ShouldWriteProvider(outputType)) + { + continue; + } - foreach (var serialization in outputType.SerializationProviders) - { - if (!referenceMapSession.ShouldWriteProvider(serialization)) + if (outputType is ModelFactoryProvider && outputType.Methods.Count == 0) { continue; } - writer = CodeModelGenerator.Instance.GetWriter(serialization); + var writer = CodeModelGenerator.Instance.GetWriter(outputType); generateFilesTasks.Add(generatedCodeWorkspace.AddGeneratedFile(writer.Write())); + + foreach (var serialization in outputType.SerializationProviders) + { + if (!referenceMapSession.ShouldWriteProvider(serialization)) + { + continue; + } + + writer = CodeModelGenerator.Instance.GetWriter(serialization); + generateFilesTasks.Add(generatedCodeWorkspace.AddGeneratedFile(writer.Write())); + } } - } - // Add all the generated files to the workspace - await Task.WhenAll(generateFilesTasks); + // Add all the generated files to the workspace + await Task.WhenAll(generateFilesTasks); - referenceMapSession.RestorePreWriteModelFactoryMethods(); + referenceMapSession.RestorePreWriteModelFactoryMethods(); + } LoggingHelpers.LogElapsedTime("All generated types have been written into memory"); // Delete any old generated files - DeleteDirectory(generatedSourceOutputPath, _filesToKeep); + using (GenerationProfile?.StartStep("Generation.DeleteOldGeneratedFiles")) + { + DeleteDirectory(generatedSourceOutputPath, _filesToKeep); + } LoggingHelpers.LogElapsedTime("All old generated files have been deleted"); } @@ -140,32 +181,44 @@ await GeneratedCodeWorkspace.LoadBaselineContract(), } var generatedFiles = new List<(string Name, string Text)>(); - await foreach (var file in generatedCodeWorkspace.GetGeneratedFilesAsync()) + using (GenerationProfile?.StartStep("Generation.ProcessDocuments")) { - if (string.IsNullOrEmpty(file.Text)) + await foreach (var file in generatedCodeWorkspace.GetGeneratedFilesAsync()) { - continue; - } + if (string.IsNullOrEmpty(file.Text)) + { + continue; + } - generatedFiles.Add((file.Name, file.Text)); + generatedFiles.Add((file.Name, file.Text)); + } } // Write the generated files to the output directory - foreach (var file in generatedFiles) + using (GenerationProfile?.StartStep("Generation.WriteGeneratedFilesToDisk")) { - var filename = Path.Combine(outputPath, file.Name); - CodeModelGenerator.Instance.Emitter.Info($"Writing {Path.GetFullPath(filename)}"); - Directory.CreateDirectory(Path.GetDirectoryName(filename)!); - await File.WriteAllTextAsync(filename, file.Text); + foreach (var file in generatedFiles) + { + var filename = Path.Combine(outputPath, file.Name); + CodeModelGenerator.Instance.Emitter.Info($"Writing {Path.GetFullPath(filename)}"); + Directory.CreateDirectory(Path.GetDirectoryName(filename)!); + await File.WriteAllTextAsync(filename, file.Text); + } } // Write additional output files (e.g. configuration schemas, .targets files) - await CodeModelGenerator.Instance.WriteAdditionalFiles(outputPath); + using (GenerationProfile?.StartStep("Generation.WriteAdditionalFiles")) + { + await CodeModelGenerator.Instance.WriteAdditionalFiles(outputPath); + } // Write project scaffolding files (after additional files so schema existence can be checked) if (CodeModelGenerator.Instance.IsNewProject) { - await CodeModelGenerator.Instance.TypeFactory.CreateNewProjectScaffolding().Execute(); + using (GenerationProfile?.StartStep("Generation.WriteProjectScaffolding")) + { + await CodeModelGenerator.Instance.TypeFactory.CreateNewProjectScaffolding().Execute(); + } } LoggingHelpers.LogElapsedTime("All files have been written to disk"); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/BinaryOperatorExpression.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/BinaryOperatorExpression.cs index 4fead8d8d25..dcbd5029dc2 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/BinaryOperatorExpression.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Expressions/BinaryOperatorExpression.cs @@ -8,12 +8,17 @@ public sealed record BinaryOperatorExpression(string Operator, ValueExpression L internal override void Write(CodeWriter writer) { writer.AppendRaw("("); + WriteUnparenthesized(writer); + writer.AppendRaw(")"); + } + + internal void WriteUnparenthesized(CodeWriter writer) + { Left.Write(writer); writer.AppendRawIf(" ", !Left.IsEmptyExpression()); writer.AppendRaw(Operator); writer.AppendRawIf(" ", !Right.IsEmptyExpression()); Right.Write(writer); - writer.AppendRaw(")"); } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs new file mode 100644 index 00000000000..d18c4281f6f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.TypeSpec.Generator +{ + // Candidate-independent syntax certificate; not a cached binding decision. + internal static class ConditionalStaticReceiverRules + { + internal static bool HasHardSourceContext(SyntaxNode receiver) + { + foreach (var ancestor in receiver.Ancestors()) + { + if (ancestor is AttributeSyntax or ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or + InitializerExpressionSyntax or InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or + QueryExpressionSyntax or PatternSyntax or WhenClauseSyntax or RefExpressionSyntax or AwaitExpressionSyntax) + return true; + if (ancestor is ArgumentSyntax argument && (!argument.RefKindKeyword.IsKind(SyntaxKind.None) || + argument.Parent is not ArgumentListSyntax list || list.Parent is not + (InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax))) return true; + // Implicit extension GetEnumerator/GetAsyncEnumerator can observe + // COLLECTION source via CAE. Their capture does not include body + // occurrences. Continue past lambdas to inspect every ancestor. + if (ancestor is CommonForEachStatementSyntax loop && loop.Expression.Span.Contains(receiver.Span)) return true; + // Optional-CAE deconstruct patterns are rejected by the compiler; + // keep this unneeded context conservative in the first slice. + if (ancestor is AssignmentExpressionSyntax assignment && (assignment.Left is TupleExpressionSyntax or DeclarationExpressionSyntax) && + assignment.Right.Span.Contains(receiver.Span)) return true; + } + return false; + } + + internal static bool KeepsTokenBoundaries(ExpressionSyntax original, ExpressionSyntax replacement) + { + static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item.FullSpan.Length != 0 && + item.Kind() is SyntaxKind.WhitespaceTrivia or SyntaxKind.EndOfLineTrivia or SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia); + var actual = replacement.WithTriviaFrom(original); + var previous = original.GetFirstToken().GetPreviousToken(); var next = original.GetLastToken().GetNextToken(); + return (Separator(previous.TrailingTrivia) || Separator(actual.GetLeadingTrivia()) || ParenthesesSyntaxFacts.TokensStaySeparate(previous, actual.GetFirstToken())) && + (Separator(actual.GetTrailingTrivia()) || Separator(next.LeadingTrivia) || ParenthesesSyntaxFacts.TokensStaySeparate(actual.GetLastToken(), next)); + } + + internal static BaseMethodDeclarationSyntax? OuterExecutableUnit(SyntaxNode receiver) + { + var unit = receiver.Ancestors().OfType().LastOrDefault(); + SyntaxNode? body = unit switch + { + MethodDeclarationSyntax method => (SyntaxNode?)method.Body ?? method.ExpressionBody, + ConstructorDeclarationSyntax constructor => (SyntaxNode?)constructor.Body ?? constructor.ExpressionBody, + _ => null + }; + return body != null && body.Span.Contains(receiver.Span) ? unit : null; + } + + internal static bool SameUnit(BaseMethodDeclarationSyntax original, SyntaxNode variant) + { + if (variant.RawKind != original.RawKind || variant.Span != original.Span || variant.ContainsDiagnostics || + !SyntaxFactory.AreEquivalent(original, variant, static _ => false)) return false; + // Physical text alone can reconstruct the same file while changing + // active/disabled syntax. Compare the complete active structure and + // raw token coordinates, not only top-level headers or FullString. + return original.DescendantTokens().Select(static token => (token.RawKind, token.SpanStart, token.Span.Length, token.Text)) + .SequenceEqual(variant.DescendantTokens().Select(static token => (token.RawKind, token.SpanStart, token.Span.Length, token.Text))); + } + + internal static bool SameOccurrence(ExpressionSyntax original, MemberAccessExpressionSyntax member, SyntaxNode root, TextSpan prefix) + { + var candidate = root.FindNode(original.Span, getInnermostNodeForTie: true); + if (candidate.RawKind != original.RawKind || candidate.Span != original.Span || + !SyntaxFactory.AreEquivalent(original, candidate, static _ => false) || + candidate.Parent is not MemberAccessExpressionSyntax access || access.Expression != candidate || + access.RawKind != member.RawKind || access.Span != member.Span || + !SyntaxFactory.AreEquivalent(member, access, static _ => false)) return false; + var tree = root.SyntaxTree; + var line = tree.GetLineSpan(prefix); + return prefix.Length != 0 && line.StartLinePosition.Line == line.EndLinePosition.Line && + tree.GetLineVisibility(prefix.Start) == LineVisibility.Visible && + tree.GetLineVisibility(prefix.End - 1) == LineVisibility.Visible; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs new file mode 100644 index 00000000000..7a24a7e125f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.TypeSpec.Generator +{ + // Shared receiver materialization safety, independent of symbol selection. + internal static class ExpressionReceiverSyntaxFacts + { + internal static bool ExposesContextualRoot(ExpressionSyntax expression) + { + var first = expression.GetFirstToken(); + // field is explicit because the older compiler API may not classify + // syntax that a newer C# 14 consumer will parse as a backing-field use. + return first.Text == first.ValueText && (SyntaxFacts.GetContextualKeywordKind(first.ValueText) != SyntaxKind.None || + first.ValueText == "field"); + } + + internal static bool MayExposeCastParse(SyntaxNode receiver) + { + // Conservative form of Roslyn's (MIT) member-access cast-parse + // guard: inspect the full enclosing chain and keep hazardous follow + // tokens. This does not remove or rewrite the parentheses. + SyntaxNode chain = receiver; + while (chain.Parent is MemberAccessExpressionSyntax parent && parent.Expression == chain) chain = parent; + if (chain.Parent is not ParenthesizedExpressionSyntax parentheses) return false; + var next = parentheses.GetLastToken().GetNextToken().Kind(); + return next is SyntaxKind.TildeToken or SyntaxKind.ExclamationToken or SyntaxKind.OpenParenToken || + SyntaxFacts.IsKeywordKind(next) && next is not (SyntaxKind.AsKeyword or SyntaxKind.IsKeyword); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index 64f57346058..f02dd1c227d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -42,10 +42,14 @@ internal class GeneratedCodeWorkspace private static readonly Lazy _metadataReferenceResolver = new(() => new WorkspaceMetadataReferenceResolver()); private static Task? _cachedProject; + internal static GeneratedCodeWorkspacePostProcessingProfile? PostProcessingProfile { get; set; } + private static readonly string[] _generatedFolders = [GeneratedFolder]; private static readonly string[] _sharedFolders = [SharedFolder]; private Project _project; + private readonly bool _structuralMode = ProviderNameReducer.IsStructuralEnabled; + private readonly Dictionary>? _typeReferences = ProviderNameReducer.IsEnabled ? new() : null; private Dictionary PlainFiles { get; } private GeneratedCodeWorkspace(Project generatedCodeProject) @@ -79,7 +83,7 @@ public void AddPlainFiles(string name, string content) public async Task AddGeneratedFile(CodeFile codefile) { var document = _project.AddDocument(codefile.Name, codefile.Content, _generatedFolders); - await UpdateProject(document); + await UpdateProject(document, codefile.TypeReferences); } public async Task AddInMemoryFile(TypeProvider type) @@ -88,11 +92,21 @@ public async Task AddInMemoryFile(TypeProvider type) await UpdateProject(document); } - private async Task UpdateProject(Document document) + private async Task UpdateProject(Document document, IReadOnlyList? typeReferences = null) { + if (_typeReferences != null) + { + if (typeReferences != null) + { + _typeReferences.Add(document.Id, typeReferences); + } + // Preserve writer offsets and original source text. The candidate + // validates metadata at planning time; it needs no AST annotations. + _project = document.Project; + return; + } var root = await document.GetSyntaxRootAsync(); Debug.Assert(root != null); - root = root.WithAdditionalAnnotations(Simplifier.Annotation); document = document.WithSyntaxRoot(root); _project = document.Project; @@ -107,6 +121,20 @@ internal static SyntaxTree GetTree(TypeProvider provider) public async IAsyncEnumerable<(string Name, string Text)> GetGeneratedFilesAsync() { + ProviderNameReducer? providerReducer = null; + StructuralTypeNameReducer? structuralReducer = null; + if ((_structuralMode || _typeReferences is { Count: > 0 } && _typeReferences.Values.Any(static references => references.Count > 0)) && + CodeModelGenerator.Instance.Rewriters.Count == 0 && + !CodeModelGenerator.Instance.Configuration.DisableRoslynReduce) + { + using var indexProfile = PostProcessingProfile?.StartStep(_structuralMode ? "StructuralTypeNames.BuildIndex" : "ProviderReducer.BuildIndex"); + var compilation = await _project.GetCompilationAsync(); + if (compilation != null) + { + if (_structuralMode) structuralReducer = new StructuralTypeNameReducer(compilation); + else providerReducer = new ProviderNameReducer(compilation); + } + } List> documents = new List>(); var memberRemover = new MemberRemoverRewriter(); foreach (Document document in _project.Documents) @@ -116,7 +144,7 @@ internal static SyntaxTree GetTree(TypeProvider provider) continue; } - documents.Add(ProcessDocument(document, memberRemover)); + documents.Add(ProcessDocument(document, memberRemover, providerReducer, structuralReducer)); } var docs = await Task.WhenAll(documents); @@ -134,20 +162,78 @@ internal static SyntaxTree GetTree(TypeProvider provider) } } - private async Task ProcessDocument(Document document, MemberRemoverRewriter memberRemover) + private async Task ProcessDocument(Document document, MemberRemoverRewriter memberRemover, ProviderNameReducer? providerReducer, StructuralTypeNameReducer? structuralReducer) { - var root = await document.GetSyntaxRootAsync(); - var semanticModel = await document.GetSemanticModelAsync(); + using var totalProfile = PostProcessingProfile?.StartStep("ProcessDocument.Total"); + SyntaxNode? root; + using (PostProcessingProfile?.StartStep("GetSyntaxRootAsync")) + { + root = await document.GetSyntaxRootAsync(); + } + + // Candidate mode never delegates uncertainty to Roslyn's simplifier. + // Without a custom rewriter, even opaque documents need no semantic model. + var useProviderReducer = _typeReferences != null; + if (root != null && useProviderReducer && CodeModelGenerator.Instance.Rewriters.Count == 0) + { + var originalRoot = root; + var traversal = memberRemover; + if (_structuralMode && structuralReducer != null) + { + var structuralReferences = _typeReferences!.TryGetValue(document.Id, out var recorded) + ? recorded : Array.Empty(); + traversal = structuralReducer.CreateRewriter(root, structuralReferences) ?? memberRemover; + } + using (PostProcessingProfile?.StartStep("MemberRemoverRewriter")) + { + using var structuralProfile = !ReferenceEquals(traversal, memberRemover) ? PostProcessingProfile?.StartStep("StructuralTypeNames.Visit") : null; + root = traversal.Visit(root); + } + if (_structuralMode) + { + if (ReferenceEquals(root, originalRoot)) PostProcessingProfile?.Add("StructuralTypeNames.KeepQualified", TimeSpan.Zero, 0); + return ReferenceEquals(root, originalRoot) ? document : document.WithSyntaxRoot(root); + } + if (!CodeModelGenerator.Instance.Configuration.DisableRoslynReduce) + { + ProviderNameReducer.PrefixPlan? plan = null; + // Never apply original writer spans to a changed root. The current + // remover only prunes empty namespaces, so this is normally a no-op. + if (ReferenceEquals(root, originalRoot) && providerReducer != null && + _typeReferences!.TryGetValue(document.Id, out var references)) + { + using var scopeProfile = PostProcessingProfile?.StartStep("ProviderReducer.Scope"); + providerReducer.TryPlan(root, references, out plan); + } + if (plan != null) + { + using var reduceProfile = PostProcessingProfile?.StartStep("ProviderReducer.Reduce"); + return document.WithText(plan.Apply()); + } + PostProcessingProfile?.Add("ProviderReducer.KeepQualified", TimeSpan.Zero, 0); + } + return ReferenceEquals(root, originalRoot) ? document : document.WithSyntaxRoot(root); + } + + SemanticModel? semanticModel; + using (PostProcessingProfile?.StartStep("GetSemanticModelAsync")) + { + semanticModel = await document.GetSemanticModelAsync(); + } if (semanticModel == null || root == null) { return document; } - root = memberRemover.Visit(root); + using (PostProcessingProfile?.StartStep("MemberRemoverRewriter")) + { + root = memberRemover.Visit(root); + } foreach (var rewriter in CodeModelGenerator.Instance.Rewriters) { + using var rewriterProfile = PostProcessingProfile?.StartStep($"CustomRewriter.{rewriter.GetType().Name}"); rewriter.SemanticModel = semanticModel; root = rewriter.Visit(root); } @@ -155,12 +241,23 @@ private async Task ProcessDocument(Document document, MemberRemoverRew if (!CodeModelGenerator.Instance.Configuration.DisableRoslynReduce) { - document = await Simplifier.ReduceAsync(document); + if (useProviderReducer) + { + // Custom rewriters may invalidate recorded references. Preserve their + // output and formatting contract without another semantic reduction pass. + PostProcessingProfile?.Add(_structuralMode ? "StructuralTypeNames.KeepQualified" : "ProviderReducer.KeepQualified", TimeSpan.Zero, 0); + } + else + { + using var reduceProfile = PostProcessingProfile?.StartStep("Roslyn.Simplifier.ReduceAsync"); + document = await Simplifier.ReduceAsync(document); + } } // Reformat if any custom rewriters have been applied if (CodeModelGenerator.Instance.Rewriters.Count > 0) { + using var formatProfile = PostProcessingProfile?.StartStep("Formatter.FormatAsync"); document = await Formatter.FormatAsync(document); } return document; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspacePostProcessingProfile.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspacePostProcessingProfile.cs new file mode 100644 index 00000000000..80ca85e3411 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspacePostProcessingProfile.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.TypeSpec.Generator +{ + internal sealed class GeneratedCodeWorkspacePostProcessingProfile + { + private readonly object _syncRoot = new(); + private readonly Dictionary _steps = new(StringComparer.Ordinal); + + public IDisposable StartStep(string stepName) => new Step(this, stepName); + + private sealed class Step(GeneratedCodeWorkspacePostProcessingProfile profile, string name) : IDisposable + { + private readonly long _start = Stopwatch.GetTimestamp(); + private readonly long _allocatedBytes = GC.GetTotalAllocatedBytes(precise: false); + + public void Dispose() + { + profile.Add(name, Stopwatch.GetElapsedTime(_start), + GC.GetTotalAllocatedBytes(precise: false) - _allocatedBytes); + } + } + + public void Add(string stepName, TimeSpan elapsed, long allocatedBytes) + { + lock (_syncRoot) + { + ref var summary = ref CollectionsMarshal.GetValueRefOrAddDefault(_steps, stepName, out _); + summary.Count++; + summary.ElapsedTicks += elapsed.Ticks; + summary.AllocatedBytes += allocatedBytes; + } + } + + public string GetSummary() + { + KeyValuePair[] steps; + lock (_syncRoot) + { + steps = _steps.ToArray(); + } + + var totalTicks = steps + .Where(static step => step.Key != "ProcessDocument.Total") + .Sum(static step => step.Value.ElapsedTicks); + var builder = new StringBuilder(); + builder.AppendLine("Step profile (concurrent and nested scopes overlap; allocations are process-wide estimates):"); + builder.AppendLine("Step, Count, Total ms, Avg ms, Percent of measured steps, Allocated bytes, Avg allocated bytes"); + + foreach (var step in steps.OrderByDescending(static step => step.Value.ElapsedTicks)) + { + var elapsedMs = TimeSpan.FromTicks(step.Value.ElapsedTicks).TotalMilliseconds; + var averageMs = elapsedMs / step.Value.Count; + var averageAllocatedBytes = step.Value.AllocatedBytes / step.Value.Count; + var percentage = totalTicks == 0 || step.Key == "ProcessDocument.Total" + ? 0 + : step.Value.ElapsedTicks * 100.0 / totalTicks; + builder.AppendLine(string.Create(CultureInfo.InvariantCulture, + $"{step.Key}, {step.Value.Count}, {elapsedMs:F3}, {averageMs:F3}, {percentage:F1}%, {step.Value.AllocatedBytes}, {averageAllocatedBytes}")); + } + + return builder.ToString(); + } + + private struct StepSummary + { + public int Count; + public long ElapsedTicks; + public long AllocatedBytes; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/MemberRemoverRewriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/MemberRemoverRewriter.cs index 9e10f3cfd53..52ac977d5f4 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/MemberRemoverRewriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/MemberRemoverRewriter.cs @@ -9,6 +9,10 @@ namespace Microsoft.TypeSpec.Generator { internal class MemberRemoverRewriter : CSharpSyntaxRewriter { + public MemberRemoverRewriter() : this(false) { } + + protected MemberRemoverRewriter(bool visitIntoStructuredTrivia) : base(visitIntoStructuredTrivia) { } + public override SyntaxNode? VisitCompilationUnit(CompilationUnitSyntax node) { var visitedNode = base.VisitCompilationUnit(node); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs new file mode 100644 index 00000000000..b0570ffa5bc --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.TypeSpec.Generator +{ + // Shared pure facts. Legacy admission/text planning remains unchanged; the + // structural consumer applies its own occurrence and composed-output proof. + internal static class ParenthesesSyntaxFacts + { + internal static bool TokensStaySeparate(SyntaxToken left, SyntaxToken right) + { + if (left.RawKind == 0 || right.RawKind == 0) return true; + var parsed = SyntaxFactory.ParseTokens(left.Text + right.Text).Where(static t => !t.IsKind(SyntaxKind.EndOfFileToken)).ToArray(); + return parsed.Length == 2 && parsed[0].RawKind == left.RawKind && parsed[0].Text == left.Text && + parsed[1].RawKind == right.RawKind && parsed[1].Text == right.Text; + } + + internal static int Precedence(ExpressionSyntax expression) => expression.Kind() switch + { + SyntaxKind.MultiplyExpression or SyntaxKind.DivideExpression or SyntaxKind.ModuloExpression => 13, + SyntaxKind.AddExpression or SyntaxKind.SubtractExpression => 12, + SyntaxKind.LeftShiftExpression or SyntaxKind.RightShiftExpression or SyntaxKind.UnsignedRightShiftExpression => 11, + SyntaxKind.LessThanExpression or SyntaxKind.GreaterThanExpression or SyntaxKind.LessThanOrEqualExpression or SyntaxKind.GreaterThanOrEqualExpression or + SyntaxKind.IsExpression or SyntaxKind.AsExpression or SyntaxKind.IsPatternExpression => 10, + SyntaxKind.EqualsExpression or SyntaxKind.NotEqualsExpression => 9, + SyntaxKind.BitwiseAndExpression => 8, + SyntaxKind.ExclusiveOrExpression => 7, + SyntaxKind.BitwiseOrExpression => 6, + SyntaxKind.LogicalAndExpression => 5, + SyntaxKind.LogicalOrExpression => 4, + SyntaxKind.CoalesceExpression => 3, + SyntaxKind.ConditionalExpression => 2, + SyntaxKind.CastExpression => 14, + _ => expression is IdentifierNameSyntax or LiteralExpressionSyntax or MemberAccessExpressionSyntax or InvocationExpressionSyntax or + ElementAccessExpressionSyntax or ObjectCreationExpressionSyntax or TypeOfExpressionSyntax or SizeOfExpressionSyntax or DefaultExpressionSyntax or ThisExpressionSyntax ? 16 : 0 + }; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs new file mode 100644 index 00000000000..eab4f56ea22 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.TypeSpec.Generator +{ + internal sealed class ProviderConditionalSyntax + { + private static readonly string[] Thresholds = ["NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER"]; + private static readonly string[] HeaderThresholds = ["NETCOREAPP2_1_OR_GREATER", "NET5_0_OR_GREATER", "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER"]; + private readonly Compilation _compilation; + private readonly ConcurrentDictionary _roots = new(); + private readonly ConcurrentDictionary _conditionalTrees = new(); + private readonly ConcurrentDictionary _typeOnlyTrees = new(); + private readonly Lazy _stableDeclarations; + + internal ProviderConditionalSyntax(Compilation compilation) + { + _compilation = compilation; + _stableDeclarations = new Lazy(ValidateCompilation); + } + + internal static bool HasConditional(SyntaxNode root) => root.ContainsDirectives && root.DescendantTrivia().Any(static t => + t.IsKind(SyntaxKind.IfDirectiveTrivia) || t.IsKind(SyntaxKind.ElifDirectiveTrivia) || t.IsKind(SyntaxKind.ElseDirectiveTrivia) || t.IsKind(SyntaxKind.EndIfDirectiveTrivia)); + + internal bool TryGetRoots(SyntaxNode root, out SyntaxNode[] roots) + { + roots = [root]; + if (!_stableDeclarations.Value) return false; + if (!TreeHasConditional(root)) return true; + return _roots.TryGetValue(root.SyntaxTree, out roots!); + } + + internal bool IsUnconditionalDocument(SyntaxNode root) => _stableDeclarations.Value && !TreeHasConditional(root); + + // Type-only lookup ignores value declarations, but generic local-function + // wrappers can change a physical site's type-parameter scope across parses. + // Keep such documents opaque; inactive syntax is never rewritten. + internal bool IsTypeOnlyDocument(SyntaxNode root) => _stableDeclarations.Value && + (!TreeHasConditional(root) || _typeOnlyTrees.ContainsKey(root.SyntaxTree)); + + private bool TreeHasConditional(SyntaxNode root) => _conditionalTrees.GetOrAdd(root.SyntaxTree, + static tree => HasConditional(tree.GetRoot())); + + private bool ValidateCompilation() + { + // Conditional changes in another partial/custom/shared source tree can + // alter namespace/member lookup. A per-document body check is not enough. + foreach (var tree in _compilation.SyntaxTrees) + { + var root = tree.GetRoot(); + if (!TreeHasConditional(root)) continue; + if (!HasInvariantHeaders(root)) return false; + // A fixed-project-flag Shared document may be opaque itself while + // proving that it cannot change lookup in other documents. + if (TryParseInvariantBodies(root, out var roots, out var hasGenericLocalFunctions)) + { + _roots.TryAdd(tree, roots); + if (!hasGenericLocalFunctions) _typeOnlyTrees.TryAdd(tree, true); + } + } + return true; + } + + private static bool HasInvariantHeaders(SyntaxNode root) + { + if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) return false; + var directives = root.DescendantTrivia().Where(static t => t.IsDirective) + .Select(static t => t.GetStructure()).OfType().ToArray(); + var used = new HashSet(StringComparer.Ordinal); + foreach (var directive in directives) + { + if (directive is DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) return false; + var condition = directive switch + { + IfDirectiveTriviaSyntax branch => branch.Condition, + ElifDirectiveTriviaSyntax branch => branch.Condition, + _ => null + }; + if (condition == null) continue; + foreach (var name in condition.DescendantNodesAndSelf().OfType()) + { + var symbol = name.Identifier.ValueText; + if (symbol.StartsWith("NET", StringComparison.Ordinal) && !HeaderThresholds.Contains(symbol)) return false; + if (HeaderThresholds.Contains(symbol)) used.Add(symbol); + } + } + // Only framework thresholds vary. Preserve the exact original project + // defines, and include the original (possibly noncanonical) NET state. + var fixedSymbols = options.PreprocessorSymbolNames.Where(static s => !HeaderThresholds.Contains(s)).ToArray(); + var signatures = new HashSet(StringComparer.Ordinal); + string Signature(IEnumerable symbols) => string.Join(";", used.Order(StringComparer.Ordinal).Select(s => symbols.Contains(s) ? "1" : "0")); + signatures.Add(Signature(options.PreprocessorSymbolNames)); + var expected = HeaderShape(root); + for (var level = 0; level <= HeaderThresholds.Length; level++) + { + var active = HeaderThresholds.Take(level).ToArray(); + if (!signatures.Add(Signature(active))) continue; + var variant = CSharpSyntaxTree.ParseText(root.SyntaxTree.GetText(), options.WithPreprocessorSymbols(fixedSymbols.Concat(active)), root.SyntaxTree.FilePath).GetRoot(); + if (variant.ContainsDiagnostics || !string.Equals(expected, HeaderShape(variant), StringComparison.Ordinal)) return false; + } + return true; + } + + private static bool TryParseInvariantBodies(SyntaxNode root, out SyntaxNode[] roots, out bool hasGenericLocalFunctions) + { + roots = []; + hasGenericLocalFunctions = false; + if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) return false; + var directives = root.DescendantTrivia().Where(static t => t.IsDirective).Select(static t => t.GetStructure()).OfType().ToArray(); + var used = new HashSet(StringComparer.Ordinal); + foreach (var directive in directives) + { + if (directive is IfDirectiveTriviaSyntax condition) + { + if (condition.Condition is not IdentifierNameSyntax name || !Thresholds.Contains(name.Identifier.ValueText)) return false; + used.Add(name.Identifier.ValueText); + } + else if (directive is ElifDirectiveTriviaSyntax or DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) return false; + } + if (used.Count == 0) return false; + var fixedSymbols = options.PreprocessorSymbolNames.Where(static s => !Thresholds.Contains(s)).ToArray(); + if (!BodyShape(root, out var originalShape, out hasGenericLocalFunctions)) return false; + var originalSymbols = options.PreprocessorSymbolNames.ToHashSet(StringComparer.Ordinal); + var signatures = new HashSet(StringComparer.Ordinal) + { + string.Join(";", used.Order(StringComparer.Ordinal).Select(s => originalSymbols.Contains(s) ? "1" : "0")) + }; + var parsed = new List { root }; + for (var level = 0; level <= Thresholds.Length; level++) + { + var active = Thresholds.Take(level).ToArray(); + var signature = string.Join(";", used.Order(StringComparer.Ordinal).Select(s => active.Contains(s) ? "1" : "0")); + if (!signatures.Add(signature)) continue; + var symbols = fixedSymbols.Concat(active).ToArray(); + var variant = options.PreprocessorSymbolNames.ToHashSet(StringComparer.Ordinal).SetEquals(symbols) + ? root : CSharpSyntaxTree.ParseText(root.SyntaxTree.GetText(), options.WithPreprocessorSymbols(symbols), root.SyntaxTree.FilePath).GetRoot(); + if (variant.ContainsDiagnostics || !BodyShape(variant, out var shape, out var variantHasGenericLocalFunctions)) return false; + hasGenericLocalFunctions |= variantHasGenericLocalFunctions; + if (!string.Equals(shape, originalShape, StringComparison.Ordinal)) return false; + parsed.Add(variant); + } + roots = parsed.ToArray(); + return roots.Length > 0; + } + + private static bool BodyShape(SyntaxNode root, out string shape, out bool hasGenericLocalFunctions) + { + shape = string.Empty; + var bodies = Bodies(root, out hasGenericLocalFunctions); + foreach (var trivia in root.DescendantTrivia().Where(static t => t.IsKind(SyntaxKind.IfDirectiveTrivia) || + t.IsKind(SyntaxKind.ElifDirectiveTrivia) || t.IsKind(SyntaxKind.ElseDirectiveTrivia) || t.IsKind(SyntaxKind.EndIfDirectiveTrivia))) + { + if (!bodies.Any(body => body.Contains(trivia.FullSpan))) return false; + var token = trivia.Token; + var statement = token.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + // Branches must start/end at complete statement boundaries. Never + // rejoin fragments of an expression, argument list, or declaration. + if (statement?.SpanStart != token.SpanStart && !(token.Parent is BlockSyntax block && block.CloseBraceToken == token)) return false; + } + shape = HeaderShape(root); + return true; + } + + private static TextSpan[] Bodies(SyntaxNode root) => Bodies(root, out _); + + private static TextSpan[] Bodies(SyntaxNode root, out bool hasGenericLocalFunctions) + { + var bodies = new List(); + hasGenericLocalFunctions = false; + // Reuse the existing declaration collector; no additional body walk + // just to discover conditional type-parameter scope hazards. + foreach (var node in root.DescendantNodes()) + { + switch (node) + { + case MethodDeclarationSyntax { Body: { } body }: + bodies.Add(body.Span); + break; + case ConstructorDeclarationSyntax { Body: { } body }: + bodies.Add(body.Span); + break; + case LocalFunctionStatementSyntax { TypeParameterList: not null }: + hasGenericLocalFunctions = true; + break; + } + } + return bodies.OrderBy(static s => s.Start).ToArray(); + } + + private static string HeaderShape(SyntaxNode root) + { + var bodies = Bodies(root); + var builder = new StringBuilder(); + foreach (var body in bodies) builder.Append(body.Start).Append(':').Append(body.Length).Append(';'); + // Directive state can escape a body even when its ordinary tokens do + // not: e.g. #nullable changes later declaration semantics. + foreach (var directive in root.DescendantTrivia().Where(static t => t.IsDirective) + .Select(static t => t.GetStructure()).OfType()) + { + if (directive is IfDirectiveTriviaSyntax or ElifDirectiveTriviaSyntax or ElseDirectiveTriviaSyntax or EndIfDirectiveTriviaSyntax or + RegionDirectiveTriviaSyntax or EndRegionDirectiveTriviaSyntax) continue; + builder.Append(directive.SpanStart).Append(':').Append(directive.RawKind).Append(':') + .Append(directive.IsActive).Append(':').Append(directive.ToFullString()).Append(';'); + } + foreach (var token in root.DescendantTokens()) + { + if (bodies.Any(body => body.Contains(token.Span))) continue; + builder.Append(token.SpanStart).Append(':').Append(token.RawKind).Append(':').Append(token.Text.Length).Append(':').Append(token.Text).Append(';'); + } + return builder.ToString(); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs new file mode 100644 index 00000000000..a4eeddc28c1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs @@ -0,0 +1,472 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; + +namespace Microsoft.TypeSpec.Generator +{ + // Local, opt-in namespace-reduction prototype. No per-document semantic model. + internal sealed class ProviderNameReducer + { + internal static bool IsStructuralEnabled => string.Equals(Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"), "structural", StringComparison.OrdinalIgnoreCase); + internal static bool IsEnabled => IsStructuralEnabled || string.Equals(Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"), "true", StringComparison.OrdinalIgnoreCase); + + private enum LookupKind { Type, StaticReceiver, Attribute, Cref } + private readonly record struct Reduction(bool ShortenPrefix, bool OmitAttributeSuffix = false); + + private readonly Compilation _compilation; + private readonly ProviderConditionalSyntax _conditional; + private readonly ConcurrentDictionary _types = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary<(string Namespace, string Name), ImmutableArray> _namespaceMembers = new(); + private readonly List _globalUsings = new(); + + internal ProviderNameReducer(Compilation compilation) + { + _compilation = compilation; + _conditional = new ProviderConditionalSyntax(compilation); + foreach (var tree in compilation.SyntaxTrees) + { + if (tree.GetRoot() is CompilationUnitSyntax root) + { + _globalUsings.AddRange(root.Usings.Where(static u => !u.GlobalKeyword.IsKind(SyntaxKind.None))); + } + } + } + + internal static bool TryMatchReferences(SyntaxNode root, IReadOnlyList references, out SyntaxNode[] matches) => + TryMatchReferences(new[] { root }, references, out matches); + + private static SimpleNameSyntax? ReferenceName(SyntaxNode node) => node switch + { + QualifiedNameSyntax qualified => qualified.Right, + AliasQualifiedNameSyntax alias => alias.Name, + MemberAccessExpressionSyntax member => member.Name, + QualifiedCrefSyntax { Member: NameMemberCrefSyntax { Name: SimpleNameSyntax name, Parameters: null } } => name, + NameMemberCrefSyntax { Name: SimpleNameSyntax name, Parameters: null } => name, + _ => null + }; + + private static bool TryMatchReferences(IReadOnlyList roots, IReadOnlyList references, out SyntaxNode[] matches) + { + matches = new SyntaxNode[references.Count]; + var sites = new Dictionary(); + var text = roots[0].SyntaxTree.GetText(); + var documentation = references.Any(static site => site.Role == TypeReferenceRole.WholeTypeCref); + for (var index = 0; index < references.Count; index++) + { + var site = references[index]; + if (!roots[0].FullSpan.Contains(site.Span) || !sites.TryAdd(site.Span, index) || + text.ToString(site.Span) != "global::" + site.Type.FullyQualifiedName) return false; + } + foreach (var root in roots) + { + var seen = new HashSet(); + foreach (var node in root.DescendantNodes(descendIntoTrivia: documentation)) + { + var name = ReferenceName(node); + if (name == null || !sites.TryGetValue(TextSpan.FromBounds(node.SpanStart, name.Identifier.Span.End), out var index)) continue; + if ((node is CrefSyntax) != (references[index].Role == TypeReferenceRole.WholeTypeCref)) continue; + if (!seen.Add(index) || name.Arity != references[index].Type.Arguments.Count) return false; + if (matches[index] != null && (matches[index].RawKind != node.RawKind || matches[index].Span != node.Span || matches[index].Parent?.RawKind != node.Parent?.RawKind)) return false; + matches[index] ??= node; + } + if (seen.Count != references.Count) + { + var disabled = root.DescendantTrivia().Where(static t => t.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static t => t.Span).ToArray(); + for (var i = 0; i < references.Count; i++) + if (!seen.Contains(i) && !disabled.Any(span => span.Contains(references[i].Span))) return false; + } + } + return matches.All(static node => node != null); + } + + internal static bool SupportsSyntax(SyntaxNode syntaxRoot, bool bodyConditionals = false) + { + if (syntaxRoot is not CompilationUnitSyntax root || root.ContainsDiagnostics || root.Externs.Count > 0 || + root.Members.Any(static m => m is GlobalStatementSyntax) || + root.DescendantNodes().Any(static n => n is InterfaceDeclarationSyntax) || + root.Usings.Any(static u => !u.StaticKeyword.IsKind(SyntaxKind.None)) || + (!bodyConditionals && ProviderConditionalSyntax.HasConditional(root))) + { + return false; + } + var namespaces = root.DescendantNodes().OfType().ToArray(); + return namespaces.Length <= 1 && !namespaces.Any(static n => n.Usings.Count > 0 || n.Externs.Count > 0) && + !(namespaces.Length > 0 && root.Members.Any(static m => m is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax)); + } + + internal bool TryPlan(SyntaxNode syntaxRoot, IReadOnlyList references, out PrefixPlan? plan) + { + plan = null; + if (!_conditional.TryGetRoots(syntaxRoot, out var roots) || roots.Any(root => !SupportsSyntax(root, bodyConditionals: roots.Length > 1))) + { + return false; + } + var root = (CompilationUnitSyntax)syntaxRoot; + var namespaces = root.DescendantNodes().OfType().ToArray(); + var importedNamespaces = new HashSet(StringComparer.Ordinal); + var blockedNames = new HashSet(StringComparer.Ordinal); + var usings = root.Usings.Concat(_globalUsings).ToArray(); + foreach (var import in usings) + { + if (!import.StaticKeyword.IsKind(SyntaxKind.None)) + { + return false; + } + if (import.Alias != null) + { + blockedNames.Add(import.Alias.Name.Identifier.ValueText); + continue; + } + if (import.Name == null || !TryNamespaceName(import.Name, out var name)) + { + return false; + } + importedNamespaces.Add(name); + } + // An alias affecting an import's first segment needs actual using binding. + if (importedNamespaces.Any(n => blockedNames.Contains(n.Split('.')[0]))) + { + return false; + } + + var namespaceName = string.Empty; + if (namespaces.Length == 1 && !TryNamespaceName(namespaces[0].Name, out namespaceName)) + { + return false; + } + var visibleNamespaces = new HashSet(importedNamespaces, StringComparer.Ordinal); + for (var ns = namespaceName; ;) + { + visibleNamespaces.Add(ns); + var separator = ns.LastIndexOf('.'); + if (separator < 0) + { + visibleNamespaces.Add(string.Empty); + break; + } + ns = ns[..separator]; + } + + foreach (var node in roots.SelectMany(static root => root.DescendantNodes())) + { + var identifier = node switch + { + BaseTypeDeclarationSyntax declaration => declaration.Identifier, + DelegateDeclarationSyntax declaration => declaration.Identifier, + MethodDeclarationSyntax declaration => declaration.Identifier, + LocalFunctionStatementSyntax declaration => declaration.Identifier, + PropertyDeclarationSyntax declaration => declaration.Identifier, + EventDeclarationSyntax declaration => declaration.Identifier, + VariableDeclaratorSyntax declaration => declaration.Identifier, + ParameterSyntax declaration => declaration.Identifier, + TypeParameterSyntax declaration => declaration.Identifier, + SingleVariableDesignationSyntax declaration => declaration.Identifier, + CatchDeclarationSyntax declaration => declaration.Identifier, + ForEachStatementSyntax declaration => declaration.Identifier, + EnumMemberDeclarationSyntax declaration => declaration.Identifier, + _ => default + }; + if (!identifier.IsKind(SyntaxKind.None)) + { + blockedNames.Add(identifier.ValueText); + } + } + + foreach (var declaration in roots.SelectMany(static root => root.DescendantNodes()).OfType()) + { + var containingNames = declaration.AncestorsAndSelf().OfType().Reverse().Select(static d => + d.Identifier.ValueText + (d is TypeDeclarationSyntax { TypeParameterList: { } parameters } ? $"`{parameters.Parameters.Count}" : string.Empty)); + var metadataName = string.Join("+", containingNames); + if (namespaceName.Length > 0) + { + metadataName = namespaceName + "." + metadataName; + } + var symbol = FindType(metadataName); + if (symbol == null) + { + return false; + } + for (var current = symbol; current != null; current = current.BaseType) + { + if (current.TypeKind == TypeKind.Error) + { + return false; + } + foreach (var parameter in current.TypeParameters) + { + blockedNames.Add(parameter.Name); + } + foreach (var member in current.GetMembers()) + { + blockedNames.Add(member.Name); + if (member is IMethodSymbol method) + { + foreach (var parameter in method.TypeParameters) + { + blockedNames.Add(parameter.Name); + } + } + } + } + } + + if (!TryMatchReferences(roots, references, out var matches)) + { + return false; + } + // Type-only lookup and expression lookup have different shadowing + // rules. Keep the original conservative type blockers unchanged. + var expressionBlockedNames = new HashSet(blockedNames, StringComparer.Ordinal) { "value", "field" }; + foreach (var node in roots.SelectMany(static root => root.DescendantNodes())) + { + var identifier = node switch + { + FromClauseSyntax clause => clause.Identifier, + LetClauseSyntax clause => clause.Identifier, + JoinClauseSyntax clause => clause.Identifier, + JoinIntoClauseSyntax clause => clause.Identifier, + QueryContinuationSyntax clause => clause.Identifier, + _ => default + }; + if (!identifier.IsKind(SyntaxKind.None)) expressionBlockedNames.Add(identifier.ValueText); + } + var decisions = new Dictionary<(string Namespace, string MetadataName, LookupKind Kind), Reduction>(); + var deletions = new List(); + for (var index = 0; index < matches.Length; index++) + { + var node = matches[index]; + var terminal = ReferenceName(node); + if (terminal == null) continue; + LookupKind kind; + if (references[index].Role == TypeReferenceRole.WholeTypeCref && node is QualifiedCrefSyntax) + kind = LookupKind.Cref; + else if (node.Parent is AttributeSyntax attribute && attribute.Name == node) + kind = LookupKind.Attribute; + else if (node.Parent is MemberAccessExpressionSyntax receiver && receiver.Expression == node) + kind = LookupKind.StaticReceiver; + else if (node is QualifiedNameSyntax name && IsSupportedTypePosition(name)) + kind = LookupKind.Type; + else + continue; + + var type = references[index].Type; + if (type.DeclaringType != null) continue; + var key = (type.Namespace, type.ClrMetadataName, kind); + if (!decisions.TryGetValue(key, out var reduction)) + { + reduction = kind == LookupKind.Attribute + ? ReduceAttribute(type, visibleNamespaces, blockedNames) + : new Reduction(CanShorten(type, visibleNamespaces, kind == LookupKind.StaticReceiver ? expressionBlockedNames : blockedNames)); + decisions.Add(key, reduction); + } + if (reduction.ShortenPrefix) + deletions.Add(TextSpan.FromBounds(node.SpanStart, terminal.SpanStart)); + if (reduction.OmitAttributeSuffix) + deletions.Add(new TextSpan(terminal.Identifier.Span.End - "Attribute".Length, "Attribute".Length)); + } + ProviderSyntaxCleanup.AddEdits(roots, _compilation, namespaceName, + usings.Where(static u => u.Alias != null).Select(static u => u.Alias!.Name.Identifier.ValueText), deletions); + deletions = deletions.Distinct().ToList(); + deletions.Sort(static (a, b) => a.Start.CompareTo(b.Start)); + var end = 0; + foreach (var deletion in deletions) + { + if (deletion.IsEmpty || deletion.Start < end || !root.FullSpan.Contains(deletion)) + { + return false; + } + end = deletion.End; + } + plan = new PrefixPlan(root.SyntaxTree.GetText(), deletions.ToArray(), decisions.Count); + return true; + } + + private INamedTypeSymbol? FindType(string metadataName) => _types.GetOrAdd(metadataName, _compilation.GetTypeByMetadataName); + + private ImmutableArray FindMembers(string namespaceName, string name) => _namespaceMembers.GetOrAdd((namespaceName, name), key => + { + INamespaceSymbol? current = _compilation.GlobalNamespace; + if (key.Namespace.Length > 0) + { + foreach (var part in key.Namespace.Split('.')) + { + current = current?.GetNamespaceMembers().FirstOrDefault(n => n.Name == part); + } + } + return current == null ? ImmutableArray.Empty : current.GetMembers(key.Name).Cast().ToImmutableArray(); + }); + + private bool CanShorten(CSharpType type, HashSet namespaces, HashSet blockedNames) + { + if (type.DeclaringType != null || blockedNames.Contains(type.Name)) + { + return false; + } + var metadataName = type.Namespace.Length == 0 ? type.ClrMetadataName : type.Namespace + "." + type.ClrMetadataName; + var target = FindType(metadataName); + // Compilation symbols also handle framework reference assemblies/type forwarding; + // runtime Type.Assembly identities need not match reference-assembly identities. + if (target == null || target.TypeKind == TypeKind.Error) + { + return false; + } + var found = false; + foreach (var ns in namespaces) + { + foreach (var member in FindMembers(ns, type.Name)) + { + if (member is not INamedTypeSymbol named) + { + return false; + } + if (named.Arity != target.Arity) + { + continue; + } + if (!SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, target.OriginalDefinition)) + { + return false; + } + found = true; + } + } + return found; + } + + private Reduction ReduceAttribute(CSharpType type, HashSet namespaces, HashSet blockedNames) + { + var target = FindType(type.Namespace.Length == 0 ? type.ClrMetadataName : type.Namespace + "." + type.ClrMetadataName); + var attribute = FindType("System.Attribute"); + if (target == null || target.TypeKind != TypeKind.Class || target.Arity != 0 || target.IsAbstract || attribute == null) + return default; + var derivesFromAttribute = false; + for (var current = target.BaseType; current != null; current = current.BaseType) + if (SymbolEqualityComparer.Default.Equals(current, attribute)) derivesFromAttribute = true; + if (!derivesFromAttribute) return default; + + var shortName = type.Name.EndsWith("Attribute", StringComparison.Ordinal) ? type.Name[..^"Attribute".Length] : string.Empty; + var canOmitSuffix = shortName.Length > 0 && SyntaxFacts.IsValidIdentifier(shortName) && + SyntaxFacts.GetKeywordKind(shortName) == SyntaxKind.None && + // Contextual keyword legality varies by occurrence (e.g. await in + // async local-function attributes). Keep suffix decisions cacheable + // across the entire document by conservatively retaining all of them. + SyntaxFacts.GetContextualKeywordKind(shortName) == SyntaxKind.None; + if (canOmitSuffix && CanUseAttributeName(target, shortName, namespaces, blockedNames)) + return new Reduction(true, true); + if (CanUseAttributeName(target, type.Name, namespaces, blockedNames)) + return new Reduction(true); + // A global-qualified short spelling does not consult local aliases or + // imported namespaces. Preserve qualification if only this form is safe. + return new Reduction(false, canOmitSuffix && CanUseAttributeName(target, shortName, new[] { type.Namespace }, null)); + } + + private bool CanUseAttributeName(INamedTypeSymbol target, string spelling, IEnumerable namespaces, HashSet? blockedNames) + { + var expanded = spelling + "Attribute"; + if (blockedNames != null && (blockedNames.Contains(spelling) || blockedNames.Contains(expanded))) return false; + var found = false; + foreach (var ns in namespaces) + { + foreach (var candidate in new[] { spelling, expanded }) + { + foreach (var member in FindMembers(ns, candidate)) + { + if (member is not INamedTypeSymbol named) return false; + if (named.Arity != 0) continue; + if (!SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, target.OriginalDefinition)) return false; + found = true; + } + } + } + return found; + } + + private static bool TryNamespaceName(NameSyntax syntax, out string name) + { + name = syntax.ToString(); + if (name.StartsWith("global::", StringComparison.Ordinal)) + { + name = name[8..]; + } + return !name.Contains("::", StringComparison.Ordinal) && name.Split('.').All(static part => SyntaxFacts.IsValidIdentifier(part)); + } + + internal sealed class PrefixPlan(SourceText source, TextSpan[] deletions, int decisionCount) + { + internal IReadOnlyList Deletions => deletions; + internal int DecisionCount => decisionCount; + + internal SourceText Apply() + { + if (deletions.Length == 0) + { + return source; + } + var input = source.ToString(); + var length = input.Length; + foreach (var deletion in deletions) + { + length -= deletion.Length; + } + var output = string.Create(length, (Input: input, Deletions: deletions), static (destination, state) => + { + var read = 0; + var written = 0; + foreach (var deletion in state.Deletions) + { + var count = deletion.Start - read; + state.Input.AsSpan(read, count).CopyTo(destination[written..]); + written += count; + read = deletion.End; + } + state.Input.AsSpan(read).CopyTo(destination[written..]); + }); + return SourceText.From(output, source.Encoding); + } + } + + // Ordinary type positions remain deliberately narrow. Static type receivers + // and attribute names use separate lookup rules above; no cast/cref rewrites. + private static bool IsSupportedTypePosition(NameSyntax node) => node.Parent switch + { + NullableTypeSyntax nullable => nullable.ElementType == node, + ArrayTypeSyntax array => array.ElementType == node, + PointerTypeSyntax pointer => pointer.ElementType == node, + RefTypeSyntax reference => reference.Type == node, + TupleElementSyntax tuple => tuple.Type == node, + TypeArgumentListSyntax => true, + ObjectCreationExpressionSyntax creation => creation.Type == node, + VariableDeclarationSyntax declaration => declaration.Type == node, + ParameterSyntax parameter => parameter.Type == node, + SimpleBaseTypeSyntax baseType => baseType.Type == node, + MethodDeclarationSyntax method => method.ReturnType == node, + LocalFunctionStatementSyntax method => method.ReturnType == node, + PropertyDeclarationSyntax property => property.Type == node, + IndexerDeclarationSyntax indexer => indexer.Type == node, + DelegateDeclarationSyntax declaration => declaration.ReturnType == node, + OperatorDeclarationSyntax declaration => declaration.ReturnType == node, + ConversionOperatorDeclarationSyntax declaration => declaration.Type == node, + TypeOfExpressionSyntax expression => expression.Type == node, + SizeOfExpressionSyntax expression => expression.Type == node, + DefaultExpressionSyntax expression => expression.Type == node, + DeclarationExpressionSyntax declaration => declaration.Type == node, + CatchDeclarationSyntax declaration => declaration.Type == node, + TypeConstraintSyntax constraint => constraint.Type == node, + DeclarationPatternSyntax pattern => pattern.Type == node, + TypePatternSyntax pattern => pattern.Type == node, + RecursivePatternSyntax pattern => pattern.Type == node, + BinaryExpressionSyntax expression when expression.IsKind(SyntaxKind.AsExpression) || expression.IsKind(SyntaxKind.IsExpression) => expression.Right == node, + _ => false + }; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs new file mode 100644 index 00000000000..9fa2cf61f64 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using static Microsoft.TypeSpec.Generator.ParenthesesSyntaxFacts; + +namespace Microsoft.TypeSpec.Generator +{ + internal static class ProviderSyntaxCleanup + { + internal static void AddEdits(IReadOnlyList roots, Compilation compilation, string namespaceName, + IEnumerable aliases, List edits) + { + var lexical = new HashSet(aliases, StringComparer.Ordinal) { "value", "field" }; + foreach (var root in roots) + foreach (var node in root.DescendantNodes()) + { + var token = node switch + { + ParameterSyntax p => p.Identifier, + TypeParameterSyntax p => p.Identifier, + BaseTypeDeclarationSyntax t => t.Identifier, + DelegateDeclarationSyntax d => d.Identifier, + LocalFunctionStatementSyntax f => f.Identifier, + VariableDeclaratorSyntax v when v.Parent?.Parent is not BaseFieldDeclarationSyntax => v.Identifier, + SingleVariableDesignationSyntax d => d.Identifier, + CatchDeclarationSyntax c => c.Identifier, + ForEachStatementSyntax f => f.Identifier, + FromClauseSyntax c => c.Identifier, + LetClauseSyntax c => c.Identifier, + JoinClauseSyntax c => c.Identifier, + JoinIntoClauseSyntax c => c.Identifier, + QueryContinuationSyntax c => c.Identifier, + _ => default + }; + if (!token.IsKind(SyntaxKind.None)) lexical.Add(token.ValueText); + } + var members = new Dictionary?>(StringComparer.Ordinal); + foreach (var root in roots) + foreach (var node in root.DescendantNodes()) + { + if (node is ParenthesizedExpressionSyntax expression && !expression.Ancestors().Any(static parent => parent is ParenthesizedExpressionSyntax)) + { + var chain = new List(); + ExpressionSyntax payload = expression; + while (payload is ParenthesizedExpressionSyntax wrapper) { chain.Add(wrapper); payload = wrapper.Expression; } + var removeAll = SafeExpression(payload) && CanRemove(expression, payload); + // Retained chains can be cast-disambiguating: ((f))(1). + if (!removeAll || !SafeBoundaries(chain[0].OpenParenToken, chain[^1].OpenParenToken, + payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) continue; + foreach (var wrapper in chain) + { + if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives || wrapper.OpenParenToken.IsMissing || wrapper.CloseParenToken.IsMissing) continue; + edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); + } + } + else if (node is ParenthesizedPatternSyntax pattern && !pattern.Ancestors().Any(static parent => parent is ParenthesizedPatternSyntax or ParenthesizedExpressionSyntax)) + { + var chain = new List(); + PatternSyntax payload = pattern; + while (payload is ParenthesizedPatternSyntax wrapper) { chain.Add(wrapper); payload = wrapper.Pattern; } + if (!SafePattern(payload)) continue; + var removeAll = pattern.Parent is IsPatternExpressionSyntax or CasePatternSwitchLabelSyntax or SwitchExpressionArmSyntax or SubpatternSyntax || + pattern.Parent is BinaryPatternSyntax binary && PatternPrecedence(payload) >= PatternPrecedence(binary) || + pattern.Parent is UnaryPatternSyntax && PatternPrecedence(payload) > 2; + if (!removeAll || !SafeBoundaries(chain[0].OpenParenToken, chain[^1].OpenParenToken, + payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) continue; + foreach (var wrapper in chain) + { + if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives) continue; + edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); + } + } + else if (node is MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: IdentifierNameSyntax name } member) + { + var identifier = name.Identifier.ValueText; + if (identifier == "Object" || lexical.Contains(identifier) || SyntaxFacts.GetContextualKeywordKind(identifier) != SyntaxKind.None || + member.Parent is InvocationExpressionSyntax call && call.Expression == member || member.ContainsDiagnostics || member.ContainsDirectives) continue; + var declaration = member.Ancestors().OfType().FirstOrDefault(); + if (declaration == null) continue; + var names = declaration.AncestorsAndSelf().OfType().Reverse().Select(static d => + d.Identifier.ValueText + (d.TypeParameterList is { } p ? "`" + p.Parameters.Count : string.Empty)); + var metadata = (namespaceName.Length == 0 ? string.Empty : namespaceName + ".") + string.Join("+", names); + if (!members.TryGetValue(metadata, out var eligible)) + { + var type = compilation.GetTypeByMetadataName(metadata); + eligible = EligibleMembers(type, compilation); + members.Add(metadata, eligible); + } + if (eligible?.Contains(identifier) != true) continue; + var prefix = TextSpan.FromBounds(member.SpanStart, name.SpanStart); + // Do not erase comments or directives attached to this/dot. + if (root.SyntaxTree.GetText().ToString(prefix) != "this.") continue; + edits.Add(prefix); + } + } + } + + private static HashSet? EligibleMembers(INamedTypeSymbol? type, Compilation compilation) + { + if (type == null || type.DeclaringSyntaxReferences.Any(static reference => + reference.GetSyntax().ChildNodes().OfType().Any())) return null; + var groups = new Dictionary>(StringComparer.Ordinal); + for (var current = type; current != null; current = current.BaseType) + { + if (current.TypeKind == TypeKind.Error) return null; + foreach (var member in current.GetMembers()) + { + if (!groups.TryGetValue(member.Name, out var list)) groups.Add(member.Name, list = new List()); + list.Add(member); + } + } + var result = new HashSet(StringComparer.Ordinal); + foreach (var (name, values) in groups) + { + if (values.Count != 1) continue; + var member = values[0]; + if (member.IsStatic || !compilation.IsSymbolAccessibleWithin(member, type)) continue; + if (member is IFieldSymbol || member is IPropertySymbol { IsIndexer: false, ExplicitInterfaceImplementations.Length: 0 }) result.Add(name); + } + return result; + } + + private static bool SafeBoundaries(SyntaxToken outerOpen, SyntaxToken innerOpen, SyntaxNode payload, SyntaxToken innerClose, SyntaxToken outerClose) + { + var first = payload.GetFirstToken(); var last = payload.GetLastToken(); + var previous = outerOpen.GetPreviousToken(); var next = outerClose.GetNextToken(); + var separatedLeft = previous.HasTrailingTrivia || outerOpen.HasLeadingTrivia || innerOpen.HasTrailingTrivia || first.HasLeadingTrivia; + var separatedRight = last.HasTrailingTrivia || innerClose.HasLeadingTrivia || outerClose.HasTrailingTrivia || next.HasLeadingTrivia; + return (separatedLeft || TokensStaySeparate(previous, first)) && (separatedRight || TokensStaySeparate(last, next)); + } + + private static bool SafeExpression(ExpressionSyntax expression) => !expression.ContainsDiagnostics && !expression.ContainsDirectives && + (expression is BinaryExpressionSyntax && Precedence(expression) > 0 || expression is IsPatternExpressionSyntax or CastExpressionSyntax or + ConditionalExpressionSyntax or IdentifierNameSyntax or LiteralExpressionSyntax or MemberAccessExpressionSyntax or InvocationExpressionSyntax or + ElementAccessExpressionSyntax or ObjectCreationExpressionSyntax or TypeOfExpressionSyntax or SizeOfExpressionSyntax or DefaultExpressionSyntax or ThisExpressionSyntax); + + private static bool CanRemove(ParenthesizedExpressionSyntax wrapper, ExpressionSyntax payload) + { + switch (wrapper.Parent) + { + case IfStatementSyntax s: return s.Condition == wrapper; + case WhileStatementSyntax s: return s.Condition == wrapper; + case DoStatementSyntax s: return s.Condition == wrapper; + case ForStatementSyntax s: return s.Condition == wrapper; + case ReturnStatementSyntax s: return s.Expression == wrapper; + case YieldStatementSyntax s: return s.Expression == wrapper; + case ThrowStatementSyntax s: return s.Expression == wrapper; + case SwitchStatementSyntax s: return s.Expression == wrapper; + case EqualsValueClauseSyntax s: return s.Value == wrapper; + case ArrowExpressionClauseSyntax s: return s.Expression == wrapper; + case CommonForEachStatementSyntax s: return s.Expression == wrapper; + case LockStatementSyntax s: return s.Expression == wrapper; + case UsingStatementSyntax s: return s.Expression == wrapper; + case CatchFilterClauseSyntax s: return s.FilterExpression == wrapper; + case CheckedExpressionSyntax s: return s.Expression == wrapper; + case ArgumentSyntax s: + return s.Expression == wrapper && s.Parent?.Parent is InvocationExpressionSyntax or ObjectCreationExpressionSyntax && + !payload.DescendantNodesAndSelf().Any(static n => n.IsKind(SyntaxKind.LessThanExpression) || n.IsKind(SyntaxKind.GreaterThanExpression)); + case AssignmentExpressionSyntax s: return s.Right == wrapper && Precedence(payload) > 1; + case ConditionalExpressionSyntax s: return s.Condition == wrapper && Precedence(payload) > 2; + case BinaryExpressionSyntax s: + var child = Precedence(payload); var parent = Precedence(s); + if (child <= 0 || parent <= 0) return false; + if (child != parent) return child > parent; + return s.IsKind(SyntaxKind.CoalesceExpression) ? s.Right == wrapper : s.Left == wrapper; + default: return false; + } + } + + private static bool SafePattern(PatternSyntax pattern) => pattern switch + { + ParenthesizedPatternSyntax p => SafePattern(p.Pattern), + BinaryPatternSyntax p => SafePattern(p.Left) && SafePattern(p.Right), + UnaryPatternSyntax p => SafePattern(p.Pattern), + RelationalPatternSyntax { Expression: LiteralExpressionSyntax } => true, + ConstantPatternSyntax { Expression: LiteralExpressionSyntax } => true, + TypePatternSyntax { Type: PredefinedTypeSyntax } => true, + _ => false + }; + private static int PatternPrecedence(PatternSyntax pattern) => pattern.Kind() switch + { + SyntaxKind.OrPattern => 1, + SyntaxKind.AndPattern => 2, + SyntaxKind.NotPattern => 3, + _ => 4 + }; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs new file mode 100644 index 00000000000..b7d2d9f76a5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.TypeSpec.Generator +{ + // Bounded attribute-name materialization and contract proof, not a general + // semantic-equivalence engine. Unknown operation shapes retain qualification. + internal static class StructuralAttributeRules + { + internal static NameSyntax? WithoutSuffix(NameSyntax name) + { + var terminal = name switch + { + IdentifierNameSyntax identifier => identifier, + QualifiedNameSyntax { Right: IdentifierNameSyntax identifier } => identifier, + AliasQualifiedNameSyntax { Name: IdentifierNameSyntax identifier } => identifier, + _ => null + }; + if (terminal == null) return null; + const string suffix = "Attribute"; + var token = terminal.Identifier; + if (!token.Text.EndsWith(suffix, StringComparison.Ordinal) || !token.ValueText.EndsWith(suffix, StringComparison.Ordinal) || + token.ValueText.Length == suffix.Length) return null; + var value = token.ValueText[..^suffix.Length]; + // Constructed identifiers can bind despite invalid serialized keyword + // syntax. Keep contextual forms too, including newer-consumer field. + if (SyntaxFacts.GetKeywordKind(value) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(value) != SyntaxKind.None || value == "field") return null; + var shortened = SyntaxFactory.IdentifierName(SyntaxFactory.Identifier(token.LeadingTrivia, SyntaxKind.None, + token.Text[..^suffix.Length], value, token.TrailingTrivia)); + return name.ReplaceNode(terminal, shortened); + } + + internal static NameSyntax? RoundTripName(NameSyntax name, CSharpParseOptions options) + { + var spelling = name.ToString(); + var parsed = SyntaxFactory.ParseCompilationUnit("[" + spelling + "] class __AttributeProof__ {}", options: options); + if (parsed.ContainsDiagnostics) return null; + var attributes = parsed.DescendantNodes().OfType().ToArray(); + if (attributes.Length != 1 || attributes[0].Name.ToString() != spelling) return null; + return attributes[0].Name; + } + + private static bool Symbol(ISymbol? left, ISymbol? right) => SymbolEqualityComparer.IncludeNullability.Equals(left, right); + + private static bool Conversion(CommonConversion left, CommonConversion right) => + left.Exists == right.Exists && left.IsIdentity == right.IsIdentity && left.IsNumeric == right.IsNumeric && + left.IsReference == right.IsReference && left.IsNullable == right.IsNullable && left.IsImplicit == right.IsImplicit && + left.IsUserDefined == right.IsUserDefined && Symbol(left.MethodSymbol, right.MethodSymbol); + + internal static bool SameContract(IOperation? left, IOperation? right) + { + if (left == null || right == null || left.Type?.TypeKind is TypeKind.Error or TypeKind.Dynamic or TypeKind.Pointer or TypeKind.FunctionPointer || + right.Type?.TypeKind is TypeKind.Error or TypeKind.Dynamic or TypeKind.Pointer or TypeKind.FunctionPointer || + left.Kind != right.Kind || !Symbol(left.Type, right.Type) || + left.IsImplicit != right.IsImplicit || left.ConstantValue.HasValue != right.ConstantValue.HasValue || + left.ConstantValue.HasValue && !Equals(left.ConstantValue.Value, right.ConstantValue.Value)) return false; + var same = (left, right) switch + { + (IAttributeOperation, IAttributeOperation) => true, + (IObjectCreationOperation a, IObjectCreationOperation b) => a.Constructor != null && Symbol(a.Constructor, b.Constructor), + (IArgumentOperation a, IArgumentOperation b) => a.ArgumentKind == b.ArgumentKind && a.Parameter != null && + Symbol(a.Parameter, b.Parameter) && a.Parameter.RefKind == b.Parameter?.RefKind && + Conversion(a.InConversion, b.InConversion) && Conversion(a.OutConversion, b.OutConversion), + (ISimpleAssignmentOperation a, ISimpleAssignmentOperation b) => a.IsRef == b.IsRef, + (IPropertyReferenceOperation a, IPropertyReferenceOperation b) => Symbol(a.Property, b.Property), + (IFieldReferenceOperation a, IFieldReferenceOperation b) => Symbol(a.Field, b.Field) && a.IsDeclaration == b.IsDeclaration, + (IParameterReferenceOperation a, IParameterReferenceOperation b) => Symbol(a.Parameter, b.Parameter), + (IMethodReferenceOperation a, IMethodReferenceOperation b) => Symbol(a.Method, b.Method), + (IInstanceReferenceOperation a, IInstanceReferenceOperation b) => a.ReferenceKind == b.ReferenceKind, + (IConversionOperation a, IConversionOperation b) => a.IsChecked == b.IsChecked && a.IsTryCast == b.IsTryCast && + Symbol(a.OperatorMethod, b.OperatorMethod) && Conversion(a.Conversion, b.Conversion), + (ITypeOfOperation a, ITypeOfOperation b) => Symbol(a.TypeOperand, b.TypeOperand), + (IUnaryOperation a, IUnaryOperation b) => a.OperatorKind == b.OperatorKind && a.IsChecked == b.IsChecked && + a.IsLifted == b.IsLifted && Symbol(a.OperatorMethod, b.OperatorMethod), + (IBinaryOperation a, IBinaryOperation b) => a.OperatorKind == b.OperatorKind && a.IsChecked == b.IsChecked && + a.IsLifted == b.IsLifted && Symbol(a.OperatorMethod, b.OperatorMethod), + (ILiteralOperation, ILiteralOperation) or (IDefaultValueOperation, IDefaultValueOperation) or + (INameOfOperation, INameOfOperation) or (IArrayCreationOperation, IArrayCreationOperation) or + (IArrayInitializerOperation, IArrayInitializerOperation) or + (IObjectOrCollectionInitializerOperation, IObjectOrCollectionInitializerOperation) or + (IParenthesizedOperation, IParenthesizedOperation) => true, + _ => false + }; + if (!same) return false; + var first = left.ChildOperations.GetEnumerator(); + var second = right.ChildOperations.GetEnumerator(); + while (first.MoveNext()) if (!second.MoveNext() || !SameContract(first.Current, second.Current)) return false; + return !second.MoveNext(); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs new file mode 100644 index 00000000000..74b3c43485c --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.TypeSpec.Generator +{ + // Original-compilation facts for the bounded whole-cref import-diagnostic + // certificate. Symbol equality alone does not preserve unused-import state. + internal static class StructuralCrefImportRules + { + internal static bool SupportsCompilation(Compilation compilation) + { + if (compilation is not CSharpCompilation csharp || csharp.ScriptCompilationInfo != null || + !csharp.Options.Usings.IsDefaultOrEmpty || + csharp.Options.GeneralDiagnosticOption != ReportDiagnostic.Default || csharp.Options.SpecificDiagnosticOptions.Count != 0) + return false; + foreach (var tree in compilation.SyntaxTrees) + { + if (tree.Options is not CSharpParseOptions { Kind: SourceCodeKind.Regular } options || + options.Features.Keys.Any(static key => key.IndexOf("interceptor", StringComparison.OrdinalIgnoreCase) >= 0) || + tree.GetRoot() is not CompilationUnitSyntax root) return false; + // Global imports can change diagnostics in OTHER documents. Aliases + // and static imports need an independent usage certificate. Inspect + // only import-bearing namespace containers, not method bodies. + if (UnsupportedImports(root.Usings, root.Externs.Count)) return false; + var pending = new Stack(root.Members.OfType()); + while (pending.TryPop(out var ns)) + { + if (UnsupportedImports(ns.Usings, ns.Externs.Count)) return false; + foreach (var nested in ns.Members.OfType()) pending.Push(nested); + } + } + return true; + } + + private static bool UnsupportedImports(SyntaxList imports, int externs) => externs != 0 || + imports.Any(static directive => directive.Alias != null || directive.StaticKeyword.IsKind(SyntaxKind.StaticKeyword) || + directive.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword)); + + internal static Compilation OriginalDiagnosticCompilation(Compilation compilation) + { + // AdhocWorkspace installs a provider even with no configured filters. + // Never infer unused-import safety from a potentially filtered bag. + // This immutable ORIGINAL-source view is shared once per reducer; no + // candidate tree is compiled, and no per-edit project clone is made. + var csharp = (CSharpCompilation)compilation; + return csharp.Options.SyntaxTreeOptionsProvider is not { } provider ? compilation : + csharp.WithOptions(csharp.Options.WithSyntaxTreeOptionsProvider(new UnfilteredDiagnosticOptions(provider))); + } + + private sealed class UnfilteredDiagnosticOptions(SyntaxTreeOptionsProvider original) : SyntaxTreeOptionsProvider + { + public override GeneratedKind IsGenerated(SyntaxTree tree, CancellationToken cancellationToken) => original.IsGenerated(tree, cancellationToken); + public override bool TryGetDiagnosticValue(SyntaxTree tree, string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = ReportDiagnostic.Default; + return false; + } + public override bool TryGetGlobalDiagnosticValue(string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = ReportDiagnostic.Default; + return false; + } + } + + internal static bool CanEraseTypeWithoutImportUse(TypeSyntax type, SemanticModel model) + { + if (type is PredefinedTypeSyntax || model.GetTypeInfo(type).Type is ITypeParameterSymbol) return true; + if (type is ArrayTypeSyntax array) return CanEraseTypeWithoutImportUse(array.ElementType, model); + if (type is NullableTypeSyntax nullable) return CanEraseTypeWithoutImportUse(nullable.ElementType, model); + if (type is TupleTypeSyntax tuple) return tuple.Elements.All(element => CanEraseTypeWithoutImportUse(element.Type, model)); + if (type is not NameSyntax name) return false; + NameSyntax root = name; + while (root is QualifiedNameSyntax qualified) root = qualified.Left; + if (root is not AliasQualifiedNameSyntax alias || alias.Alias.Identifier.Text != "global") return false; + // A rooted outer name is not enough: an unqualified generic argument + // can still consume an import, e.g. global::List. + return name.DescendantNodesAndSelf().OfType().All(generic => + generic.TypeArgumentList.Arguments.All(argument => CanEraseTypeWithoutImportUse(argument, model))); + } + + internal static bool SupportsOriginalTreeSourcePolicy(SyntaxTree tree) + { + if (tree.Options.DocumentationMode == DocumentationMode.None) return false; + var root = tree.GetRoot(); + return !root.ContainsDirectives || !root.DescendantTrivia(descendIntoTrivia: true).Any(static trivia => + trivia.IsKind(SyntaxKind.PragmaWarningDirectiveTrivia) || trivia.IsKind(SyntaxKind.LineSpanDirectiveTrivia)); + } + + internal static bool HasSafeOriginalTreeDiagnostics(SemanticModel model) + { + if (!SupportsOriginalTreeSourcePolicy(model.SyntaxTree)) return false; + // Deliberately request the COMPLETE original tree. A cref-sized span + // does not force Roslyn's unused-import accounting. Empty filtered or + // unsupported diagnostics are never treated as proof of usage. + // Retain on ANY original diagnostic, including hidden CS8019, rather + // than guessing which import one occurrence might consume. + return model.GetDiagnostics().Length == 0; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs new file mode 100644 index 00000000000..000b932512d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.TypeSpec.Generator.ParenthesesSyntaxFacts; + +namespace Microsoft.TypeSpec.Generator +{ + // Bounded, non-reassociating slice of Roslyn's MIT-licensed + // ParenthesizedExpressionSyntaxExtensions. No semantic inference/reassociation. + internal static class StructuralParenthesesRules + { + internal static bool SupportsTrivia(ParenthesizedExpressionSyntax wrapper) => + !wrapper.ContainsDiagnostics && !wrapper.ContainsDirectives && !wrapper.OpenParenToken.IsMissing && !wrapper.CloseParenToken.IsMissing && + wrapper.DescendantTrivia().All(static trivia => trivia.Kind() is SyntaxKind.WhitespaceTrivia or SyntaxKind.EndOfLineTrivia or + SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia); + + private static bool SupportsPayload(ExpressionSyntax payload) => !payload.ContainsDiagnostics && !payload.ContainsDirectives && + !payload.IsKind(SyntaxKind.DefaultLiteralExpression) && + (payload is BinaryExpressionSyntax && Precedence(payload) > 0 || payload is IsPatternExpressionSyntax or ConditionalExpressionSyntax or + IdentifierNameSyntax or LiteralExpressionSyntax or InvocationExpressionSyntax or ElementAccessExpressionSyntax or + ObjectCreationExpressionSyntax or TypeOfExpressionSyntax or SizeOfExpressionSyntax or ThisExpressionSyntax or CastExpressionSyntax || + payload is MemberAccessExpressionSyntax member && member.IsKind(SyntaxKind.SimpleMemberAccessExpression)); + + internal static bool CanRemove(ParenthesizedExpressionSyntax outer, ExpressionSyntax payload) + { + if (!SupportsPayload(payload)) return false; + // Always retain cast operands, receivers/targets and uncertain roles. + // In particular an alias-qualified cast type may already have shortened. + switch (outer.Parent) + { + case IfStatementSyntax node: return node.Condition == outer; + case WhileStatementSyntax node: return node.Condition == outer; + case DoStatementSyntax node: return node.Condition == outer; + case ForStatementSyntax node: return node.Condition == outer; + case ReturnStatementSyntax node: return node.Expression == outer; + case YieldStatementSyntax node: return node.Expression == outer && node.IsKind(SyntaxKind.YieldReturnStatement); + case ThrowStatementSyntax node: return node.Expression == outer; + case SwitchStatementSyntax node: return node.Expression == outer; + case EqualsValueClauseSyntax node: return node.Value == outer && node.Parent is not ParameterSyntax; + case ArrowExpressionClauseSyntax node: return node.Expression == outer; + case CommonForEachStatementSyntax node: return node.Expression == outer; + case LockStatementSyntax node: return node.Expression == outer; + case UsingStatementSyntax node: return node.Expression == outer; + case CatchFilterClauseSyntax node: return node.FilterExpression == outer; + case CheckedExpressionSyntax node: return node.Expression == outer; + case AssignmentExpressionSyntax node: return node.Right == outer && Precedence(payload) > 2; + case ConditionalExpressionSyntax node: return node.Condition == outer && Precedence(payload) > 2; + case ArgumentSyntax node: + return node.Expression == outer && node.RefKindKeyword.IsKind(SyntaxKind.None) && + node.Parent is ArgumentListSyntax list && list.Parent is InvocationExpressionSyntax or ObjectCreationExpressionSyntax; + case BinaryExpressionSyntax node: + // Relational comma-list ambiguities need more than precedence. + if (node.IsKind(SyntaxKind.LessThanExpression) || node.IsKind(SyntaxKind.GreaterThanExpression)) return false; + var child = Precedence(payload); var parent = Precedence(node); + if (child <= 0 || parent <= 0) return false; + if (child != parent) return child > parent; + return node.IsKind(SyntaxKind.CoalesceExpression) ? node.Right == outer : node.Left == outer; + default: return false; + } + } + + internal static ExpressionSyntax Remove(IReadOnlyList chain, ExpressionSyntax payload) + { + var leading = new List(); + foreach (var wrapper in chain) + { + leading.AddRange(wrapper.OpenParenToken.LeadingTrivia); + leading.AddRange(wrapper.OpenParenToken.TrailingTrivia); + } + leading.AddRange(payload.GetLeadingTrivia()); + var trailing = new List(payload.GetTrailingTrivia()); + for (var index = chain.Count - 1; index >= 0; index--) + { + trailing.AddRange(chain[index].CloseParenToken.LeadingTrivia); + trailing.AddRange(chain[index].CloseParenToken.TrailingTrivia); + } + return payload.WithLeadingTrivia(leading).WithTrailingTrivia(trailing); + } + + internal static bool KeepsTokenBoundaries(ParenthesizedExpressionSyntax outer, ExpressionSyntax replacement) + { + static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item.FullSpan.Length != 0 && + item.Kind() is SyntaxKind.WhitespaceTrivia or SyntaxKind.EndOfLineTrivia or SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia); + var previous = outer.OpenParenToken.GetPreviousToken(); + var next = outer.CloseParenToken.GetNextToken(); + return (Separator(previous.TrailingTrivia) || Separator(replacement.GetLeadingTrivia()) || TokensStaySeparate(previous, replacement.GetFirstToken())) && + (Separator(replacement.GetTrailingTrivia()) || Separator(next.LeadingTrivia) || TokensStaySeparate(replacement.GetLastToken(), next)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs new file mode 100644 index 00000000000..9e63552e96c --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs @@ -0,0 +1,1068 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; + +namespace Microsoft.TypeSpec.Generator +{ + // Compilation-wide immutable/thread-safe facts. Stateful traversal and lookup + // caches belong to a separate rewriter for EACH document. + internal sealed class StructuralTypeNameReducer + { + private sealed record Target(INamedTypeSymbol Symbol, string[] Parts); + private readonly Compilation _compilation; + private readonly ProviderConditionalSyntax _conditional; + private readonly ConcurrentDictionary _targets = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _thisMemberEligibility = new(SymbolEqualityComparer.IncludeNullability); + private readonly ConcurrentDictionary _attributeEligibility = new(SymbolEqualityComparer.Default); + private readonly ConcurrentDictionary _optionalDefaultTypes = new(SymbolEqualityComparer.IncludeNullability); + private readonly ConcurrentDictionary> _primaryConstructorParameters = new(SymbolEqualityComparer.Default); + private readonly ConcurrentDictionary _capturesCallerSource = new(SymbolEqualityComparer.Default); + private readonly Lazy _crefImportEnvironment; + private readonly Lazy _crefDiagnosticCompilation; + private readonly ConcurrentDictionary> _crefOriginalDiagnostics = new(); + private readonly ConcurrentDictionary> _crefSourcePolicies = new(); + + internal StructuralTypeNameReducer(Compilation compilation) + { + _compilation = compilation; + _conditional = new ProviderConditionalSyntax(compilation); + _crefImportEnvironment = new Lazy(() => StructuralCrefImportRules.SupportsCompilation(compilation)); + _crefDiagnosticCompilation = new Lazy(() => StructuralCrefImportRules.OriginalDiagnosticCompilation(compilation)); + } + + internal MemberRemoverRewriter? CreateRewriter(SyntaxNode root, IReadOnlyList references) + { + // Reuse the existing compilation-wide header/configuration contract. + // Conditional documents additionally prove body/header eligibility and + // reject generic local-function scope changes in every supported parse. + // Only active original syntax is visited; disabled text is untouched. + if (root.ContainsDiagnostics || !_conditional.IsTypeOnlyDocument(root)) return null; + // Provenance is unique across roles, not just within a selected role. + var uniqueSites = references.GroupBy(static site => site.Span).Where(static group => group.Count() == 1) + .Select(static group => group.Single()).ToArray(); + var sites = uniqueSites.Where(static site => site.Role == TypeReferenceRole.Code).ToDictionary(static site => site.Span); + var crefSites = uniqueSites.Where(static site => site.Role == TypeReferenceRole.WholeTypeCref).ToDictionary(static site => site.Span); + var canProveCallSource = _conditional.IsUnconditionalDocument(root); + // Syntax-only expression cleanup does not require type-writer metadata. + // No recorded sites still means no namespace/static name reductions. + return sites.Count == 0 && !canProveCallSource && !_conditional.TryGetRoots(root, out _) ? null : + new Rewriter(this, root.SyntaxTree, sites, canProveCallSource, crefSites); + } + + private Target? Resolve(CSharpType type) + { + for (var current = type; current != null; current = current.DeclaringType) + if (current != type && current.Arguments.Count != 0 || current.IsArray || current.IsFrameworkType && current.FrameworkType.IsGenericParameter) return null; + // ClrMetadataName contains the declaring-type chain, not its namespace. + // This is a canonical metadata lookup, never emitted-source rewriting. + var metadata = type.Namespace.Length == 0 ? type.ClrMetadataName : type.Namespace + "." + type.ClrMetadataName; + return _targets.GetOrAdd(metadata, name => + { + var symbol = _compilation.GetTypeByMetadataName(name); + if (symbol == null || symbol.TypeKind == TypeKind.Error) return null; + var parts = new Stack(); + for (var current = symbol; current != null; current = current.ContainingType) + { + if (current.Arity != 0 && !SymbolEqualityComparer.Default.Equals(current, symbol)) return null; + parts.Push(current.Name); + } + for (var ns = symbol.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) parts.Push(ns.Name); + return new Target(symbol, parts.ToArray()); + }); + } + + private sealed class Region + { + internal readonly StructuralTypeNameSelector.LookupFacts Facts; + internal readonly Dictionary Decisions = new(SymbolEqualityComparer.Default); + internal Dictionary? QualifierChoices; + internal Region(SemanticModel model, int position) => Facts = new(model, position); + } + + private sealed class Rewriter : MemberRemoverRewriter + { + private readonly StructuralTypeNameReducer _owner; + private readonly SyntaxTree _tree; + private readonly IReadOnlyDictionary _sites; + private readonly IReadOnlyDictionary _crefSites; + private readonly Dictionary _regions = new(); + private readonly bool _canProveCallSource; + private readonly List _calls = new(); + private readonly Dictionary _callFacts = new(); + private readonly Dictionary _conditionalStaticUnits = new(); + private SyntaxNode? _scope; + private bool _insideAttribute; + private bool _insideSourceObservableExpression; + private bool _insideParenthesesExcludedContext; + private readonly Dictionary _relationalArguments = new(); + private readonly List _parenthesesArgumentLists = new(); + private SyntaxNode[]? _conditionalParenthesesRoots; + private readonly List _thisValueContexts = new(); + private readonly Dictionary _dynamicThisContexts = new(); + private readonly Dictionary _thisContainingTypes = new(); + private bool _insideThisExcludedContext; + private SemanticModel? _model; + + internal Rewriter(StructuralTypeNameReducer owner, SyntaxTree tree, IReadOnlyDictionary sites, + bool canProveCallSource, IReadOnlyDictionary crefSites) : base(crefSites.Count != 0) + { + _owner = owner; _tree = tree; _sites = sites; _canProveCallSource = canProveCallSource; _crefSites = crefSites; + } + + public override SyntaxTrivia VisitTrivia(SyntaxTrivia trivia) => trivia.HasStructure && + trivia.GetStructure() is not DocumentationCommentTriviaSyntax ? trivia : base.VisitTrivia(trivia); + + private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) + { + internal static readonly SourceObservation Retain = new(true, Array.Empty()); + internal static readonly SourceObservation Unobserved = new(false, Array.Empty()); + } + + public override SyntaxNode? Visit(SyntaxNode? node) + { + if (node == null) return null; + // Whole-cref proof owns this context. Never send unsupported or + // unrecorded member-cref children through ordinary code-name rules. + if (node is CrefSyntax cref) return TryReduceWholeTypeCref(cref) ?? cref; + var previous = _scope; + var previousAttribute = _insideAttribute; + var previousSourceObservableExpression = _insideSourceObservableExpression; + var previousParenthesesExcludedContext = _insideParenthesesExcludedContext; + var previousCallDepth = _calls.Count; + var previousArgumentDepth = _parenthesesArgumentLists.Count; + var previousValueDepth = _thisValueContexts.Count; + var previousThisExcludedContext = _insideThisExcludedContext; + if (node is TypeDeclarationSyntax or BaseMethodDeclarationSyntax or LocalFunctionStatementSyntax or DelegateDeclarationSyntax or + BaseListSyntax or TypeParameterConstraintClauseSyntax or ExplicitInterfaceSpecifierSyntax) _scope = node; + if (node is AttributeSyntax) _insideAttribute = true; + if (node is ParameterSyntax or ConstructorInitializerSyntax || + node is EqualsValueClauseSyntax initializer && (initializer.Parent is PropertyDeclarationSyntax || + initializer.Parent?.Parent?.Parent is BaseFieldDeclarationSyntax) || + node is BaseMethodDeclarationSyntax methodDeclaration && methodDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || + node is LocalFunctionStatementSyntax localDeclaration && localDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || + node is PropertyDeclarationSyntax propertyDeclaration && propertyDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)) + _insideThisExcludedContext = true; + if (node is BinaryExpressionSyntax or AssignmentExpressionSyntax or PrefixUnaryExpressionSyntax or PostfixUnaryExpressionSyntax or + CastExpressionSyntax or ConditionalExpressionSyntax or AwaitExpressionSyntax) + _thisValueContexts.Add((ExpressionSyntax)node); + if (node is ParameterSyntax or PatternSyntax or QueryExpressionSyntax or WhenClauseSyntax || + node is ArgumentSyntax argumentContext && !argumentContext.RefKindKeyword.IsKind(SyntaxKind.None)) + _insideParenthesesExcludedContext = true; + if (node is ArgumentListSyntax argumentList) _parenthesesArgumentLists.Add(argumentList); + // Keep hidden-call/conditional contexts conservative. Ordinary call + // source is checked lazily once per ORIGINAL call, before name caches. + if (node is ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or InitializerExpressionSyntax or + InterpolatedStringExpressionSyntax or CollectionExpressionSyntax) _insideSourceObservableExpression = true; + if (!_insideAttribute && !_insideSourceObservableExpression) + { + SyntaxNode? call = node as InvocationExpressionSyntax; + if (node is ArgumentSyntax argument) + { + call = argument.Parent is ArgumentListSyntax list && list.Parent is InvocationExpressionSyntax or + ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax ? list.Parent : null; + if (call == null) _insideSourceObservableExpression = true; + } + if (call != null) + { + // A receiver-local's type can vary across framework branches + // even when headers/type lookup remain invariant. Original- + // configuration call facts alone cannot prove that case. + if (!_canProveCallSource) _insideSourceObservableExpression = true; + else if (_calls.Count == 0 || !ReferenceEquals(_calls[^1], call)) _calls.Add(call); + } + } + try + { + if (node is DefaultExpressionSyntax optionalDefault) + { + // Decide whole-expression erasure before visiting children. + // A new import veto must retain original source, not expose + // child rewrites that C5 discarded when erasing this operand. + var replacement = TryReduceOptionalDefault(optionalDefault, out var retainOriginal); + return replacement ?? (retainOriginal ? optionalDefault : base.Visit(node)); + } + if (node is AttributeSyntax attribute) + { + // Arguments remain under the existing attribute exclusion. + // Only the complete original attribute's name may change. + var visited = (AttributeSyntax)base.Visit(node)!; + return TryReduceAttributeName(attribute) ?? visited; + } + if (!_insideAttribute && !_insideSourceObservableExpression && node is NameSyntax name && _scope != null && + _sites.TryGetValue(ReferenceSpan(name), out var site) && IsTypeOnlyContext(name)) + { + // Namespace selection preserves the current terminal subtree. + // Visit arguments first in this SAME traversal; retention must + // not discard independently proven child changes. + if (Terminal(name) is GenericNameSyntax) + { + var visited = (NameSyntax)base.Visit(node)!; + return TryReduce(name, site, visited)?.WithTriviaFrom(name) ?? visited; + } + var rewritten = TryReduce(name, site); + if (rewritten != null) return rewritten.WithTriviaFrom(name); + } + if (!_insideAttribute && _scope != null && + node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAccessExpressionSyntax member && + member.IsKind(SyntaxKind.SimpleMemberAccessExpression) && member.Expression == node && + _sites.TryGetValue(ReferenceSpan(node), out var receiverSite) && + (_canProveCallSource && !_insideSourceObservableExpression || + !_canProveCallSource && CanProveConditionalStaticReceiver((ExpressionSyntax)node, member, receiverSite))) + { + var visited = (ExpressionSyntax)base.Visit(node)!; + return TryReduceStaticReceiver((ExpressionSyntax)node, member, receiverSite, visited, conditionalSource: !_canProveCallSource)?.WithTriviaFrom(node) ?? visited; + } + if (!_insideAttribute && !_insideSourceObservableExpression && !_insideThisExcludedContext && _canProveCallSource && _scope != null && + node is MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: IdentifierNameSyntax } thisMember && + thisMember.IsKind(SyntaxKind.SimpleMemberAccessExpression)) + { + var visited = (MemberAccessExpressionSyntax)base.Visit(node)!; + return (ExpressionSyntax?)TryReduceThisMember(thisMember, (IdentifierNameSyntax)visited.Name) ?? visited; + } + return base.Visit(node); + } + finally + { + _scope = previous; + _insideAttribute = previousAttribute; + _insideSourceObservableExpression = previousSourceObservableExpression; + _insideParenthesesExcludedContext = previousParenthesesExcludedContext; + if (_calls.Count > previousCallDepth) _calls.RemoveRange(previousCallDepth, _calls.Count - previousCallDepth); + if (_parenthesesArgumentLists.Count > previousArgumentDepth) + _parenthesesArgumentLists.RemoveRange(previousArgumentDepth, _parenthesesArgumentLists.Count - previousArgumentDepth); + if (_thisValueContexts.Count > previousValueDepth) _thisValueContexts.RemoveRange(previousValueDepth, _thisValueContexts.Count - previousValueDepth); + _insideThisExcludedContext = previousThisExcludedContext; + } + } + + public override SyntaxNode? VisitParenthesizedExpression(ParenthesizedExpressionSyntax node) + { + if (_scope == null || _insideAttribute || _insideSourceObservableExpression || _insideParenthesesExcludedContext || + HasRelationalArgumentContext()) return base.VisitParenthesizedExpression(node); + + var chain = new List(); + ExpressionSyntax payload = node; + while (payload is ParenthesizedExpressionSyntax wrapper) + { + chain.Add(wrapper); + payload = wrapper.Expression; + } + // Direct chains are one operation. Never independently remove an + // inner barrier and leave ((f))(1) serialized as the cast-like (f)(1). + var visited = (ExpressionSyntax)Visit(payload)!; + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.Attempt", TimeSpan.Zero, 0); + var supported = StructuralParenthesesRules.SupportsTrivia(node) && chain.All(static wrapper => + !wrapper.OpenParenToken.IsMissing && !wrapper.CloseParenToken.IsMissing) && + StructuralParenthesesRules.CanRemove(node, visited); + if (supported && !_canProveCallSource) supported = CanRemoveConditionalParentheses(node, visited); + if (supported) + { + // New cast-payload admission requires visible ORIGINAL + // delimiters. Keep pre-existing non-cast policies unchanged. + foreach (var wrapper in chain) + if (visited is CastExpressionSyntax && + (_tree.GetLineVisibility(wrapper.OpenParenToken.SpanStart) != LineVisibility.Visible || + _tree.GetLineVisibility(wrapper.CloseParenToken.SpanStart) != LineVisibility.Visible) || + !AllowsSourceChange(wrapper.OpenParenToken.Span) || !AllowsSourceChange(wrapper.CloseParenToken.Span)) + { + supported = false; + break; + } + } + if (supported) + { + var replacement = StructuralParenthesesRules.Remove(chain, visited); + if (StructuralParenthesesRules.KeepsTokenBoundaries(node, replacement)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.Removed", TimeSpan.Zero, 0); + return replacement; + } + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.Retained", TimeSpan.Zero, 0); + ExpressionSyntax retained = visited; + for (var index = chain.Count - 1; index >= 0; index--) retained = chain[index].WithExpression(retained); + return retained; + } + + private bool CanRemoveConditionalParentheses(ParenthesizedExpressionSyntax original, ExpressionSyntax payload) + { + // These parses prove source correspondence and grammar, NOT value + // bindings. Conditional call/static/this permissions remain opaque. + // CreateRewriter already required IsTypeOnlyDocument, including its + // generic-local-function veto; TryGetRoots alone is not that proof. + if (_conditionalParenthesesRoots == null && !_owner._conditional.TryGetRoots(_tree.GetRoot(), out _conditionalParenthesesRoots)) return false; + if (original.Parent == null) return false; + var role = original.Parent.ChildNodes().TakeWhile(child => child != original).Count(); + foreach (var root in _conditionalParenthesesRoots!) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.ConfigurationCheck", TimeSpan.Zero, 0); + if (root.FindNode(original.Span, getInnermostNodeForTie: true) is not ParenthesizedExpressionSyntax candidate || + candidate.Span != original.Span || !SyntaxFactory.AreEquivalent(original, candidate) || candidate.Parent == null || + candidate.Parent.RawKind != original.Parent.RawKind || candidate.Parent.Span != original.Parent.Span || + candidate.Parent.ChildNodes().TakeWhile(child => child != candidate).Count() != role) return false; + // Empty original call frames are NOT evidence of source safety: + // conditional calls intentionally have no observer frames. Check + // every variant's entire ancestry, including outside the span. + if (candidate.Ancestors().Any(static ancestor => ancestor is InvocationExpressionSyntax or ArgumentSyntax or + ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax or + AttributeSyntax or ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or InitializerExpressionSyntax or + InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyntax or PatternSyntax or + QueryExpressionSyntax or WhenClauseSyntax)) return false; + if (!StructuralParenthesesRules.SupportsTrivia(candidate) || !StructuralParenthesesRules.CanRemove(candidate, payload)) return false; + var chain = new List(); + ExpressionSyntax current = candidate; + while (current is ParenthesizedExpressionSyntax wrapper) + { + if (wrapper.OpenParenToken.IsMissing || wrapper.CloseParenToken.IsMissing || + root.SyntaxTree.GetLineVisibility(wrapper.OpenParenToken.SpanStart) != LineVisibility.Visible || + root.SyntaxTree.GetLineVisibility(wrapper.CloseParenToken.SpanStart) != LineVisibility.Visible) return false; + chain.Add(wrapper); + current = wrapper.Expression; + } + // Use the variant's wrappers/neighbouring tokens, and the FINAL + // visited original payload. Only original-tree output is emitted. + var replacement = StructuralParenthesesRules.Remove(chain, payload); + if (!StructuralParenthesesRules.KeepsTokenBoundaries(candidate, replacement)) return false; + } + return true; + } + + private ExpressionSyntax? TryReduceOptionalDefault(DefaultExpressionSyntax expression, out bool retainOriginal) + { + retainOriginal = false; + if (!_canProveCallSource || _scope == null || _insideAttribute || _insideSourceObservableExpression || + _tree.Options is not CSharpParseOptions options || options.LanguageVersion < LanguageVersion.CSharp7_1 || + expression.Parent is not EqualsValueClauseSyntax clause || clause.Value != expression || + clause.Parent is not ParameterSyntax parameter || parameter.Default != clause || + parameter.Parent is not ParameterListSyntax list || list.Parent is not + (MethodDeclarationSyntax or ConstructorDeclarationSyntax or DelegateDeclarationSyntax or LocalFunctionStatementSyntax) || + expression.ContainsDiagnostics || expression.ContainsDirectives) return null; + var lines = _tree.GetLineSpan(expression.Span); + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(expression.SpanStart) != LineVisibility.Visible || + expression.DescendantTrivia().Any(trivia => trivia.Span.End > expression.SpanStart && trivia.SpanStart < expression.Span.End && + !trivia.IsKind(SyntaxKind.WhitespaceTrivia) && !trivia.IsKind(SyntaxKind.EndOfLineTrivia)) || + !AllowsSourceChange(expression.Span)) return null; + _model ??= _owner._compilation.GetSemanticModel(_tree); + if (_model.GetDeclaredSymbol(parameter) is not IParameterSymbol symbol || !symbol.IsOptional || !symbol.HasExplicitDefaultValue || + symbol.RefKind != RefKind.None || symbol.IsParams || symbol.IsThis || symbol.ContainingSymbol is not IMethodSymbol method || + method.MethodKind is not (MethodKind.Ordinary or MethodKind.Constructor or MethodKind.DelegateInvoke or MethodKind.LocalFunction)) return null; + var operand = _model.GetTypeInfo(expression.Type).Type; + // The declaration supplies the target type. This is Roslyn's + // exact-declared-type fast path, never its overload-sensitive fallback. + // Null metadata values are valid for some struct/generic defaults. + if (operand == null || !SymbolEqualityComparer.IncludeNullability.Equals(symbol.Type, operand) || + !_owner._optionalDefaultTypes.GetOrAdd(symbol.Type, SupportsOptionalDefaultType)) return null; + // Neither original diagnostic absence nor a reduction elsewhere + // proves a final surviving import use. Erase only import-independent + // type syntax, or prove this parameter's identical declaration is an + // unchanged witness. This also covers newly withheld handler uses. + var canEraseType = StructuralCrefImportRules.CanEraseTypeWithoutImportUse(expression.Type, _model) || + HasSurvivingParameterTypeImportWitness(parameter, expression.Type); + if (canEraseType) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralOptionalDefaults.Attempt", TimeSpan.Zero, 0); + var replacement = SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)).WithTriviaFrom(expression); + static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item.FullSpan.Length != 0 && + item.Kind() is SyntaxKind.WhitespaceTrivia or SyntaxKind.EndOfLineTrivia or SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia); + var previous = expression.GetFirstToken().GetPreviousToken(); + var next = expression.GetLastToken().GetNextToken(); + if (!Separator(previous.TrailingTrivia) && !Separator(replacement.GetLeadingTrivia()) && + !ParenthesesSyntaxFacts.TokensStaySeparate(previous, replacement.GetFirstToken()) || + !Separator(replacement.GetTrailingTrivia()) && !Separator(next.LeadingTrivia) && + !ParenthesesSyntaxFacts.TokensStaySeparate(replacement.GetLastToken(), next)) return null; + if (!canEraseType) + { + // Every original C5 eligibility check passed. Only this new + // veto freezes the subtree; originally ineligible expressions + // still receive their independently supported child rewrites. + retainOriginal = true; + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralOptionalDefaults.Accepted", TimeSpan.Zero, 0); + return replacement; + } + + private bool HasSurvivingParameterTypeImportWitness(ParameterSyntax parameter, TypeSyntax erasedType) + { + if (parameter.Type is not { } declaration || !SyntaxFactory.AreEquivalent(declaration, erasedType) || + declaration.ToString() != erasedType.ToString()) return false; + // The exact declared/operand symbols and annotations were already + // checked. In this parameter context only recorded code-name sites + // can rewrite declaration type tokens; do not assume the original + // spelling survives when any such occurrence can be visited. + return _sites.Count == 0 || !declaration.DescendantNodesAndSelf().OfType() + .Any(name => _sites.ContainsKey(ReferenceSpan(name))); + } + + private static bool SupportsOptionalDefaultType(ITypeSymbol type) + { + var seen = new HashSet(SymbolEqualityComparer.IncludeNullability); + bool VisitType(ITypeSymbol current) + { + if (!seen.Add(current)) return true; + return current switch + { + IErrorTypeSymbol or IDynamicTypeSymbol or IPointerTypeSymbol or IFunctionPointerTypeSymbol => false, + INamedTypeSymbol named => named.SpecialType != SpecialType.System_Void && !named.IsUnboundGenericType && !named.IsRefLikeType && + (named.ContainingType == null || VisitType(named.ContainingType)) && named.TypeArguments.All(VisitType), + IArrayTypeSymbol array => VisitType(array.ElementType), + ITypeParameterSymbol parameter => parameter.ConstraintTypes.All(VisitType), + _ => false + }; + } + return VisitType(type); + } + + private AttributeSyntax? TryReduceAttributeName(AttributeSyntax attribute) + { + var name = attribute.Name; + if (!_canProveCallSource || _scope == null || _insideSourceObservableExpression || attribute.ContainsDiagnostics || attribute.ContainsDirectives || + !_sites.TryGetValue(name.Span, out var site) || site.Type.IsArray || site.Type.IsNullable) return null; + var lines = _tree.GetLineSpan(name.Span); + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(name.SpanStart) != LineVisibility.Visible || + !AllowsSourceChange(name.Span)) return null; + var target = _owner.Resolve(site.Type); + if (target == null) return null; + for (var type = target.Symbol; type != null; type = type.ContainingType) if (type.Arity != 0) return null; + if (!_owner._attributeEligibility.GetOrAdd(target.Symbol, type => + { + if (type.TypeKind != TypeKind.Class || type.IsAbstract || !HasUsableTypeGraph(type)) return false; + var baseType = _owner._compilation.GetTypeByMetadataName("System.Attribute"); + if (baseType == null) return false; + for (var current = type.BaseType; current != null; current = current.BaseType) + if (SymbolEqualityComparer.Default.Equals(current, baseType)) return true; + return false; + })) return null; + var source = DescribeRecordedReference(name, target); + if (source == null || source.Parts.Any(static part => part is not IdentifierNameSyntax)) return null; + _model ??= _owner._compilation.GetSemanticModel(_tree); + var original = _model.GetSymbolInfo(attribute); + if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || + original.Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } constructor || + !SymbolEqualityComparer.IncludeNullability.Equals(constructor.ContainingType, target.Symbol)) return null; + var operation = _model.GetOperation(attribute); + if (!StructuralAttributeRules.SameContract(operation, operation)) return null; + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.Attempt", TimeSpan.Zero, 0); + AttributeSyntax? replacement = null; + var facts = new StructuralTypeNameSelector.LookupFacts(_model, name.SpanStart); + var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model, name, constructor.ContainingType, + SharedFacts: facts, KnownDeclarationType: true, Source: source, PreserveTerminalArguments: true, + ModeOverride: StructuralTypeNameSelector.Mode.Type); + StructuralTypeNameSelector.Choose(new[] { context }, plan => + { + var qualified = plan.Render(source); + if (qualified == null) return false; + bool Accept(NameSyntax candidateName) + { + if (candidateName.ToString() == name.ToString()) return false; + var parsedName = StructuralAttributeRules.RoundTripName(candidateName, (CSharpParseOptions)_tree.Options); + if (parsedName == null) return false; + var candidate = SyntaxFactory.Attribute(parsedName.WithTriviaFrom(name), attribute.ArgumentList).WithTriviaFrom(attribute); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.SpeculativeCheck", TimeSpan.Zero, 0); + if (!_model.TryGetSpeculativeSemanticModel(attribute.SpanStart, candidate, out var speculative) || speculative == null) return false; + var rebound = speculative.GetSymbolInfo(candidate); + if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || + !SymbolEqualityComparer.IncludeNullability.Equals(constructor, rebound.Symbol) || + !StructuralAttributeRules.SameContract(operation, speculative.GetOperation(candidate))) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.BindingRejected", TimeSpan.Zero, 0); + return false; + } + replacement = candidate; + return true; + } + var abbreviated = StructuralAttributeRules.WithoutSuffix(qualified); + return abbreviated != null && Accept(abbreviated) || Accept(qualified); + }); + if (replacement != null) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.Accepted", TimeSpan.Zero, 0); + return replacement; + } + + private CrefSyntax? TryReduceWholeTypeCref(CrefSyntax cref) + { + if (!_canProveCallSource || _scope == null || _insideAttribute || _insideSourceObservableExpression || + !_crefSites.TryGetValue(cref.Span, out var site) || site.Type.IsArray || site.Type.IsNullable || + cref is not QualifiedCrefSyntax { Member: NameMemberCrefSyntax { Name: IdentifierNameSyntax, Parameters: null } } || + cref.ContainsDiagnostics || cref.ContainsDirectives) return null; + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.Attempt", TimeSpan.Zero, 0); + var target = _owner.Resolve(site.Type); + if (target == null) return null; + for (var type = target.Symbol; type != null; type = type.ContainingType) if (type.Arity != 0) return null; + var source = DescribeRecordedReference(cref, target); + var lines = _tree.GetLineSpan(cref.Span); + if (source == null || source.Parts.Any(static part => part is not IdentifierNameSyntax) || + lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(cref.SpanStart) != LineVisibility.Visible || + !AllowsSourceChange(cref.Span)) return null; + _model ??= _owner._compilation.GetSemanticModel(_tree); + var original = _model.GetSymbolInfo(cref); + if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || + original.Symbol is not INamedTypeSymbol construction || !HasUsableTypeGraph(construction) || + !SymbolEqualityComparer.IncludeNullability.Equals(construction, target.Symbol)) return null; + // A shorter cref can consume a previously unused import while + // preserving its symbol and emitted XML. Use original complete-tree + // facts, shared once per tree, before selecting any new spelling. + var importCertificate = HasCrefImportCertificate(); + // A top-level type directly in the attachment's immediate + // namespace is found before namespace imports. This alternate + // permission applies ONLY to its bare-name plan below. + if (!importCertificate && !HasImmediateNamespaceCrefCertificate(cref, construction)) return null; + CrefSyntax? replacement = null; + var facts = new StructuralTypeNameSelector.LookupFacts(_model, cref.SpanStart); + var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model, cref, construction, + SharedFacts: facts, KnownDeclarationType: true, Source: source, PreserveTerminalArguments: true, + ModeOverride: StructuralTypeNameSelector.Mode.Cref); + StructuralTypeNameSelector.Choose(new[] { context }, plan => + { + var name = plan.RenderCrefName(source); + if (name == null || name.ToString() == cref.ToString()) return false; + if (!importCertificate && (name is not IdentifierNameSyntax identifier || + identifier.Identifier.ValueText != construction.Name)) return false; + // Parse only this prospective XML spelling. A forced TypeCref + // AST can select a type even when serialized bare T reparses as + // a NameMemberCref and resolves a same-named method instead. + var candidate = ParseWholeCref(name); + if (candidate == null) return false; + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.SpeculativeCheck", TimeSpan.Zero, 0); + var rebound = _model.GetSpeculativeSymbolInfo(cref.SpanStart, candidate, SpeculativeBindingOption.BindAsTypeOrNamespace); + if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || + !SymbolEqualityComparer.IncludeNullability.Equals(construction, rebound.Symbol)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.BindingRejected", TimeSpan.Zero, 0); + return false; + } + replacement = candidate.WithTriviaFrom(cref); + return true; + }); + if (replacement != null) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.Accepted", TimeSpan.Zero, 0); + return replacement; + } + + private bool HasImmediateNamespaceCrefCertificate(CrefSyntax cref, INamedTypeSymbol target) + { + if (target.ContainingType != null || !_owner._crefImportEnvironment.Value || + !_owner._crefSourcePolicies.GetOrAdd(_tree, + tree => new Lazy(() => StructuralCrefImportRules.SupportsOriginalTreeSourcePolicy(tree))).Value) + return false; + var documentation = cref.Ancestors().OfType().FirstOrDefault(); + if (documentation == null) return false; + var token = documentation.ParentTrivia.Token; + if (!token.LeadingTrivia.Contains(documentation.ParentTrivia)) return false; + var declaration = token.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); + // In particular, documentation attached to a namespace is not + // documentation inside that namespace. Keep uncertain owners out. + if (declaration is not TypeDeclarationSyntax and not BaseMethodDeclarationSyntax and + not BasePropertyDeclarationSyntax and not BaseFieldDeclarationSyntax) return false; + var declared = _model!.GetDeclaredSymbol(declaration); + if (declared == null) return false; + var namespaceDeclaration = declaration.Ancestors().OfType().FirstOrDefault(); + var immediateNamespace = namespaceDeclaration == null ? _owner._compilation.GlobalNamespace : + _model.GetDeclaredSymbol(namespaceDeclaration); + return SymbolEqualityComparer.Default.Equals(immediateNamespace, target.ContainingNamespace) && + SymbolEqualityComparer.Default.Equals(declared.ContainingNamespace, target.ContainingNamespace); + } + + private bool HasCrefImportCertificate() => _owner._crefImportEnvironment.Value && + _owner._crefOriginalDiagnostics.GetOrAdd(_tree, + tree => new Lazy(() => StructuralCrefImportRules.HasSafeOriginalTreeDiagnostics( + _owner._crefDiagnosticCompilation.Value.GetSemanticModel(tree)))).Value; + + private CrefSyntax? ParseWholeCref(NameSyntax name) + { + var spelling = name.ToString(); + var options = ((CSharpParseOptions)_tree.Options).WithDocumentationMode(DocumentationMode.Diagnose); + var parsed = SyntaxFactory.ParseCompilationUnit("/// \nclass __CrefProof__ {}", options: options); + if (parsed.ContainsDiagnostics) return null; + var attributes = parsed.DescendantNodes(descendIntoTrivia: true).OfType().ToArray(); + if (attributes.Length != 1 || attributes[0].Cref.ToString() != spelling) return null; + // Reconstruct the COMPLETE root to detach it from synthetic XML; + // token spelling and the actual parser-selected shape are retained. + return attributes[0].Cref switch + { + QualifiedCrefSyntax qualified => SyntaxFactory.QualifiedCref(qualified.Container, qualified.Member), + NameMemberCrefSyntax { Parameters: null } member => SyntaxFactory.NameMemberCref(member.Name), + TypeCrefSyntax type => SyntaxFactory.TypeCref(type.Type), + _ => null + }; + } + + private bool HasRelationalArgumentContext() + { + // Only a candidate requests this whole-list fact; ordinary calls + // without parentheses do not trigger a subtree scan. Preserve all + // enclosing lists' sibling protection and memoize each original list. + foreach (var list in _parenthesesArgumentLists) + { + if (!_relationalArguments.TryGetValue(list, out var relational)) + { + relational = list.DescendantNodes().Any(static child => child.IsKind(SyntaxKind.LessThanExpression) || child.IsKind(SyntaxKind.GreaterThanExpression)); + _relationalArguments.Add(list, relational); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.ArgumentListScan", TimeSpan.Zero, 0); + } + if (relational) return true; + } + return false; + } + + private static SimpleNameSyntax Terminal(SyntaxNode name) => name switch + { + QualifiedNameSyntax qualified => qualified.Right, + AliasQualifiedNameSyntax alias => alias.Name, + SimpleNameSyntax simple => simple, + MemberAccessExpressionSyntax member => member.Name, + _ => throw new InvalidOperationException("Unsupported name shape") + }; + + // CodeWriter records the identifier prefix, before or []/?. + private static TextSpan ReferenceSpan(SyntaxNode name) => TextSpan.FromBounds(name.SpanStart, Terminal(name).Identifier.Span.End); + + private NameSyntax? TryReduce(NameSyntax node, TypeReferenceSite site, NameSyntax? visited = null) + { + var target = _owner.Resolve(site.Type); + if (target == null) return null; + var source = DescribeRecordedReference(node, target); + if (source == null || !AllowsSourceChange(site.Span)) return null; + if (!_regions.TryGetValue(_scope!, out var region)) + { + _model ??= _owner._compilation.GetSemanticModel(_tree); + region = new Region(_model, node.SpanStart); + _regions.Add(_scope!, region); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.Region", TimeSpan.Zero, 0); + } + if (target.Symbol.Arity > 0) + { + // The recorded head proves the canonical definition. Original + // binding supplies the actual construction, including lexical + // type parameters and nullability; metadata arguments are not + // reconstructed or substituted into the emitted syntax. + if (_model!.GetSymbolInfo(node).Symbol is not INamedTypeSymbol construction || !HasUsableTypeGraph(construction) || + !SymbolEqualityComparer.Default.Equals(construction.OriginalDefinition, target.Symbol)) return null; + var parts = (SimpleNameSyntax[])source.Parts.Clone(); + parts[^1] = ((GenericNameSyntax)parts[^1]).WithTypeArgumentList(((GenericNameSyntax)Terminal(visited!)).TypeArgumentList); + source = source with { Parts = parts }; + var choices = region.QualifierChoices ??= new(SymbolEqualityComparer.IncludeNullability); + if (choices.TryGetValue(construction, out var choice)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.CacheHit", TimeSpan.Zero, 0); + return choice?.Render(source); + } + var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model!, node, construction, + region.Facts, KnownDeclarationType: true, Source: source, PreserveTerminalArguments: true); + var selection = StructuralTypeNameSelector.Choose(new[] { context }); + choices.Add(construction, selection.Plan); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.Decision", TimeSpan.Zero, 0); + return selection.Plan?.Render(source); + } + if (region.Decisions.TryGetValue(target.Symbol, out var cached)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.CacheHit", TimeSpan.Zero, 0); + return cached; + } + // Bind the first proven occurrence of a target in this region; + // spelling alone is not its canonical compilation identity. + var actual = _model!.GetSymbolInfo(node).Symbol; + NameSyntax? replacement = null; + if (SymbolEqualityComparer.Default.Equals(actual, target.Symbol)) + { + var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model!, node, target.Symbol, + region.Facts, KnownDeclarationType: true, Source: source); + var selection = StructuralTypeNameSelector.Evaluate(new[] { context }); + if (selection.Reason == StructuralTypeNameSelector.Retention.None && selection.Name is NameSyntax selected && + !SyntaxFactory.AreEquivalent(node, selected, static _ => false)) + replacement = selected.WithoutLeadingTrivia().WithoutTrailingTrivia(); + } + region.Decisions.Add(target.Symbol, replacement); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.Decision", TimeSpan.Zero, 0); + return replacement; + } + + private static StructuralTypeNameSelector.SourceName? DescribeRecordedReference(SyntaxNode node, Target target) + { + // Both callers restore this occurrence's complete outer trivia. + // Canonical spelling/arity/trivia is proved before any choice/cache. + var source = StructuralTypeNameSelector.Describe(node, preserveBoundaryComments: true); + if (source.Diagnostics || source.Directives || source.SignificantTrivia || source.Root?.Parent is not AliasQualifiedNameSyntax alias || + alias.Alias != source.Root || source.Root.Identifier.Text != SyntaxFacts.GetText(SyntaxKind.GlobalKeyword) || + source.Parts.Length != target.Parts.Length) return null; + for (var index = 0; index < source.Parts.Length; index++) + { + var part = source.Parts[index]; + var arity = part is GenericNameSyntax generic ? generic.TypeArgumentList.Arguments.Count : 0; + if (part.Identifier.ValueText != target.Parts[index] || arity != (index == source.Parts.Length - 1 ? target.Symbol.Arity : 0)) return null; + } + return source; + } + + private bool CanProveConditionalStaticReceiver(ExpressionSyntax receiver, MemberAccessExpressionSyntax member, TypeReferenceSite site) + { + // CreateRewriter has already enforced compilation-wide headers, + // type-only document admission and the generic-local-function veto. + // This certificate adds value/call invariance, not other permissions. + if (_canProveCallSource || ConditionalStaticReceiverRules.HasHardSourceContext(receiver)) return false; + var unit = ConditionalStaticReceiverRules.OuterExecutableUnit(receiver); + if (unit == null || !_owner._conditional.TryGetRoots(_tree.GetRoot(), out var roots)) return false; + if (!_conditionalStaticUnits.TryGetValue(unit, out var invariant)) + { + invariant = roots.All(root => ConditionalStaticReceiverRules.SameUnit(unit, + root.FindNode(unit.Span, getInnermostNodeForTie: true))); + _conditionalStaticUnits.Add(unit, invariant); + } + if (!invariant) return false; + // Never cache occurrence acceptance or infer it from a previous + // visible/bindable receiver of the same type in this unit. + return roots.All(root => ConditionalStaticReceiverRules.SameOccurrence(receiver, member, root, site.Span)); + } + + private bool AllowsConditionalStaticSourceChange(SyntaxNode receiver, TextSpan prefix) + { + var lines = _tree.GetLineSpan(prefix); + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return false; + // The invariant OUTER unit and global header proof make original + // call maps reusable. Walk past lambdas/local functions to every + // original ancestor frame; an empty conditional _calls is not proof. + foreach (var call in receiver.Ancestors().Where(static ancestor => ancestor is InvocationExpressionSyntax or + ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax)) + if (HasSourceObservationConflict(call, prefix)) return false; + return true; + } + + private bool HasSourceObservationConflict(SyntaxNode call, TextSpan prefix) + { + if (!_callFacts.TryGetValue(call, out var facts)) + { + facts = ObserveCall(call); + _callFacts.Add(call, facts); + } + return facts.Unknown || facts.Captured.Any(span => span.IntersectsWith(prefix)); + } + + private ExpressionSyntax? TryReduceStaticReceiver(ExpressionSyntax receiver, MemberAccessExpressionSyntax member, + TypeReferenceSite site, ExpressionSyntax visited, bool conditionalSource = false) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.Attempt", TimeSpan.Zero, 0); + // A standalone field/property access has no call frame, but deleting + // a newline could still alter a later CallerLineNumber argument. + var lines = _tree.GetLineSpan(site.Span); + var target = _owner.Resolve(site.Type); + if (target == null) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.MetadataRejected", TimeSpan.Zero, 0); + return null; + } + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return null; + var source = DescribeRecordedReference(receiver, target); + if (source == null || !(conditionalSource ? AllowsConditionalStaticSourceChange(receiver, site.Span) : AllowsSourceChange(site.Span))) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.SourceRejected", TimeSpan.Zero, 0); + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); + var original = _model.GetSymbolInfo(receiver); + if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || + original.Symbol is not INamedTypeSymbol construction || !HasUsableTypeGraph(construction) || + !SymbolEqualityComparer.Default.Equals(construction.OriginalDefinition, target.Symbol)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.OriginalRejected", TimeSpan.Zero, 0); + return null; + } + var accessed = _model.GetSymbolInfo(member); + if (accessed.CandidateReason != CandidateReason.None || accessed.CandidateSymbols.Length != 0 || + accessed.Symbol is not (IMethodSymbol or IFieldSymbol or IPropertySymbol or IEventSymbol) || !accessed.Symbol.IsStatic) return null; + if (accessed.Symbol is IMethodSymbol method && method.MethodKind != MethodKind.Ordinary) return null; + if (member.Parent is InvocationExpressionSyntax invocation && invocation.Expression == member) + { + var called = _model.GetSymbolInfo(invocation); + if (called.CandidateReason != CandidateReason.None || called.CandidateSymbols.Length != 0 || + called.Symbol is not IMethodSymbol { IsStatic: true, MethodKind: MethodKind.Ordinary } callee || + !SymbolEqualityComparer.IncludeNullability.Equals(callee, accessed.Symbol)) return null; + } + if (ExpressionReceiverSyntaxFacts.MayExposeCastParse(receiver)) return null; + + // Expression lookup is position-sensitive even within one method. + // Share requests across candidate attempts at THIS occurrence, never + // consult or populate the declaration/type-only decision caches. + var facts = new StructuralTypeNameSelector.LookupFacts(_model, receiver.SpanStart); + var parts = (SimpleNameSyntax[])source.Parts.Clone(); + parts[^1] = Terminal(visited); + source = source with { Parts = parts }; + var selection = StructuralTypeNameSelector.Choose(new[] + { + new StructuralTypeNameSelector.Context(_owner._compilation, _model, receiver, construction, facts, + Source: source, PreserveTerminalArguments: true, ModeOverride: StructuralTypeNameSelector.Mode.Expression) + }); + var replacement = selection.Plan?.RenderExpression(source); + if (replacement == null || SyntaxFactory.AreEquivalent(receiver, replacement, static _ => false) || + conditionalSource && !ConditionalStaticReceiverRules.KeepsTokenBoundaries(receiver, replacement)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.LookupRejected", TimeSpan.Zero, 0); + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.SpeculativeCheck", TimeSpan.Zero, 0); + var rebound = _model.GetSpeculativeSymbolInfo(receiver.SpanStart, replacement, SpeculativeBindingOption.BindAsExpression); + if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || + rebound.Symbol is not INamedTypeSymbol named || !SymbolEqualityComparer.IncludeNullability.Equals(named, construction)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.SpeculativeRejected", TimeSpan.Zero, 0); + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.Accepted", TimeSpan.Zero, 0); + return replacement; + } + + private IdentifierNameSyntax? TryReduceThisMember(MemberAccessExpressionSyntax member, IdentifierNameSyntax name) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.Attempt", TimeSpan.Zero, 0); + if (name.Identifier.IsMissing || name.Identifier.ValueText == "Object" || member.ContainsDiagnostics || member.ContainsDirectives) return null; + // Parentheses do not change which data member is the invocation + // target. Keep this deliberately excluded family uniformly, even + // when enough wrappers would make the serialized spelling safe. + SyntaxNode invocationTarget = member; + while (invocationTarget.Parent is ParenthesizedExpressionSyntax wrapper) invocationTarget = wrapper; + if (invocationTarget.Parent is InvocationExpressionSyntax invocation && invocation.Expression == invocationTarget) return null; + // Keep removed-token ownership deliberately tight. Boundary comments + // are restored from the complete original access; internal trivia is + // not discarded or moved from this/dot onto an unrelated identifier. + if (member.Expression.GetTrailingTrivia().Count != 0 || member.OperatorToken.LeadingTrivia.Count != 0 || + member.OperatorToken.TrailingTrivia.Count != 0 || member.Name.GetLeadingTrivia().Count != 0) return null; + var prefix = TextSpan.FromBounds(member.SpanStart, member.Name.SpanStart); + var lines = _tree.GetLineSpan(prefix); + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(member.SpanStart) != LineVisibility.Visible || + !AllowsSourceChange(prefix)) return null; + if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(name) || ExpressionReceiverSyntaxFacts.MayExposeCastParse(member)) return null; + _model ??= _owner._compilation.GetSemanticModel(_tree); + var original = _model.GetSymbolInfo(member); + if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || original.Symbol == null || + !_owner._thisMemberEligibility.GetOrAdd(original.Symbol, IsSupportedThisMember) || HasPrimaryConstructorShadowRisk(original.Symbol)) return null; + var memberType = _model.GetTypeInfo(member); + if (UnsafeThisType(memberType.Type) || UnsafeThisType(memberType.ConvertedType) || HasDynamicThisContext()) return null; + // Never pass the attached original member.Name: Roslyn promotes + // that node to this.Member via GetStandaloneExpression. Speculate + // the actual detached replacement, otherwise shadows look unchanged. + var replacement = SyntaxFactory.IdentifierName(name.Identifier).WithTriviaFrom(member); + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.SpeculativeCheck", TimeSpan.Zero, 0); + var rebound = _model.GetSpeculativeSymbolInfo(member.SpanStart, replacement, SpeculativeBindingOption.BindAsExpression); + if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || + !SymbolEqualityComparer.IncludeNullability.Equals(original.Symbol, rebound.Symbol)) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.BindingRejected", TimeSpan.Zero, 0); + return null; + } + if (!ParenthesesSyntaxFacts.TokensStaySeparate(member.GetFirstToken().GetPreviousToken(), replacement.GetFirstToken()) && + !HasRealThisSeparator(member.GetFirstToken().GetPreviousToken().TrailingTrivia) && !HasRealThisSeparator(replacement.GetLeadingTrivia())) return null; + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.Accepted", TimeSpan.Zero, 0); + return replacement; + } + + private bool HasPrimaryConstructorShadowRisk(ISymbol member) + { + // Binding equality is insufficient for diagnostics: an unqualified + // inherited member can newly emit CS9179. Speculation discards that + // warning (and warnings-as-errors builds would reject the output). + var declaration = _scope!.AncestorsAndSelf().OfType().FirstOrDefault(); + if (declaration == null) return true; + if (!_thisContainingTypes.TryGetValue(declaration, out var containingType)) + { + containingType = _model!.GetDeclaredSymbol(declaration) as INamedTypeSymbol; + _thisContainingTypes.Add(declaration, containingType); + } + if (containingType == null) return true; + if (SymbolEqualityComparer.Default.Equals(containingType.OriginalDefinition, member.ContainingType!.OriginalDefinition)) return false; + // Only declaration metadata is shared; source scope, the selected + // original member and the binding decision remain occurrence-owned. + var parameters = _owner._primaryConstructorParameters.GetOrAdd(containingType.OriginalDefinition, static type => + type.InstanceConstructors.Where(static constructor => constructor.DeclaringSyntaxReferences.Any(static syntax => syntax.GetSyntax() is TypeDeclarationSyntax)) + .SelectMany(static constructor => constructor.Parameters).Select(static parameter => parameter.Name).ToHashSet(StringComparer.Ordinal)); + // Conservatively retain even if a base-initializer use might + // suppress the warning. Do not reproduce compiler capture analysis. + return parameters.Contains(member.Name); + } + + private static bool HasRealThisSeparator(SyntaxTriviaList trivia) => trivia.Any(static item => item.FullSpan.Length != 0); + + private static bool UnsafeThisType(ITypeSymbol? type) => type?.TypeKind is TypeKind.Dynamic or TypeKind.Error or TypeKind.Pointer or TypeKind.FunctionPointer; + + private static bool IsSupportedThisMember(ISymbol member) + { + if (member.IsStatic || member.ContainingType == null || !HasUsableTypeGraph(member.ContainingType)) return false; + var type = member switch + { + IFieldSymbol { IsFixedSizeBuffer: false } field => field.Type, + IPropertySymbol { IsIndexer: false } property when property.ExplicitInterfaceImplementations.Length == 0 => property.Type, + _ => null + }; + return type != null && !UnsafeThisType(type) && HasUsableTypeGraph(type); + } + + private bool HasDynamicThisContext() + { + foreach (var context in _thisValueContexts) + { + if (!_dynamicThisContexts.TryGetValue(context, out var dynamic)) + { + bool Unsafe(ExpressionSyntax expression) + { + var info = _model!.GetTypeInfo(expression); + return UnsafeThisType(info.Type) || UnsafeThisType(info.ConvertedType); + } + dynamic = Unsafe(context) || (context switch + { + BinaryExpressionSyntax binary => Unsafe(binary.Left) || Unsafe(binary.Right), + AssignmentExpressionSyntax assignment => Unsafe(assignment.Left) || Unsafe(assignment.Right), + PrefixUnaryExpressionSyntax unary => Unsafe(unary.Operand), + PostfixUnaryExpressionSyntax unary => Unsafe(unary.Operand), + CastExpressionSyntax cast => Unsafe(cast.Expression), + ConditionalExpressionSyntax conditional => Unsafe(conditional.Condition) || Unsafe(conditional.WhenTrue) || Unsafe(conditional.WhenFalse), + AwaitExpressionSyntax awaited => Unsafe(awaited.Expression), + _ => false + }); + _dynamicThisContexts.Add(context, dynamic); + } + if (dynamic) return true; + } + return false; + } + + private bool AllowsSourceChange(TextSpan prefix) + { + if (_calls.Count == 0) return true; + // A newly admitted edit must not shift implicit CallerLineNumber + // values, including values in later calls outside this expression. + var lines = _tree.GetLineSpan(prefix); + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return false; + foreach (var call in _calls) + if (HasSourceObservationConflict(call, prefix)) return false; + return true; + } + + private static bool IsCallerExpression(AttributeData attribute) => + attribute.AttributeClass?.Name == "CallerArgumentExpressionAttribute" && + attribute.AttributeClass.ContainingNamespace.ToDisplayString() == "System.Runtime.CompilerServices"; + + private SourceObservation ObserveCall(SyntaxNode call) + { + var syntaxArguments = call switch + { + InvocationExpressionSyntax invocation => invocation.ArgumentList, + ObjectCreationExpressionSyntax creation => creation.ArgumentList, + ImplicitObjectCreationExpressionSyntax creation => creation.ArgumentList, + ConstructorInitializerSyntax initializer => initializer.ArgumentList, + _ => null + }; + // A synthesized interpolated-handler constructor can capture the + // source of a sibling argument even when the selected enclosing + // method has no CallerArgumentExpression parameter. Keep this + // original-call fact conservative, before the metadata fast path. + // _callFacts shares the result, never occurrence acceptance. + if (syntaxArguments != null && syntaxArguments.Arguments.Any(static argument => + argument.DescendantNodesAndSelf().Any(static node => node is InterpolatedStringExpressionSyntax))) + return SourceObservation.Retain; + _model ??= _owner._compilation.GetSemanticModel(_tree); + var binding = _model.GetSymbolInfo(call); + if (binding.CandidateReason != CandidateReason.None || binding.Symbol is not IMethodSymbol method || + method.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension or MethodKind.Constructor or MethodKind.DelegateInvoke)) return SourceObservation.Retain; + var definition = (method.ReducedFrom ?? method).OriginalDefinition; + if (!_owner._capturesCallerSource.GetOrAdd(definition, static target => + target.Parameters.Any(static parameter => parameter.GetAttributes().Any(IsCallerExpression)))) + return SourceObservation.Unobserved; + + // Metadata proves the common no-capture case without building an + // operation tree. CAE-bearing calls need the actual parameter map. + var operation = _model.GetOperation(call); + var arguments = operation switch + { + IInvocationOperation invocation => invocation.Arguments.ToArray(), + IObjectCreationOperation creation => creation.Arguments.ToArray(), + _ => null + }; + if (arguments == null) return SourceObservation.Retain; + var captured = new List(); + foreach (var supplied in arguments) + { + // Extension receivers can be implicit EXPLICIT arguments. + // Only DefaultValue denotes an omitted capture-text argument. + if (supplied.ArgumentKind != ArgumentKind.DefaultValue || supplied.Parameter == null) continue; + foreach (var attribute in supplied.Parameter.GetAttributes().Where(IsCallerExpression)) + { + if (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Value is not string name) + return SourceObservation.Retain; + var sources = arguments.Where(argument => argument.Parameter?.Name == name).ToArray(); + if (sources.Length == 0) return SourceObservation.Retain; + foreach (var source in sources) + { + if (source.ArgumentKind != ArgumentKind.Explicit) return SourceObservation.Retain; + // Classic reduced extensions expose the original static + // signature: their 'this' source is argument.Value.Syntax. + var syntax = source.Parameter?.IsThis == true ? source.Value.Syntax : source.Syntax; + if (syntax.SyntaxTree != _tree) return SourceObservation.Retain; + captured.Add(syntax.Span); + } + } + } + return captured.Count == 0 ? SourceObservation.Unobserved : new SourceObservation(false, captured.ToArray()); + } + + private static bool HasUsableTypeGraph(ITypeSymbol type) + { + var visited = new HashSet(SymbolEqualityComparer.IncludeNullability); + bool VisitType(ITypeSymbol current) + { + if (!visited.Add(current)) return true; + return current switch + { + IErrorTypeSymbol => false, + INamedTypeSymbol named => !named.IsUnboundGenericType && + (named.ContainingType == null || VisitType(named.ContainingType)) && named.TypeArguments.All(VisitType), + IArrayTypeSymbol array => VisitType(array.ElementType), + IPointerTypeSymbol pointer => VisitType(pointer.PointedAtType), + IFunctionPointerTypeSymbol => false, + _ => true + }; + } + return VisitType(type); + } + + private static bool IsTypeOnlyContext(NameSyntax name) + { + SyntaxNode type = name; + while (true) + { + switch (type.Parent) + { + case NullableTypeSyntax nullable when nullable.ElementType == type: type = nullable; continue; + case ArrayTypeSyntax array when array.ElementType == type: type = array; continue; + case PointerTypeSyntax pointer when pointer.ElementType == type: type = pointer; continue; + case RefTypeSyntax reference when reference.Type == type: type = reference; continue; + case ScopedTypeSyntax scoped when scoped.Type == type: type = scoped; continue; + case TupleElementSyntax element when element.Type == type && element.Parent is TupleTypeSyntax tuple: type = tuple; continue; + case FunctionPointerParameterSyntax parameter when parameter.Type == type && + parameter.Parent?.Parent is FunctionPointerTypeSyntax function: type = function; continue; + case TypeArgumentListSyntax arguments when arguments.Parent is GenericNameSyntax generic: + type = generic; continue; + case QualifiedNameSyntax qualified when qualified.Right == type: type = qualified; continue; + case AliasQualifiedNameSyntax alias when alias.Name == type: type = alias; continue; + } + break; + } + // The compiler owns type-slot grammar. Our adapter supplies policy + // only: normalize wrappers, then preserve exclusions that require + // more than type lookup (parse/source/configuration proofs). + if (type.Parent is CastExpressionSyntax { Expression: not ParenthesizedExpressionSyntax } or TypePatternSyntax or + CrefSyntax or CrefParameterSyntax or IncompleteMemberSyntax or RefValueExpressionSyntax or FromClauseSyntax or JoinClauseSyntax || + type.Parent?.IsKind(SyntaxKind.IsExpression) == true) return false; + if (type.Parent is QualifiedNameSyntax qualifiedParent && qualifiedParent.Left == type) return false; + // Classify the original named node, not its complete wrapper: + // GetStandaloneExpression promotes array/stackalloc wrappers to + // their value expression. Outer normalization above is for policy. + return SyntaxFacts.IsInTypeOnlyContext(name); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs new file mode 100644 index 00000000000..541f399e41b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.TypeSpec.Generator +{ + // Structural name selection over shared compiler-backed lookup facts. No + // Simplifier, speculative binding, candidate reparsing, source edits or + // whole-document traversal. Callers choose the supported integration slice. + internal static class StructuralTypeNameSelector + { + internal enum Retention { None, UnsupportedInput, Trivia, CastShape, GenericCref, ConfigurationShape, Unresolved } + internal sealed record Selection(SyntaxNode Name, int ScopeSnapshots, int MemberSnapshots, int CandidatesTried, Retention Reason = Retention.None, + int RootQueries = 0, int MemberQueries = 0, NamePlan? Plan = null); + private static readonly ConditionalWeakTable> _aliasNames = new(); + internal sealed record Context(Compilation Compilation, SemanticModel Model, SyntaxNode? Reference, + INamedTypeSymbol? Target, LookupFacts? SharedFacts = null, bool KnownDeclarationType = false, SourceName? Source = null, + bool PreserveTerminalArguments = false, Mode? ModeOverride = null); + internal sealed record SourceName(SimpleNameSyntax[] Parts, IdentifierNameSyntax? Root, bool SignificantTrivia, bool Directives, bool Diagnostics); + + internal enum Mode { Type, Expression, Nameof, Cref } + internal enum RootKind { Ordinary, Alias, Global } + internal sealed record Part(string Name, int TypeIndex, TypeArgumentListSyntax? Arguments, int Arity); + internal sealed record Form(RootKind Root, int Start, string? Alias, bool NamespaceAlias, int Cost, bool PreferredAlias = false); + + // A cached qualifier choice never owns occurrence-specific argument syntax. + // The same construction may be spelled differently at two proven sites. + internal sealed class NamePlan + { + private sealed record Segment(string Name, int TypeIndex, int Arity); + + // Expressions require member-access nodes, not a qualified-name tree + // with merely identical serialized text. Generic arguments stay opaque. + internal ExpressionSyntax? RenderExpression(SourceName source) + { + static ExpressionSyntax AsExpression(NameSyntax name) => name switch + { + QualifiedNameSyntax qualified => SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, AsExpression(qualified.Left), qualified.Right), + // Detached roots are essential: speculative APIs promote an + // attached original name back to its original member access. + IdentifierNameSyntax identifier => SyntaxFactory.IdentifierName(identifier.Identifier), + GenericNameSyntax generic => SyntaxFactory.GenericName(generic.Identifier, generic.TypeArgumentList), + AliasQualifiedNameSyntax alias => SyntaxFactory.AliasQualifiedName(alias.Alias, alias.Name), + _ => throw new InvalidOperationException("Unsupported expression name") + }; + var name = Render(source); + if (name == null) return null; + var expression = AsExpression(name); + // Constructed IdentifierName binding does not prove serialized + // contextual-token parsing. Share the same gate with this removal. + if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(expression)) return null; + return expression; + } + private readonly Segment[] _segments; + private readonly Form _form; + private readonly int _typeCount; + + internal NamePlan(IReadOnlyList parts, Form form, int typeCount) + { + _segments = parts.Select(static part => new Segment(part.Name, part.TypeIndex, part.Arity)).ToArray(); + _form = form; + _typeCount = typeCount; + } + + internal NameSyntax? Render(SourceName source) => Render(source, crefSyntax: false); + + // Non-global alias::T is XML documentation-ID text, not CrefSyntax. + // Cref namespace aliases must serialize as alias.T instead. Ordinary + // C# rendering and global:: qualification are deliberately unchanged. + internal NameSyntax? RenderCrefName(SourceName source) => Render(source, crefSyntax: true); + + private NameSyntax? Render(SourceName source, bool crefSyntax) + { + var parts = new Part[_segments.Length]; + for (var index = 0; index < parts.Length; index++) + { + var segment = _segments[index]; + var sourceIndex = source.Parts.Length - _typeCount + segment.TypeIndex; + var arguments = segment.TypeIndex >= 0 && sourceIndex >= 0 && sourceIndex < source.Parts.Length && + source.Parts[sourceIndex] is GenericNameSyntax generic ? generic.TypeArgumentList : null; + if (index >= _form.Start && segment.Arity > 0 && arguments?.Arguments.Count != segment.Arity) return null; + parts[index] = new Part(segment.Name, segment.TypeIndex, arguments, segment.Arity); + } + return StructuralTypeNameSelector.Render(parts, _form, source, _typeCount, crefSyntax); + } + } + + private sealed class View + { + internal readonly Context Input; + internal readonly INamedTypeSymbol Target; + internal readonly INamedTypeSymbol[] Types; + internal readonly Mode LookupMode; + internal readonly bool WholeNameofOperand; + internal readonly string TerminalSpelling; + internal readonly string? PreferredAlias; + internal readonly SimpleNameSyntax[] SourceNames; + internal readonly bool UnsupportedCast; + internal readonly LookupFacts Facts; + internal readonly SourceName Source; + internal int ScopeSnapshots => Facts.ScopeSnapshots; + internal int MemberSnapshots => Facts.MemberSnapshots; + + internal View(Context input, INamedTypeSymbol target) + { + Input = input; Target = target; + Facts = input.SharedFacts ?? new LookupFacts(input.Model, input.Reference!.SpanStart); + var types = new Stack(); + for (var type = target; type != null; type = type.ContainingType) types.Push(type); + Types = types.ToArray(); + LookupMode = input.ModeOverride ?? (input.Reference is CrefSyntax ? Mode.Cref : Mode.Type); + if (input.ModeOverride == null && !input.KnownDeclarationType) + foreach (var ancestor in input.Reference!.Ancestors()) + { + if (ancestor is CastExpressionSyntax cast && cast.Type.Span.Contains(input.Reference.Span) && cast.Expression is not ParenthesizedExpressionSyntax) + UnsupportedCast = true; + if (LookupMode != Mode.Nameof && ancestor is InvocationExpressionSyntax invocation && input.Model.GetOperation(invocation) is INameOfOperation) + { + LookupMode = Mode.Nameof; + WholeNameofOperand = invocation.ArgumentList.Arguments.Single().Expression == input.Reference; + } + if (ancestor is MemberDeclarationSyntax) break; + } + if (input.ModeOverride == null && !input.KnownDeclarationType && LookupMode == Mode.Type && input.Reference!.Parent is MemberAccessExpressionSyntax member && member.Expression == input.Reference) + LookupMode = Mode.Expression; + Source = input.Source ?? Describe(input.Reference!); + SourceNames = Source.Parts; + TerminalSpelling = SourceNames.LastOrDefault()?.Identifier.ValueText ?? target.Name; + if (!input.KnownDeclarationType && Source.Root != null) PreferredAlias = input.Model.GetAliasInfo(Source.Root)?.Name; + } + + internal ISymbol[] Scope(string name, bool expressions) => Facts.Scope(name, expressions); + internal ISymbol[] Members(INamespaceOrTypeSymbol container, string name, bool expressions) => Facts.Members(container, name, expressions); + internal IEnumerable Aliases() => Facts.Aliases(); + } + + internal sealed class LookupFacts + { + private readonly SemanticModel _model; + private readonly int _position; + private readonly Dictionary<(bool Expressions, string Name), ISymbol[]> _scopes = new(); + private readonly Dictionary<(INamespaceOrTypeSymbol Container, bool Expressions, string Name), ISymbol[]> _members = new(); + private readonly HashSet _scopeViews = new(); + private readonly HashSet<(INamespaceOrTypeSymbol, bool)> _memberViews = new(); + internal int ScopeSnapshots => _scopeViews.Count; + internal int MemberSnapshots => _memberViews.Count; + internal int RootQueries { get; private set; } + internal int MemberQueries { get; private set; } + internal LookupFacts(SemanticModel model, int position) { _model = model; _position = position; } + + internal ISymbol[] Scope(string name, bool expressions) + { + var key = (expressions, name); + if (!_scopes.TryGetValue(key, out var facts)) + { + var symbols = expressions ? _model.LookupSymbols(_position, name: name) + : _model.LookupNamespacesAndTypes(_position, name: name); + facts = symbols.Distinct(SymbolEqualityComparer.Default).ToArray(); + _scopes.Add(key, facts); _scopeViews.Add(expressions); RootQueries++; + } + return facts; + } + + internal ISymbol[] Members(INamespaceOrTypeSymbol container, string name, bool expressions) + { + var key = (container, expressions, name); + if (!_members.TryGetValue(key, out var facts)) + { + var symbols = expressions ? _model.LookupSymbols(_position, container, name) + : _model.LookupNamespacesAndTypes(_position, container, name); + facts = symbols.Distinct(SymbolEqualityComparer.Default).ToArray(); + _members.Add(key, facts); _memberViews.Add((container, expressions)); MemberQueries++; + } + return facts; + } + + internal IEnumerable Aliases() + { + // Only source alias NAMES are indexed compilation-wide. Actual + // visibility/precedence/target is resolved in this lookup region. + var names = _aliasNames.GetValue(_model.Compilation, static compilation => new Lazy(() => CollectAliasNames(compilation))).Value; + foreach (var name in names) + foreach (var symbol in Scope(name, false)) + if (symbol is IAliasSymbol alias) yield return alias; + } + } + + internal static Selection Evaluate(IReadOnlyList configurations) => EvaluateCore(configurations, chooseOnly: false); + + // Occurrence-specific integrations may reject a lexically valid plan + // after their own complete-context proof and continue to the next form. + // The selector neither performs nor caches that caller-owned binding. + internal static Selection Choose(IReadOnlyList configurations, Func? acceptPlan = null) => + EvaluateCore(configurations, chooseOnly: true, acceptPlan); + + private static Selection EvaluateCore(IReadOnlyList configurations, bool chooseOnly, Func? acceptPlan = null) + { + if (configurations.Count == 0) throw new ArgumentException("At least one scope view is required", nameof(configurations)); + var original = configurations[0].Reference ?? throw new ArgumentException("Missing original reference"); + var views = new List(); + foreach (var configuration in configurations) + { + var target = configuration.Target; + if (configuration.Reference == null || target == null || original is PredefinedTypeSyntax || target.IsUnboundGenericType) + return new Selection(original, 0, 0, 0, Retention.UnsupportedInput); + views.Add(new View(configuration, target)); + } + var shape = views[0].Types; + if (views.Any(v => v.Types.Length != shape.Length || v.Types.Where((type, index) => + type.Name != shape[index].Name || type.Arity != shape[index].Arity).Any())) + return Finish(original, views, 0, Retention.ConfigurationShape); + // Preserve uncertain trivia and cast forms. Context is captured once, + // not rediscovered for each candidate. Existing wrappers are untouched. + if (views.Any(static v => v.Source.Diagnostics || v.Source.Directives || v.Source.SignificantTrivia)) + return Finish(original, views, 0, Retention.Trivia); + if (views.Any(static v => v.UnsupportedCast)) + return Finish(original, views, 0, Retention.CastShape); + if (views.Any(static v => v.LookupMode == Mode.Cref && v.Types.Any(static t => t.Arity > 0))) + return Finish(original, views, 0, Retention.GenericCref); + + var first = views[0]; + var sourceNames = first.SourceNames; + var namespaces = new Stack(); + for (var ns = first.Target.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) namespaces.Push(ns); + var parts = namespaces.Select(static ns => new Part(ns.Name, -1, null, 0)).ToList(); + for (var index = 0; index < first.Types.Length; index++) + { + var sourceIndex = sourceNames.Length - first.Types.Length + index; + var arguments = sourceIndex >= 0 && sourceNames[sourceIndex] is GenericNameSyntax generic ? generic.TypeArgumentList : null; + parts.Add(new Part(first.Types[index].Name, index, arguments, first.Types[index].Arity)); + } + var forms = new List
(); + for (var start = 0; start < parts.Count; start++) forms.Add(new Form(RootKind.Ordinary, start, null, false, parts.Count - start)); + forms.Add(new Form(RootKind.Global, 0, null, false, parts.Count + 1)); + foreach (var alias in first.Aliases()) + { + var namespaceAlias = alias.Target is INamespaceSymbol; + if (alias.Target is not INamespaceOrTypeSymbol) continue; + for (var start = 0; start <= parts.Count; start++) + { + if (namespaceAlias && start == parts.Count) continue; + forms.Add(new Form(RootKind.Alias, start, alias.Name, namespaceAlias, parts.Count - start + 1, alias.Name == first.PreferredAlias)); + } + } + var attempts = 0; + foreach (var form in forms.OrderBy(static f => f.Cost).ThenBy(static f => f.PreferredAlias ? 0 : 1) + .ThenBy(static f => f.Root).ThenBy(static f => f.Alias, StringComparer.Ordinal).ThenBy(static f => f.Start)) + { + if (parts.Skip(form.Start).Any(static p => p.Arity > 0 && p.Arguments?.Arguments.Count != p.Arity)) continue; + // Prefix-only integration must not replace the terminal and drop + // its opaque argument list via a closed-type alias. + if (form.Start == parts.Count && views.Any(static view => view.Input.PreserveTerminalArguments)) continue; + if (views.Any(view => view.LookupMode == Mode.Nameof && view.WholeNameofOperand && + (form.Start == parts.Count ? form.Alias : parts[^1].Name) != view.TerminalSpelling)) continue; + attempts++; + if (views.All(view => Resolves(view, parts, form))) + { + if (!chooseOnly) return Finish(Render(parts, form), views, attempts); + var plan = new NamePlan(parts, form, first.Types.Length); + if (acceptPlan != null && !acceptPlan(plan)) continue; + return Finish(original, views, attempts, plan: plan); + } + } + return Finish(original, views, attempts, Retention.Unresolved); + } + + private static Selection Finish(SyntaxNode syntax, IEnumerable views, int attempts, Retention reason = Retention.None, NamePlan? plan = null) => + new(syntax, views.Sum(static v => v.ScopeSnapshots), views.Sum(static v => v.MemberSnapshots), attempts, reason, + views.Sum(static v => v.Facts.RootQueries), views.Sum(static v => v.Facts.MemberQueries), plan); + + private static bool Resolves(View view, IReadOnlyList parts, Form form) + { + var expression = view.LookupMode is Mode.Expression or Mode.Nameof; + var start = form.Start; + List current; + if (form.Root == RootKind.Global) current = new() { view.Input.Compilation.GlobalNamespace }; + else if (form.Root == RootKind.Alias) + { + var bindings = view.Scope(form.Alias!, form.NamespaceAlias ? false : expression); + if (bindings.Any(static s => s is not INamespaceOrTypeSymbol && s is not IAliasSymbol)) return false; + current = bindings.OfType().Where(a => a.Name == form.Alias && (a.Target is INamespaceSymbol) == form.NamespaceAlias) + .Select(static a => a.Target).OfType().ToList(); + if (current.Count == 0) return false; + } + else + { + current = Bind(view, view.Scope(parts[start].Name, expression), parts[start]); + start++; + } + current = current.Distinct(SymbolEqualityComparer.IncludeNullability).ToList(); + if (current.Count != 1) return false; + for (; start < parts.Count; start++) + { + var part = parts[start]; + current = Bind(view, view.Members(current[0], part.Name, expression), part) + .Distinct(SymbolEqualityComparer.IncludeNullability).ToList(); + // Ambiguity is a failure at this lookup stage. Later suffixes + // cannot discard one ambiguous owner and make the path legal. + if (current.Count != 1) return false; + } + return SymbolEqualityComparer.IncludeNullability.Equals(current[0], view.Target); + } + + private static string[] CollectAliasNames(Compilation compilation) + { + var names = new HashSet(StringComparer.Ordinal); + // A namespace/using-declaration index only: do not visit any type or + // method body. Lazy publication ensures one collection per compilation. + foreach (var tree in compilation.SyntaxTrees) + { + if (tree.GetRoot() is not CompilationUnitSyntax root) continue; + Add(root.Usings); + var pending = new Stack(root.Members.OfType()); + while (pending.TryPop(out var ns)) + { + Add(ns.Usings); + foreach (var nested in ns.Members.OfType()) pending.Push(nested); + } + } + return names.Order(StringComparer.Ordinal).ToArray(); + + void Add(IEnumerable directives) + { + foreach (var directive in directives) + if (directive.Alias != null) names.Add(directive.Alias.Name.Identifier.ValueText); + } + } + + private static List Bind(View view, ISymbol[] symbols, Part part) + { + var result = new List(); + foreach (var entry in symbols) + { + var symbol = entry; + var alias = symbol is IAliasSymbol; + if (symbol is IAliasSymbol aliased) + { + if (part.Arity != 0) continue; + symbol = aliased.Target; + } + if (symbol is INamespaceSymbol ns && part.Arity == 0) result.Add(ns); + else if (symbol is INamedTypeSymbol named && (alias || named.Arity == part.Arity)) + { + if (!alias && named.Arity > 0 && part.TypeIndex >= 0) + named = named.ConstructedFrom.Construct(view.Types[part.TypeIndex].TypeArguments.ToArray()); + result.Add(named); + } + else if (symbol is ITypeParameterSymbol && part.Arity == 0) + { + // A type parameter wins lookup but is not the named target. + return new List(); + } + else if (symbol is not INamespaceOrTypeSymbol && !alias && + (part.Arity == 0 || symbol is IMethodSymbol method && method.Arity == part.Arity)) + return new List(); + } + return result; + } + + private static NameSyntax Render(IReadOnlyList parts, Form form, SourceName? source = null, int typeCount = 0, bool crefSyntax = false) + { + SimpleNameSyntax PartName(Part part) + { + if (source != null && part.TypeIndex >= 0) + return source.Parts[source.Parts.Length - typeCount + part.TypeIndex]; + return part.Arity == 0 ? SyntaxFactory.IdentifierName(Identifier(part.Name)) + : SyntaxFactory.GenericName(Identifier(part.Name), part.Arguments!); + } + NameSyntax name; + var start = form.Start; + if (form.Root == RootKind.Alias) + { + var alias = SyntaxFactory.IdentifierName(Identifier(form.Alias!)); + if (start == parts.Count) return alias; + name = form.NamespaceAlias && !crefSyntax ? SyntaxFactory.AliasQualifiedName(alias, PartName(parts[start++])) + : SyntaxFactory.QualifiedName(alias, PartName(parts[start++])); + } + else if (form.Root == RootKind.Global) + name = SyntaxFactory.AliasQualifiedName(SyntaxFactory.IdentifierName(SyntaxFactory.Token(SyntaxKind.GlobalKeyword)), PartName(parts[start++])); + else name = PartName(parts[start++]); + for (; start < parts.Count; start++) name = SyntaxFactory.QualifiedName(name, PartName(parts[start])); + return name; + } + + private static SyntaxToken Identifier(string name) + { + var escape = SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None; + return SyntaxFactory.Identifier(default, SyntaxKind.None, escape ? "@" + name : name, name, default); + } + + // Only callers that restore the complete outer trivia on the final + // replacement may admit boundary comments. Internal trivia and directives + // remain conservative, including comments inside retained arguments. + internal static SourceName Describe(SyntaxNode reference, bool preserveBoundaryComments = false) + { + var names = new List(); + IdentifierNameSyntax? root = null; + CollectSourceParts(reference, names, ref root); + var significant = false; + foreach (var trivia in reference.DescendantTrivia()) + { + if (trivia.IsKind(SyntaxKind.WhitespaceTrivia) || trivia.IsKind(SyntaxKind.EndOfLineTrivia)) continue; + if (preserveBoundaryComments && (trivia.Span.End <= reference.SpanStart || trivia.SpanStart >= reference.Span.End) && + trivia.Kind() is SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia or + SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia) continue; + significant = true; + break; + } + return new SourceName(names.ToArray(), root, significant, reference.ContainsDirectives, reference.ContainsDiagnostics); + } + + private static void CollectSourceParts(SyntaxNode node, List parts, ref IdentifierNameSyntax? rootName) + { + switch (node) + { + case SimpleNameSyntax simple: + if (parts.Count == 0) rootName = simple as IdentifierNameSyntax; + parts.Add(simple); break; + case QualifiedNameSyntax qualified: + CollectSourceParts(qualified.Left, parts, ref rootName); + parts.Add(qualified.Right); break; + case AliasQualifiedNameSyntax alias: + if (parts.Count == 0) rootName = alias.Alias; + parts.Add(alias.Name); break; + case MemberAccessExpressionSyntax member: + CollectSourceParts(member.Expression, parts, ref rootName); + parts.Add(member.Name); break; + case QualifiedCrefSyntax cref: + CollectSourceParts(cref.Container, parts, ref rootName); + CollectSourceParts(cref.Member, parts, ref rootName); break; + case NameMemberCrefSyntax cref: + CollectSourceParts(cref.Name, parts, ref rootName); break; + case TypeCrefSyntax cref: + CollectSourceParts(cref.Type, parts, ref rootName); break; + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CodeFile.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CodeFile.cs index 372532f4374..12f012a76ec 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CodeFile.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/CodeFile.cs @@ -1,16 +1,21 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Collections.Generic; + namespace Microsoft.TypeSpec.Generator.Primitives { public class CodeFile { - internal CodeFile(string content, string name) + internal CodeFile(string content, string name, IReadOnlyList? typeReferences = null) { Name = name; Content = content; + TypeReferences = typeReferences; } + internal IReadOnlyList? TypeReferences { get; } + public string Name { get; } public string Content { get; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeProviderWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeProviderWriter.cs index 39f06420d9e..e109678255d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeProviderWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeProviderWriter.cs @@ -29,7 +29,7 @@ public virtual CodeFile Write() WriteType(writer); } } - return new CodeFile(writer.ToString(), _provider.RelativeFilePath); + return new CodeFile(writer.ToString(), _provider.RelativeFilePath, writer.TypeReferences); } private bool IsPublicContext(TypeProvider provider) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeReferenceSite.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeReferenceSite.cs new file mode 100644 index 00000000000..00da1ec20aa --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Primitives/TypeReferenceSite.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.TypeSpec.Generator.Primitives +{ + internal enum TypeReferenceRole { Code, WholeTypeCref } + + internal readonly record struct TypeReferenceSite(TextSpan Span, CSharpType Type, TypeReferenceRole Role = TypeReferenceRole.Code); +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/ExpressionStatement.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/ExpressionStatement.cs index e0e5566a9bb..6f764cedae0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/ExpressionStatement.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/ExpressionStatement.cs @@ -3,6 +3,7 @@ using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Providers; +using Microsoft.TypeSpec.Generator.Snippets; namespace Microsoft.TypeSpec.Generator.Statements { @@ -17,7 +18,21 @@ public ExpressionStatement(ValueExpression expression) internal override void Write(CodeWriter writer) { - Expression.Write(writer); + var expression = Expression; + while (expression is ScopedApi scoped) + { + expression = scoped.Original; + } + if (expression is BinaryOperatorExpression { Operator: "=" or "+=" or "-=" or "*=" or "/=" or "%=" or "&=" or "|=" or "^=" or "<<=" or ">>=" or ">>>=" or "??=" } assignment) + { + // C# statement expressions cannot wrap an assignment in parentheses + // (CS0201). Preserve grouping inside its operands and in other uses. + assignment.WriteUnparenthesized(writer); + } + else + { + Expression.Write(writer); + } writer.WriteRawLine(";"); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index 8df02af1341..7e567cd5338 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -10,6 +10,7 @@ using System.Reflection.Metadata; using System.Text; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; using Microsoft.TypeSpec.Generator.Expressions; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; @@ -26,12 +27,16 @@ internal sealed partial class CodeWriter : IDisposable private const char _space = ' '; private readonly HashSet _usingNamespaces = new HashSet(); + private readonly List? _typeReferenceSites = ProviderNameReducer.IsEnabled ? new() : null; + private int _writtenCharacters; + internal IReadOnlyList? TypeReferences { get; private set; } private readonly Stack _scopes; private string? _currentNamespace; private UnsafeBufferSequence _builder; private bool _atBeginningOfLine; private bool _writingXmlDocumentation; + private bool _writingCrefType; private bool _writingNewInstance; internal CodeWriter() { @@ -462,7 +467,18 @@ public void WriteParameter(ParameterProvider parameter) if (parameter.DefaultValue != null) { AppendRaw(" = "); - parameter.DefaultValue.Write(this); + if (parameter.DefaultValue is CastExpression { Inner: KeywordExpression { Keyword: "default", Expression: null } } cast && + cast.Type.Equals(parameter.Type)) + { + // A casted default of a struct is not a legal optional value + // (CS1736). Emit the typed default directly, without requiring + // Roslyn's cast reducer to repair the generated declaration. + Append($"default({cast.Type})"); + } + else + { + parameter.DefaultValue.Write(this); + } } } @@ -569,7 +585,16 @@ private void AppendTypeForCRef(CSharpType type) } AppendRaw($""); if (isNullable) @@ -651,6 +676,7 @@ private void AppendType(CSharpType type, bool isDeclaration, bool writeTypeNameO UseNamespace(type.Namespace); AppendRaw("global::"); + var referenceStart = _writtenCharacters - "global::".Length; AppendRaw(type.Namespace); AppendRaw("."); if (type.DeclaringType is not null) @@ -659,6 +685,18 @@ private void AppendType(CSharpType type, bool isDeclaration, bool writeTypeNameO } AppendRaw(type.Name); + if (_typeReferenceSites != null && (!_writingXmlDocumentation || _writingCrefType)) + { + // Array suffixes belong to ArrayTypeSyntax, not the named reference. + var referenceType = type; + while (referenceType.IsArray) + { + referenceType = referenceType.ElementType; + } + var referenceEnd = _writtenCharacters - type.Name.Length + referenceType.Name.Length; + _typeReferenceSites.Add(new TypeReferenceSite(TextSpan.FromBounds(referenceStart, referenceEnd), referenceType, + _writingCrefType ? TypeReferenceRole.WholeTypeCref : TypeReferenceRole.Code)); + } } if (type.Arguments.Any()) @@ -703,6 +741,7 @@ private CodeWriter AppendRawChar(char c) var destination = _builder.GetSpan(1); destination[0] = c; _builder.Advance(1); + _writtenCharacters++; _atBeginningOfLine = true; return this; } @@ -719,6 +758,7 @@ private CodeWriter AppendRaw(ReadOnlySpan span) var destination = _builder.GetSpan(span.Length); span.CopyTo(destination); _builder.Advance(span.Length); + _writtenCharacters += span.Length; _atBeginningOfLine = span[span.Length - 1] == _newLine; return this; @@ -741,6 +781,7 @@ private void AddSpaces(ReadOnlySpan span) var destination = _builder.GetSpan(spaces); destination.Slice(0, spaces).Fill(_space); _builder.Advance(spaces); + _writtenCharacters += spaces; } internal CodeWriter WriteIdentifier(string identifier) @@ -951,6 +992,7 @@ public string ToString(bool header) var totalLength = reader.Length; if (totalLength == 0) { + TypeReferences = _typeReferenceSites == null ? null : Array.Empty(); return string.Empty; } @@ -991,6 +1033,16 @@ public string ToString(bool header) } } + if (_typeReferenceSites != null) + { + var headerLength = builder.Length; + TypeReferences = _typeReferenceSites.Select(site => site with + { + Span = new TextSpan(site.Span.Start + headerLength, site.Span.Length) + }).ToArray(); + _typeReferenceSites.Clear(); + } + _writtenCharacters = 0; reader.CopyTo(builder, default); return builder.ToString(); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProfilingTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProfilingTests.cs new file mode 100644 index 00000000000..33eb6828cd8 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProfilingTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProfilingTests + { + [TestCase(false)] + [TestCase(true)] + public async Task DocumentProfilingPreservesOutput(bool disableRoslynReduce) + { + var outputPath = Path.Combine(Path.GetTempPath(), "typespec-profile-test", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(outputPath); + try + { + MockHelpers.LoadMockGenerator( + outputPath: outputPath, + configuration: $"{{\"package-name\":\"Profiling.Test\",\"disable-roslyn-reduce\":{disableRoslynReduce.ToString().ToLowerInvariant()}}}"); + + var unprofiled = await ProcessDocuments(null); + var profile = new GeneratedCodeWorkspacePostProcessingProfile(); + var profiled = await ProcessDocuments(profile); + + CollectionAssert.AreEqual(unprofiled, profiled); + Assert.AreEqual(3, profiled.Count); + StringAssert.Contains("ProcessDocument.Total, 2,", profile.GetSummary()); + if (disableRoslynReduce) + { + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", profile.GetSummary()); + } + else + { + StringAssert.Contains("Roslyn.Simplifier.ReduceAsync, 2,", profile.GetSummary()); + } + } + finally + { + GeneratedCodeWorkspace.PostProcessingProfile = null; + Directory.Delete(outputPath, recursive: true); + } + } + + private static async Task> ProcessDocuments(GeneratedCodeWorkspacePostProcessingProfile? profile) + { + GeneratedCodeWorkspace.PostProcessingProfile = profile; + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(new CodeFile("namespace Profiling.Test { public class First { public global::System.String Name { get; set; } } }", "First.cs")); + await workspace.AddGeneratedFile(new CodeFile("namespace Profiling.Test { public class Second { public global::System.Int32 Value { get; set; } } }", "Second.cs")); + workspace.AddPlainFiles("plain.txt", "unchanged"); + var files = new List<(string Name, string Text)>(); + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + files.Add(file); + } + return files; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs new file mode 100644 index 00000000000..93879d2e855 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderConditionalCleanupTests + { + private string? _previous; + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static p => MetadataReference.CreateFromFile(p)).ToArray(); + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "true"); + MockHelpers.LoadMockGenerator(); + } + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + + [Test] + public void BodyOnlyBranchesShortenRecordedSitesInBothBranches() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer { public object M() {"); + writer.WriteRawLine("#if NET6_0_OR_GREATER"); + writer.WriteLine($"return new {typeof(DateTimeOffset)}();"); + writer.WriteRawLine("#else"); + writer.WriteLine($"return new {typeof(TimeSpan)}();"); + writer.WriteRawLine("#endif"); + writer.WriteRawLine("} } }"); + var output = Reduce(writer, true); + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + StringAssert.DoesNotContain("global::System.TimeSpan", output); + } + + [Test] + public void InactiveBranchLocalsCannotCaptureStaticPrefixes() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer { public object M() {"); + writer.WriteRawLine("#if NET6_0_OR_GREATER"); + writer.WriteRawLine("object DateTimeOffset = null;"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("#else"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("#endif"); + writer.WriteRawLine("} } }"); + StringAssert.Contains("global::System.DateTimeOffset.Now", Reduce(writer, true)); + } + + [TestCase("NET6_0_OR_GREATER")] + [TestCase("NETCOREAPP2_1_OR_GREATER")] + [TestCase("NET5_0_OR_GREATER")] + public void ConditionalExternalPartialCannotChangeOrdinaryDocumentBinding(string frameworkSymbol) + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public partial class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("} }"); + var source = writer.ToString(); + var extra = "namespace Example { public partial class Consumer {\n#if " + frameworkSymbol + "\npublic class DateTimeOffset {}\n#endif\n} }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("ExternalPartial", new[] { tree, CSharpSyntaxTree.ParseText(extra) }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.False(new ProviderNameReducer(compilation).TryPlan(tree.GetRoot(), writer.TypeReferences!, out _), + "Framework-conditional declarations anywhere must keep the ordinary target qualified"); + foreach (var symbols in new[] { Array.Empty(), new[] { frameworkSymbol } }) + { + var options = new CSharpParseOptions(preprocessorSymbols: symbols); + var target = CSharpSyntaxTree.ParseText(source, options); + var variant = compilation.RemoveAllSyntaxTrees().AddSyntaxTrees(target, CSharpSyntaxTree.ParseText(extra, options)); + var property = target.GetRoot().DescendantNodes().OfType().Single(); + Assert.AreEqual("System.DateTimeOffset", variant.GetSemanticModel(target).GetTypeInfo(property.Type).Type!.ToDisplayString()); + } + } + + [TestCase("NET6_0_OR_GREATER")] + [TestCase("CUSTOM_FEATURE")] + public void ConditionalDeclarationsRemainOpaque(string symbol) + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteRawLine("#if " + symbol); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("#else"); + writer.WriteLine($"public {typeof(TimeSpan)} Value => default;"); + writer.WriteRawLine("#endif"); + writer.WriteRawLine("} }"); + StringAssert.Contains("global::", Reduce(writer, false)); + } + + [TestCase("DEBUG", false)] + [TestCase("DEBUG", true)] + [TestCase("HAS_INTERNALS_VISIBLE_CORE", false)] + [TestCase("HAS_INTERNALS_VISIBLE_CORE", true)] + [TestCase("NETCOREAPP2_1_OR_GREATER", false)] + [TestCase("NET5_0_OR_GREATER", false)] + public void HeaderInvariantSharedSourceDoesNotDisableOrdinaryPlans(string symbol, bool defined) + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("} }"); + var options = new CSharpParseOptions(preprocessorSymbols: defined ? new[] { symbol } : Array.Empty()); + var target = CSharpSyntaxTree.ParseText(writer.ToString(), options); + var shared = symbol == "HAS_INTERNALS_VISIBLE_CORE" + ? "namespace Shared { public class Helper {\n#if HAS_INTERNALS_VISIBLE_CORE\ninternal\n#endif\nvoid M() {} } }" + : "namespace Shared { public class Helper { public void M() {\n#if " + symbol + "\nSystem.Console.WriteLine(1);\n#endif\n} } }"; + var extra = CSharpSyntaxTree.ParseText(shared, options); + var compilation = CSharpCompilation.Create("FixedFlags", new[] { target, extra }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.True(new ProviderNameReducer(compilation).TryPlan(target.GetRoot(), writer.TypeReferences!, out var plan)); + StringAssert.DoesNotContain("global::System.DateTimeOffset", plan!.Apply().ToString()); + if (symbol == "DEBUG" || symbol == "HAS_INTERNALS_VISIBLE_CORE") + Assert.False(new ProviderNameReducer(compilation).TryPlan(extra.GetRoot(), Array.Empty(), out _), + "Unknown project-conditional documents remain opaque themselves"); + } + + [Test] + public void ConditionalNullableStateCannotEscapeIgnoredMethodBody() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("} }"); + var target = CSharpSyntaxTree.ParseText(writer.ToString()); + var extra = CSharpSyntaxTree.ParseText("namespace Shared { public class Helper { public int M() {\n#if NET6_0_OR_GREATER\n#nullable enable\n#endif\nreturn 1; } } }"); + var compilation = CSharpCompilation.Create("NullableState", new[] { target, extra }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.False(new ProviderNameReducer(compilation).TryPlan(target.GetRoot(), writer.TypeReferences!, out _)); + } + + [Test] + public void FrameworkConditionalGlobalAliasRejectsOrdinaryTarget() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("} }"); + var target = CSharpSyntaxTree.ParseText(writer.ToString()); + var extra = CSharpSyntaxTree.ParseText("#if NET5_0_OR_GREATER\nglobal using DateTimeOffset = Other.DateTimeOffset;\n#endif\nnamespace Other { public class DateTimeOffset {} }"); + var compilation = CSharpCompilation.Create("AliasHeaders", new[] { target, extra }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.False(new ProviderNameReducer(compilation).TryPlan(target.GetRoot(), writer.TypeReferences!, out _)); + } + + [Test] + public void OriginalNoncanonicalFrameworkBranchContributesLexicalBlockers() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer { public object M() {"); + writer.WriteRawLine("#if NET6_0_OR_GREATER"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("#else\n#if NET9_0_OR_GREATER\nobject DateTimeOffset = null;"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("#else"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("#endif\n#endif\n} } }"); + var options = new CSharpParseOptions(preprocessorSymbols: new[] { "NET9_0_OR_GREATER" }); + var tree = CSharpSyntaxTree.ParseText(writer.ToString(), options); + var compilation = CSharpCompilation.Create("OriginalFrameworkState", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.True(new ProviderNameReducer(compilation).TryPlan(tree.GetRoot(), writer.TypeReferences!, out var plan)); + StringAssert.Contains("global::System.DateTimeOffset.Now", plan!.Apply().ToString()); + } + + private static string Reduce(CodeWriter writer, bool expectedPlan) + { + var source = writer.ToString(); + var root = CSharpSyntaxTree.ParseText(source).GetRoot(); + var compilation = CSharpCompilation.Create("ConditionalCleanup", new[] { root.SyntaxTree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var canPlan = new ProviderNameReducer(compilation).TryPlan(root, writer.TypeReferences!, out var plan); + Assert.AreEqual(expectedPlan, canPlan); + var output = canPlan ? plan!.Apply().ToString() : source; + foreach (var symbols in new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET9_0_OR_GREATER" } }) + { + var options = new CSharpParseOptions(preprocessorSymbols: symbols); + foreach (var text in new[] { source, output }) + { + var variant = CSharpCompilation.Create("ConditionalVariant", new[] { CSharpSyntaxTree.ParseText(text, options) }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(variant.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + } + } + return output; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderCrefCleanupTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderCrefCleanupTests.cs new file mode 100644 index 00000000000..777267a6a48 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderCrefCleanupTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderCrefCleanupTests + { + private string? _previous; + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static p => MetadataReference.CreateFromFile(p)).ToArray(); + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "true"); + MockHelpers.LoadMockGenerator(); + } + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + + [Test] + public void RecordedWholeTypeCrefIsShortenedAndKeepsItsDocumentationId() + { + using var writer = new CodeWriter(); + using (new CodeWriter.XmlDocWritingScope(writer)) + writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteRawLine("public class Consumer {} "); + var output = Reduce(writer); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + } + + [Test] + public void GenericDefinitionAndArgumentDescriptionKeepTheirIds() + { + using var writer = new CodeWriter(); + using (new CodeWriter.XmlDocWritingScope(writer)) + writer.WriteLine($"/// {typeof(List):C}"); + writer.WriteRawLine("public class Consumer {} "); + var output = Reduce(writer); + StringAssert.Contains("cref=\"List{T}\"", output); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + } + + [Test] + public void UnrecordedMemberContainerKeepsSelectedOverload() + { + using var writer = new CodeWriter(); + using (new CodeWriter.XmlDocWritingScope(writer)) + writer.WriteLine($"/// "); + writer.WriteRawLine("public class Consumer {} "); + StringAssert.Contains("cref=\"global::System.Convert.ToString(int)\"", Reduce(writer)); + } + + [Test] + public void ProseCodeTextAndUnrecordedCrefAreNotRewritten() + { + using var writer = new CodeWriter(); + using (new CodeWriter.XmlDocWritingScope(writer)) + { + writer.WriteLine($"/// {typeof(DateTimeOffset)} text {typeof(DateTimeOffset)}."); + writer.WriteRawLine("/// "); + } + writer.WriteRawLine("public class Consumer {} "); + var output = Reduce(writer); + StringAssert.Contains("global::System.DateTimeOffset", output); + StringAssert.Contains("text global::System.DateTimeOffset", output); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + } + + private static string Reduce(CodeWriter writer) + { + var source = writer.ToString(); + var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); + var tree = CSharpSyntaxTree.ParseText(source, options); + var compilation = CSharpCompilation.Create("CrefCleanup", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + AssertNoErrors(compilation); + var root = tree.GetRoot(); + var beforeModel = compilation.GetSemanticModel(tree); + var before = root.DescendantNodes(descendIntoTrivia: true).OfType() + .Select(n => (n.Cref.Span, Id(beforeModel, n.Cref))).ToArray(); + Assert.True(new ProviderNameReducer(compilation).TryPlan(root, writer.TypeReferences!, out var plan)); + var output = plan!.Apply().ToString(); + var parsed = CSharpSyntaxTree.ParseText(output, options); + var after = compilation.ReplaceSyntaxTree(tree, parsed); + AssertNoErrors(after); + var model = after.GetSemanticModel(parsed); + foreach (var (span, id) in before) + { + var start = span.Start - plan.Deletions.Where(c => c.End <= span.Start).Sum(static c => c.Length); + var end = span.End - plan.Deletions.Where(c => c.End <= span.End).Sum(static c => c.Length); + var mapped = parsed.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType() + .Single(n => n.Cref.Span == TextSpan.FromBounds(start, end)); + Assert.AreEqual(id, Id(model, mapped.Cref)); + } + return output; + } + private static string Id(SemanticModel model, CrefSyntax cref) + { + var info = model.GetSymbolInfo(cref); + Assert.NotNull(info.Symbol, cref.ToString()); + return info.Symbol!.ContainingAssembly?.Identity + ":" + info.Symbol.OriginalDefinition.GetDocumentationCommentId(); + } + private static void AssertNoErrors(Compilation c) => Assert.That(c.GetDiagnostics() + .Where(static d => d.Severity == DiagnosticSeverity.Error || d.Id == "CS1574" || d.Id == "CS1584"), Is.Empty); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderNameReducerBindingTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderNameReducerBindingTests.cs new file mode 100644 index 00000000000..8816b277230 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderNameReducerBindingTests.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderNameReducerBindingTests + { + private string? _previous; + private static readonly MetadataReference[] RuntimeReferences = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "true"); + MockHelpers.LoadMockGenerator(configuration: "{\"package-name\":\"Example\",\"disable-xml-docs\":true}"); + } + + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + + [Test] + public void FrameworkTypeIsShortenedAndKeepsItsBinding() + { + var result = Reduce(Write(typeof(DateTimeOffset))); + StringAssert.Contains("public DateTimeOffset Value", result); + } + + [Test] + public void FrameworkGenericsAndArgumentsKeepTheirBindings() + { + var result = Reduce(Write(new CSharpType(typeof(Dictionary<,>), typeof(string), typeof(DateTimeOffset)))); + StringAssert.Contains("Dictionary", result); + } + + [TestCase(false)] + [TestCase(true)] + public void FrameworkGenericArityDoesNotCauseFalseAmbiguity(bool generic) + { + var result = Reduce(Write(generic ? new CSharpType(typeof(Task)) : new CSharpType(typeof(Task)))); + StringAssert.Contains(generic ? "public Task Value" : "public Task Value", result); + } + + [Test] + public void NestedGenericAndArrayEditsKeepEveryBinding() + { + var result = Reduce(Write(typeof(Dictionary>))); + StringAssert.Contains("Dictionary>", result); + } + + [Test] + public void RepeatedTypesShareOneDecisionWithinTheDocument() + { + var code = Write(typeof(Dictionary>)); + var (compilation, root) = Prepare(code); + Assert.True(new ProviderNameReducer(compilation).TryPlan(root, code.TypeReferences!, out var plan)); + Assert.AreEqual(3, plan!.DecisionCount, "Dictionary, List, DateTimeOffset; repeated element must reuse decision"); + Assert.AreEqual(4, plan.Deletions.Count); + Reduce(code); + } + + [Test] + public void CachedDecisionsDoNotLeakAcrossDocumentScopes() + { + var first = Write(typeof(DateTimeOffset)); + var second = Write(typeof(DateTimeOffset), ns: "Other", declaration: "public partial class Consumer"); + var (compilation, root) = Prepare(first, second.Content); + var reducer = new ProviderNameReducer(compilation); + Assert.True(reducer.TryPlan(root, first.TypeReferences!, out var firstPlan)); + var otherRoot = compilation.SyntaxTrees.Last().GetRoot(); + Assert.True(reducer.TryPlan(otherRoot, second.TypeReferences!, out var secondPlan)); + Assert.AreEqual(1, firstPlan!.Deletions.Count); + Assert.IsEmpty(secondPlan!.Deletions); + Assert.AreSame(otherRoot.SyntaxTree.GetText(), secondPlan.Apply(), "No edits must reuse original source text"); + StringAssert.Contains("global::System.DateTimeOffset", secondPlan.Apply().ToString()); + } + + [Test] + public void MismatchedGenericArityMetadataIsRejected() + { + var code = Write(typeof(Action)); + var sites = code.TypeReferences!.Select(static site => site with { Type = new CSharpType(typeof(Action)) }).ToArray(); + Assert.False(ProviderNameReducer.TryMatchReferences(CSharpSyntaxTree.ParseText(code.Content).GetRoot(), sites, out _)); + } + + [Test] + public void NullableFrameworkTypeKeepsItsBinding() + { + var result = Reduce(Write(new CSharpType(typeof(DateTimeOffset), isNullable: true))); + StringAssert.Contains("DateTimeOffset? Value", result); + } + + [TestCase(typeof(DateTimeOffset[]), "DateTimeOffset[]")] + [TestCase(typeof(DateTimeOffset[][]), "DateTimeOffset[][]")] + [TestCase(typeof(DateTimeOffset[,]), "DateTimeOffset[,]")] + public void FrameworkArrayElementsAreShortenedAndKeepTheirBindings(Type type, string expected) + { + var result = Reduce(Write(type)); + StringAssert.Contains($"public {expected} Value", result); + } + + [Test] + public void ArrayDoesNotPreventOtherReferencesFromBeingShortened() + { + using var writer = new CodeWriter(); + using (writer.SetNamespace("Example")) + { + writer.WriteRawLine("public partial class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset[])} Values => default;"); + writer.WriteLine($"public {typeof(TimeSpan)} Duration => default;"); + writer.WriteRawLine("}"); + } + var result = Reduce(new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences)); + StringAssert.Contains("public DateTimeOffset[] Values", result); + StringAssert.Contains("public TimeSpan Duration", result); + } + + [Test] + public void ArrayElementCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset[])), + "namespace Example { public class DateTimeOffset {} }"); + StringAssert.Contains("global::System.DateTimeOffset[] Value", result); + } + + [Test] + public void GeneratedTypeKeepsItsBinding() + { + var widget = new CSharpType("Widget", "Example", false, false, null, Array.Empty(), true, false); + var result = Reduce(Write(new CSharpType(typeof(List<>), widget)), "namespace Example { public class Widget {} }"); + StringAssert.Contains("List", result); + } + + [Test] + public void FrameworkTypeConflictingWithGeneratedNamespaceTypeStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset)), "namespace Example { public class DateTimeOffset {} }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void TypeParameterCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset), declaration: "public partial class Consumer")); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void HandwrittenPartialNestedTypeCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset)), + "namespace Example { public partial class Consumer { public class DateTimeOffset {} } }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void InheritedNestedTypeCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset), declaration: "public partial class Consumer : Base"), + "namespace Example { public class Base { protected class DateTimeOffset {} } }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void InheritedInterfaceTypeCannotCaptureFrameworkReference() + { + var code = Write(typeof(DateTimeOffset), declaration: "public partial interface Consumer : IBase"); + var result = ReduceOrKeepQualified(code, + "namespace Example { public interface IBase { public class DateTimeOffset {} } }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void MixedGlobalAndNamespaceTypesCannotInspectWrongPartialType() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Anchor {} }"); + writer.WriteRawLine("public partial class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteRawLine("}"); + var code = new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences); + var result = ReduceOrKeepQualified(code, + "namespace Example { public class Consumer {} }\n" + + "public partial class Consumer { public class DateTimeOffset {} }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void AncestorNamespaceCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset), ns: "Example.Inner"), + "namespace Example { public class DateTimeOffset {} }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void AliasCollisionStaysQualified() + { + var result = Reduce(Write(typeof(DateTimeOffset), imports: "using DateTimeOffset = Example.Custom;"), + "namespace Example { public class Custom {} }"); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void ImportedTypeNotInProviderReferencesStillBlocksReduction() + { + var assembly = CSharpCompilation.Create("UnreferencedCollision", + [CSharpSyntaxTree.ParseText("namespace Other { public class DateTimeOffset {} }")], + RuntimeReferences, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var stream = new MemoryStream(); + Assert.True(assembly.Emit(stream).Success); + var reference = MetadataReference.CreateFromImage(stream.ToArray()); + var result = Reduce(Write(typeof(DateTimeOffset), imports: "using Other;"), additionalReference: reference); + StringAssert.Contains("global::System.DateTimeOffset Value", result); + } + + [Test] + public void NestedFrameworkTypeStaysQualifiedInFirstPrototype() + { + var result = Reduce(Write(typeof(Environment.SpecialFolder))); + StringAssert.Contains("global::System.Environment.SpecialFolder Value", result); + } + + [Test] + public void RawNamesAndStringsAreNotRewritten() + { + var code = Write(typeof(DateTimeOffset), extraMembers: + "public global::System.DateTimeOffset Raw => default;\n" + + "public const string Text = \"global::System.DateTimeOffset\";"); + var result = Reduce(code); + StringAssert.Contains("public DateTimeOffset Value", result); + StringAssert.Contains("global::System.DateTimeOffset Raw", result); + StringAssert.Contains("\"global::System.DateTimeOffset\"", result); + } + + [Test] + public void MixedEditsKeepStaticReceiverAndCastBindings() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(Dictionary>)} Before => default;"); + writer.WriteLine($"public object Current => {typeof(DateTimeOffset)}.Now;"); + writer.WriteLine($"public object Convert(object input) => ({typeof(TimeSpan)})input;"); + writer.WriteLine($"public {typeof(List)} After => default;"); + writer.WriteRawLine("} }"); + var result = Reduce(new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences)); + StringAssert.Contains("Dictionary> Before", result); + StringAssert.Contains("DateTimeOffset.Now", result); + StringAssert.DoesNotContain("global::System.DateTimeOffset.Now", result); + StringAssert.Contains("(global::System.TimeSpan)input", result); + StringAssert.Contains("List After", result); + } + + [Test] + public void StaticTypePrefixIsShortenedAndKeepsItsBinding() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public object Value => {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + var code = new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences); + var result = Reduce(code); + StringAssert.Contains("DateTimeOffset.Now", result); + StringAssert.DoesNotContain("global::System.DateTimeOffset.Now", result); + } + + [Test] + public void CastTypeStaysQualifiedInFirstPrototype() + { + var number = new CSharpType("Number", "Example", false, false, null, Array.Empty(), true, false); + using var writer = new CodeWriter(); + using (writer.SetNamespace("Example")) + { + writer.WriteRawLine("public class Consumer {"); + writer.WriteLine($"public object Convert(int input) => ({number})input;"); + writer.WriteRawLine("}"); + } + var code = new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences); + var result = Reduce(code, + "namespace Example { public class Number { public static explicit operator Number(int value) => new Number(); } }"); + StringAssert.Contains("(global::Example.Number)input", result); + } + + [Test] + public void UsingStaticRequiresQualifiedNames() + { + var code = Write(typeof(DateTimeOffset), imports: "using static System.Math;"); + var (compilation, root) = Prepare(code); + Assert.False(new ProviderNameReducer(compilation).TryPlan(root, code.TypeReferences!, out _)); + } + + [Test] + public void ConditionalDeclarationsRequireQualifiedNames() + { + var code = Write(typeof(DateTimeOffset), extraMembers: "#if FEATURE\npublic int Other;\n#endif"); + var (compilation, root) = Prepare(code); + Assert.False(new ProviderNameReducer(compilation).TryPlan(root, code.TypeReferences!, out _)); + } + + [Test] + public void IncorrectReferenceSpansAreRejected() + { + var code = Write(typeof(DateTimeOffset)); + var root = CSharpSyntaxTree.ParseText(code.Content).GetRoot(); + var sites = code.TypeReferences!.Select(static site => site with { Span = new Microsoft.CodeAnalysis.Text.TextSpan(site.Span.Start + 1, site.Span.Length) }).ToArray(); + Assert.False(ProviderNameReducer.TryMatchReferences(root, sites, out _)); + } + + [Test] + public void LegacyArraySuffixSpansAreRejected() + { + // Older writer metadata included the array suffix and array identity, + // rather than the named element. Keep rejecting that unsupported shape. + var code = Write(typeof(DateTimeOffset[])); + var root = CSharpSyntaxTree.ParseText(code.Content).GetRoot(); + var start = code.TypeReferences!.Single().Span.Start; + var sites = new[] + { + new TypeReferenceSite(new Microsoft.CodeAnalysis.Text.TextSpan(start, "global::System.DateTimeOffset[]".Length), + new CSharpType(typeof(DateTimeOffset[]))) + }; + Assert.False(ProviderNameReducer.TryMatchReferences(root, sites, out _)); + } + + [Test] + public void MismatchedRecordedTypeIdentityIsRejected() + { + var code = Write(typeof(DateTimeOffset)); + var root = CSharpSyntaxTree.ParseText(code.Content).GetRoot(); + var sites = code.TypeReferences!.Select(static site => site with { Type = new CSharpType(typeof(TimeSpan)) }).ToArray(); + Assert.False(ProviderNameReducer.TryMatchReferences(root, sites, out _)); + } + + private static CodeFile Write(CSharpType type, string ns = "Example", string declaration = "public partial class Consumer", + string imports = "", string extraMembers = "") + { + using var writer = new CodeWriter(); + if (imports.Length > 0) + { + writer.WriteRawLine(imports); + } + using (writer.SetNamespace(ns)) + { + writer.WriteRawLine(declaration); + using (writer.Scope()) + { + writer.WriteLine($"public {type} Value => default;"); + if (extraMembers.Length > 0) + { + writer.WriteRawLine(extraMembers); + } + } + } + return new CodeFile(writer.ToString(), "Consumer.cs", writer.TypeReferences); + } + + private static (CSharpCompilation Compilation, SyntaxNode Root) Prepare(CodeFile code, string? additionalSource = null, MetadataReference? additionalReference = null) + { + var tree = CSharpSyntaxTree.ParseText(code.Content); + Assert.True(ProviderNameReducer.TryMatchReferences(tree.GetRoot(), code.TypeReferences!, out _), "Writer reference offsets must match syntax nodes"); + var trees = additionalSource == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(additionalSource) }; + var references = additionalReference == null ? RuntimeReferences : RuntimeReferences.Append(additionalReference); + var compilation = CSharpCompilation.Create("ReducerBindingTests", trees, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + AssertNoErrors(compilation); + return (compilation, tree.GetRoot()); + } + + private static string ReduceOrKeepQualified(CodeFile code, string additionalSource) + { + var (compilation, root) = Prepare(code, additionalSource); + if (!new ProviderNameReducer(compilation).TryPlan(root, code.TypeReferences!, out _)) + { + return root.ToFullString(); + } + return Reduce(code, additionalSource); + } + + private static string Reduce(CodeFile code, string? additionalSource = null, MetadataReference? additionalReference = null) + { + var (compilation, root) = Prepare(code, additionalSource, additionalReference); + Assert.True(new ProviderNameReducer(compilation).TryPlan(root, code.TypeReferences!, out var plan)); + Assert.True(ProviderNameReducer.TryMatchReferences(root, code.TypeReferences!, out var originalNodes)); + var before = BoundTypes(compilation, originalNodes); + var output = plan!.Apply().ToString(); + + // Compare bindings in the actual emitted/reparsed product. Translate each + // original reference span through every preceding/contained deletion, so + // nested generics and unchanged references remain regression anchors too. + var parsed = CSharpSyntaxTree.ParseText(output, (CSharpParseOptions)root.SyntaxTree.Options); + var parsedRoot = parsed.GetRoot(); + var nodes = originalNodes.Select(node => + { + var start = node.SpanStart - plan.Deletions.Where(cut => cut.End <= node.SpanStart).Sum(static cut => cut.Length); + var end = node.Span.End - plan.Deletions.Where(cut => cut.End <= node.Span.End).Sum(static cut => cut.Length); + var span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(start, end); + var mapped = parsedRoot.FindNode(span, getInnermostNodeForTie: true); + Assert.AreEqual(span, mapped.Span, "Every original reference must map to one emitted node"); + var terminal = node switch + { + QualifiedNameSyntax qualified => qualified.Right, + MemberAccessExpressionSyntax member => member.Name, + AliasQualifiedNameSyntax alias => alias.Name, + _ => null + }; + var ownPrefixDeleted = terminal != null && plan.Deletions.Contains(TextSpan.FromBounds(node.SpanStart, terminal.SpanStart)); + var expectedKind = ownPrefixDeleted ? terminal!.RawKind : node.RawKind; + Assert.AreEqual(expectedKind, mapped.RawKind, "Prefix-only edits must preserve emitted parse shape"); + return mapped; + }).ToArray(); + var emitted = compilation.ReplaceSyntaxTree(root.SyntaxTree, parsed); + AssertNoErrors(emitted); + CollectionAssert.AreEquivalent(before, BoundTypes(emitted, nodes), "Emitted text must retain every original type binding"); + return output; + } + + private static KeyValuePair[] BoundTypes(CSharpCompilation compilation, SyntaxNode[] nodes) + { + var model = compilation.GetSemanticModel(nodes[0].SyntaxTree); + return nodes.Select((node, index) => + { + var type = model.GetTypeInfo(node).Type ?? model.GetSymbolInfo(node).Symbol as ITypeSymbol; + Assert.NotNull(type, node.ToString()); + return new KeyValuePair(index.ToString(), Identity(type!)); + }).ToArray(); + } + + private static string Identity(ITypeSymbol type) => type switch + { + INamedTypeSymbol named => $"{named.ContainingAssembly.Identity}:{named.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}<{string.Join(",", named.TypeArguments.Select(Identity))}>", + IArrayTypeSymbol array => $"{Identity(array.ElementType)}[{array.Rank}]", + _ => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + private static void AssertNoErrors(Compilation compilation) => Assert.That( + compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.ToString()), Is.Empty); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs new file mode 100644 index 00000000000..7d278158016 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderQualifierReductionTests + { + private string? _previous; + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "true"); + MockHelpers.LoadMockGenerator(configuration: "{\"package-name\":\"Example\",\"disable-xml-docs\":true}"); + } + + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + + [Test] + public void StaticTypePrefixIsShortenedWithoutDroppingItsMember() + { + var output = Reduce(WriteStatic(typeof(DateTimeOffset))); + StringAssert.Contains("DateTimeOffset.Now", output); + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + } + + [TestCase("DateTimeOffset")] + [TestCase("unused")] + public void StaticPrefixRespectsParameterShadowing(string parameter) + { + var output = Reduce(WriteStatic(typeof(DateTimeOffset), $"object {parameter}")); + if (parameter == "DateTimeOffset") StringAssert.Contains("global::System.DateTimeOffset.Now", output); + else StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + } + + [TestCase("from DateTimeOffset in new[] { 1 } select ")] + [TestCase("from first in new[] { 1 } let DateTimeOffset = first select ")] + [TestCase("from first in new[] { 1 } join DateTimeOffset in new[] { 1 } on first equals DateTimeOffset select ")] + [TestCase("from first in new[] { 1 } group first by first into DateTimeOffset select ")] + [TestCase("from first in new[] { 1 } join second in new[] { 1 } on first equals second into DateTimeOffset select ")] + public void QueryRangeVariablesKeepStaticReceiverQualified(string query) + { + using var writer = new CodeWriter(); + writer.WriteRawLine("using System.Linq;"); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteRawLine("public object M() => " + query); + writer.WriteLine($"{typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + StringAssert.Contains("global::System.DateTimeOffset.Now", Reduce(File(writer))); + } + + [Test] + public void ImplicitAccessorValueKeepsStaticReceiverQualified() + { + var type = new CSharpType("value", "Example", false, false, null, Array.Empty(), true, false); + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer { public int P { set {"); + writer.WriteLine($"_ = {type}.Now;"); + writer.WriteRawLine("} } } }"); + StringAssert.Contains("global::Example.value.Now", Reduce(File(writer), + "namespace Example { public class value { public static int Now => 1; } }")); + } + + [Test] + public void NameofStaticMemberKeepsItsTargetAndConstant() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public string M() => nameof({typeof(DateTimeOffset)}.Now);"); + writer.WriteRawLine("} }"); + StringAssert.Contains("nameof(DateTimeOffset.Now)", Reduce(File(writer))); + } + + [TestCase(false)] + [TestCase(true)] + public void ContextualAttributeSuffixIsRetainedAcrossOrdinaryAndAsyncContexts(bool qualifiedOnly) + { + var type = new CSharpType("awaitAttribute", "Example", false, false, null, Array.Empty(), true, false); + using var writer = new CodeWriter(); + writer.WriteRawLine(qualifiedOnly ? "namespace Other {" : "namespace Example {"); + writer.WriteLine($"[{type}]"); + writer.WriteRawLine("public class Consumer { public async System.Threading.Tasks.Task M() {"); + writer.WriteLine($"[{type}]"); + writer.WriteRawLine("void Local() {} Local(); await System.Threading.Tasks.Task.CompletedTask; } } }"); + var additional = "namespace Example { public sealed class awaitAttribute : System.Attribute {} }" + + (qualifiedOnly ? "namespace Other { public class awaitAttribute : System.Attribute {} }" : string.Empty); + var output = Reduce(File(writer), additional); + StringAssert.Contains(qualifiedOnly ? "[global::Example.awaitAttribute]" : "[awaitAttribute]", output); + StringAssert.DoesNotContain("[await]", output); + StringAssert.DoesNotContain("[global::Example.await]", output); + } + + [Test] + public void TypeAndShadowedExpressionDecisionsDoNotShareCacheEntries() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("using System.Linq;"); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Value => default;"); + writer.WriteLine($"public object M() => from DateTimeOffset in new[] {{1}} select {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + var output = Reduce(File(writer)); + StringAssert.Contains("public DateTimeOffset Value", output); + StringAssert.Contains("global::System.DateTimeOffset.Now", output); + } + + [Test] + public void StaticMethodAndGenericReceiverKeepSelectedMembers() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public string M() => {typeof(Convert)}.ToString(42);"); + writer.WriteLine($"public int Compare() => {typeof(Comparer)}.Default.Compare(default, default);"); + writer.WriteRawLine("} }"); + var output = Reduce(File(writer)); + StringAssert.Contains("Convert.ToString(42)", output); + StringAssert.Contains("Comparer.Default", output); + } + + [Test] + public void AttributeQualificationAndSuffixAreShortened() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute))); + StringAssert.Contains("[Obsolete(\"message\")]", output); + } + + [Test] + public void AttributeShortNameCollisionRetainsSuffix() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute)), "namespace Example { public class Obsolete : System.Attribute {} }"); + StringAssert.Contains("[ObsoleteAttribute(\"message\")]", output); + } + + [Test] + public void AttributeAliasCollisionRetainsSuffix() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute), "using Obsolete = Example.OtherAttribute;"), + "namespace Example { public class OtherAttribute : System.Attribute {} }"); + StringAssert.Contains("[ObsoleteAttribute(\"message\")]", output); + } + + [Test] + public void AttributeExpandedFullNameCollisionCanUseUnambiguousShortName() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute), "using Other;"), + "namespace Other { public class ObsoleteAttributeAttribute : System.Attribute { public ObsoleteAttributeAttribute(string text) {} } }"); + StringAssert.Contains("[Obsolete(\"message\")]", output); + } + + [Test] + public void AttributeQualificationCollisionAllowsQualifiedSuffixOnly() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute)), + "namespace Example { public class ObsoleteAttribute : System.Attribute {} }"); + StringAssert.Contains("[global::System.Obsolete(\"message\")]", output); + } + + [Test] + public void CompetingShortNameInTargetNamespaceRetainsAttributeSuffix() + { + var type = new CSharpType("NoticeAttribute", "Example", false, false, null, Array.Empty(), true, false); + var output = Reduce(WriteAttribute(type), + "namespace Example { public class Notice {} public class NoticeAttribute : System.Attribute { public NoticeAttribute(string text) {} } }"); + StringAssert.Contains("[NoticeAttribute(\"message\")]", output); + } + + [TestCase(typeof(Attribute))] // Abstract attribute target. + [TestCase(typeof(System.Text.StringBuilder))] // Not an attribute target. + [TestCase(typeof(List))] // Generic, not an attribute target. + public void UnsupportedRecordedAttributeTargetsAreNotReduced(Type type) + { + // These are deliberately invalid C# attribute targets; this oracle + // checks retention, not compilation acceptance of the original input. + var file = WriteAttribute(type); + var tree = CSharpSyntaxTree.ParseText(file.Content); + var compilation = CSharpCompilation.Create("InvalidAttribute", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.True(new ProviderNameReducer(compilation).TryPlan(tree.GetRoot(), file.TypeReferences!, out var plan)); + Assert.AreEqual(file.Content, plan!.Apply().ToString()); + } + + [Test] + public void AttributeFullNameCollisionRetainsQualification() + { + var output = Reduce(WriteAttribute(typeof(ObsoleteAttribute)), + "namespace Example { public class ObsoleteAttribute : System.Attribute {} }"); + StringAssert.Contains("[global::System.", output); + } + + [Test] + public void AttributeArgumentsAlsoShortenRecordedStaticReceivers() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example {"); + writer.WriteLine($"[{typeof(AttributeUsageAttribute)}({typeof(AttributeTargets)}.All)]"); + writer.WriteRawLine("public class Consumer : System.Attribute {} }"); + StringAssert.Contains("[AttributeUsage(AttributeTargets.All)]", Reduce(File(writer))); + } + + [Test] + public void CombinedGenericAndStaticEditsPreserveBindings() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public {typeof(List)} Values => default;"); + writer.WriteLine($"public object M() => {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + var output = Reduce(File(writer)); + StringAssert.Contains("List", output); + StringAssert.Contains("DateTimeOffset.Now", output); + } + + private static CodeFile WriteStatic(CSharpType type, string parameters = "") + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Example { public class Consumer {"); + writer.WriteLine($"public object M({parameters}) => {type}.Now;"); + writer.WriteRawLine("} }"); + return File(writer); + } + + private static CodeFile WriteAttribute(CSharpType type, string import = "") + { + using var writer = new CodeWriter(); + if (import.Length > 0) writer.WriteRawLine(import); + writer.WriteRawLine("namespace Example {"); + writer.WriteLine($"[{type}(\"message\")]"); + writer.WriteRawLine("public class Consumer {} }"); + return File(writer); + } + + private static CodeFile File(CodeWriter writer) => new(writer.ToString(), "Consumer.cs", writer.TypeReferences); + + private static string Reduce(CodeFile file, string? additional = null) + { + var tree = CSharpSyntaxTree.ParseText(file.Content); + var trees = additional == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(additional) }; + var compilation = CSharpCompilation.Create("QualifierBindings", trees, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + AssertNoErrors(compilation); + var root = tree.GetRoot(); + Assert.True(ProviderNameReducer.TryMatchReferences(root, file.TypeReferences!, out var references)); + Assert.True(new ProviderNameReducer(compilation).TryPlan(root, file.TypeReferences!, out var plan)); + var model = compilation.GetSemanticModel(tree); + var observations = new List<(TextSpan Span, string Identity, bool Constant)>(); + foreach (var reference in references) + { + observations.Add((reference.Span, Identity(model, reference), false)); + if (reference.Parent is AttributeSyntax attribute) + observations.Add((attribute.Span, Identity(model, attribute), false)); + if (reference.Parent is MemberAccessExpressionSyntax member && member.Expression == reference) + observations.Add((member.Span, Identity(model, member), false)); + foreach (var invocation in reference.Ancestors().OfType().Where(static n => n.Expression.ToString() == "nameof")) + observations.Add((invocation.Span, model.GetConstantValue(invocation).Value!.ToString()!, true)); + } + var output = plan!.Apply().ToString(); + var parsed = CSharpSyntaxTree.ParseText(output); + var after = compilation.ReplaceSyntaxTree(tree, parsed); + AssertNoErrors(after); + var afterModel = after.GetSemanticModel(parsed); + foreach (var observation in observations) + { + var start = observation.Span.Start - plan.Deletions.Where(cut => cut.End <= observation.Span.Start).Sum(static cut => cut.Length); + var end = observation.Span.End - plan.Deletions.Where(cut => cut.End <= observation.Span.End).Sum(static cut => cut.Length); + var span = TextSpan.FromBounds(start, end); + var node = parsed.GetRoot().FindNode(span, getInnermostNodeForTie: true); + Assert.AreEqual(span, node.Span, "Every original type/member/attribute must retain an exact emitted node"); + Assert.AreEqual(observation.Identity, observation.Constant ? afterModel.GetConstantValue(node).Value!.ToString()! : Identity(afterModel, node), node.ToString()); + } + return output; + } + + private static string Identity(SemanticModel model, SyntaxNode node) + { + var symbol = model.GetSymbolInfo(node).Symbol ?? model.GetTypeInfo(node).Type; + Assert.NotNull(symbol, node.ToString()); + return symbol!.ContainingAssembly?.Identity + ":" + symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + } + + private static void AssertNoErrors(Compilation compilation) => Assert.That( + compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.ToString()), Is.Empty); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderReducerTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderReducerTests.cs new file mode 100644 index 00000000000..0665fe8f770 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderReducerTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Input; +using Microsoft.TypeSpec.Generator.Primitives; +using Microsoft.TypeSpec.Generator.Tests.Common; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderReducerTests + { + private const string ReducerEnvironmentVariable = "TYPESPEC_PROVIDER_REDUCER"; + + [TestCase(0)] // No writer metadata. + [TestCase(1)] // using static. + [TestCase(2)] // Custom rewriter still gets its semantic model. + [TestCase(3)] // Invalid reference spans. + [TestCase(4)] // Interface document. + [TestCase(5)] // Mixed global/namespace declarations. + [TestCase(6)] // Removing an empty namespace invalidates original spans. + public async Task UncertainDocumentsKeepQualifiedNamesWithoutRoslyn(int mode) + { + var previous = Environment.GetEnvironmentVariable(ReducerEnvironmentVariable); + var directory = Path.Combine(Path.GetTempPath(), "typespec-provider-reducer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + Environment.SetEnvironmentVariable(ReducerEnvironmentVariable, "true"); + MockHelpers.LoadMockGenerator(outputPath: directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true}"); + using var writer = new CodeWriter(); + if (mode == 6) + { + writer.WriteRawLine("namespace Empty { }"); + } + if (mode == 1) + { + writer.WriteRawLine("using static System.Math;"); + } + if (mode == 5) + { + writer.WriteRawLine("namespace Sample { public class Anchor {} }"); + writer.WriteRawLine("public class Clock {"); + } + else + { + writer.WriteRawLine(mode == 4 ? "namespace Sample { public interface Clock {" : "namespace Sample { public class Clock {"); + } + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine(mode == 5 ? "}" : "} }"); + var text = writer.ToString(); + var references = mode == 0 ? null : writer.TypeReferences; + if (mode == 3) + { + references = references!.Select(static site => site with + { + Span = new Microsoft.CodeAnalysis.Text.TextSpan(site.Span.Start + 1, site.Span.Length) + }).ToArray(); + } + var probe = new SemanticProbeRewriter(); + if (mode == 2) + { + CodeModelGenerator.Instance.AddRewriter(probe); + } + var code = new CodeFile(text, "Clock.cs", references); + var profile = new GeneratedCodeWorkspacePostProcessingProfile(); + GeneratedCodeWorkspace.PostProcessingProfile = profile; + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(code); + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + StringAssert.Contains("global::System.DateTimeOffset", file.Text); + if (mode == 6) + { + StringAssert.DoesNotContain("namespace Empty", file.Text); + } + } + if (mode == 6) + { + StringAssert.DoesNotContain("ProviderReducer.Scope", profile.GetSummary()); + } + StringAssert.Contains("ProviderReducer.KeepQualified", profile.GetSummary()); + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", profile.GetSummary()); + StringAssert.DoesNotContain("ProviderReducer.Reduce,", profile.GetSummary()); + if (mode == 2) + { + Assert.True(probe.SawSemanticModel); + } + else + { + StringAssert.DoesNotContain("GetSemanticModelAsync", profile.GetSummary()); + } + } + finally + { + GeneratedCodeWorkspace.PostProcessingProfile = null; + Environment.SetEnvironmentVariable(ReducerEnvironmentVariable, previous); + Directory.Delete(directory, recursive: true); + } + } + + private sealed class SemanticProbeRewriter : LibraryRewriter + { + internal bool SawSemanticModel { get; private set; } + + public override SyntaxNode? VisitCompilationUnit(CompilationUnitSyntax node) + { + SawSemanticModel = SemanticModel != null; + return base.VisitCompilationUnit(node); + } + } + + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task OptInSimpleModelUsesProviderReducerInsteadOfRoslyn(bool useProviderReducer, bool arrayReference) + { + var previous = Environment.GetEnvironmentVariable(ReducerEnvironmentVariable); + var directory = Path.Combine(Path.GetTempPath(), "typespec-provider-reducer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + Environment.SetEnvironmentVariable(ReducerEnvironmentVariable, useProviderReducer ? "true" : "false"); + var model = InputFactory.Model("Clock", properties: + [InputFactory.Property("Timestamp", new InputDateTimeType(DateTimeKnownEncoding.Rfc3339, "utcDateTime", "TypeSpec.utcDateTime", InputPrimitiveType.String))]); + MockHelpers.LoadMockGenerator(inputModelTypes: [model], outputPath: directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true}"); + var provider = CodeModelGenerator.Instance.TypeFactory.CreateModel(model)!; + var code = new TypeProviderWriter(provider).Write(); + if (arrayReference) + { + using var writer = new CodeWriter(); + using (writer.SetNamespace("Sample")) + { + writer.WriteRawLine("public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset[])} Timestamps => default;"); + writer.WriteLine($"public {typeof(TimeSpan)} Duration => default;"); + writer.WriteRawLine("}"); + } + code = new CodeFile(writer.ToString(), "Clock.cs", writer.TypeReferences); + } + var profile = new GeneratedCodeWorkspacePostProcessingProfile(); + GeneratedCodeWorkspace.PostProcessingProfile = profile; + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(code); + string? output = null; + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + output = file.Text; + } + + Assert.NotNull(output); + StringAssert.Contains("DateTimeOffset", output); + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + if (arrayReference) + { + StringAssert.Contains("DateTimeOffset[] Timestamps", output); + StringAssert.Contains("TimeSpan Duration", output); + StringAssert.DoesNotContain("global::System.TimeSpan", output); + } + if (useProviderReducer) + { + StringAssert.Contains("ProviderReducer.Reduce", profile.GetSummary()); + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", profile.GetSummary()); + StringAssert.DoesNotContain("GetSemanticModelAsync", profile.GetSummary()); + } + else + { + StringAssert.Contains("Roslyn.Simplifier.ReduceAsync", profile.GetSummary()); + StringAssert.DoesNotContain("ProviderReducer.Reduce,", profile.GetSummary()); + } + } + finally + { + GeneratedCodeWorkspace.PostProcessingProfile = null; + Environment.SetEnvironmentVariable(ReducerEnvironmentVariable, previous); + Directory.Delete(directory, recursive: true); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderRetainedCleanupTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderRetainedCleanupTests.cs new file mode 100644 index 00000000000..92969b520b2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderRetainedCleanupTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public class ProviderRetainedCleanupTests + { + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static p => MetadataReference.CreateFromFile(p)).ToArray(); + + [Test] + public void ExplicitGenericArgumentsPreventNonGenericOverloadSelection() + { + var source = "namespace Example { public class Consumer { static string Pick(T x)=>\"generic\"; static string Pick(object x)=>\"nongeneric\"; public string M()=>Pick(new object()); } }"; + var output = Reduce(source); + StringAssert.Contains("Pick", output); + Assert.AreEqual("generic", Run(output)); + Assert.AreEqual("nongeneric", Run(source.Replace("Pick(", "Pick("))); + } + + [Test] + public void TypedDefaultCastProtectsOverloadSelection() + { + var source = "namespace Example { public class Consumer { static string Pick(string x)=>\"string\"; static string Pick(int x)=>\"int\"; public string M()=>Pick((string)default); } }"; + var output = Reduce(source); + StringAssert.Contains("(string)default", output); + Assert.AreEqual("string", Run(output)); + var broken = Compile(source.Replace("(string)default", "default")); + Assert.True(broken.GetDiagnostics().Any(static d => d.Severity == DiagnosticSeverity.Error)); + } + + [Test] + public void TypedNullCastProtectsOverloadSelection() + { + var source = "namespace Example { public class Consumer { static string Pick(string x)=>\"string\"; static string Pick(object x)=>\"object\"; public string M()=>Pick((object)null); } }"; + var output = Reduce(source); + StringAssert.Contains("(object)null", output); + Assert.AreEqual("object", Run(output)); + Assert.AreEqual("string", Run(source.Replace("(object)null", "null"))); + } + + [Test] + public void CompleteMemberReceiverCannotBeDroppedAcrossShadowing() + { + var source = "namespace Example { public class Consumer { public static int Value=>7; public int M() { int Value=1; return global::Example.Consumer.Value; } } }"; + var output = Reduce(source); + StringAssert.Contains("Consumer.Value", output); + Assert.AreEqual(7, Run(output)); + Assert.AreEqual(1, Run(source.Replace("global::Example.Consumer.Value", "Value"))); + } + + [Test] + public void NameofConstantAloneDoesNotProveTargetIdentity() + { + var source = "namespace Example { public class Consumer { const string DateTimeOffset=\"local\"; public string M()=>nameof(global::System.DateTimeOffset); } }"; + var output = Reduce(source); + StringAssert.Contains("global::System.DateTimeOffset", output); + var shortened = source.Replace("global::System.DateTimeOffset", "DateTimeOffset"); + Assert.AreEqual(Run(source), Run(shortened), "Equal nameof strings cannot establish equal referenced symbols"); + Assert.AreNotEqual(NameofSymbol(source), NameofSymbol(shortened)); + } + + private static string NameofSymbol(string source) + { + var compilation = Compile(source); + var tree = compilation.SyntaxTrees.Single(); + var argument = tree.GetRoot().DescendantNodes().OfType() + .Single().ArgumentList.Arguments.Single().Expression; + var symbol = compilation.GetSemanticModel(tree).GetSymbolInfo(argument).Symbol!; + return symbol.Kind + ":" + symbol.ToDisplayString(); + } + + [Test] + public void RemovingNestedTypeOwnerCanSelectAnotherType() + { + var source = "namespace Example { public class Outer { public class Inner {} } public class Inner {} public class Consumer { public string M()=>typeof(global::Example.Outer.Inner).FullName!; } }"; + var output = Reduce(source); + StringAssert.Contains("Outer.Inner", output); + Assert.AreEqual("Example.Outer+Inner", Run(output)); + Assert.AreEqual("Example.Inner", Run(source.Replace("global::Example.Outer.Inner", "Inner"))); + } + + [Test] + public void UnrecordedFrameworkLikeIdentifierMustNotBecomeKeyword() + { + var source = "namespace Example { public struct Byte {} public class Consumer { public string M()=>typeof(Byte).FullName!; } }"; + var output = Reduce(source); + StringAssert.Contains("typeof(Byte)", output); + Assert.AreEqual("Example.Byte", Run(output)); + Assert.AreEqual("System.Byte", Run(source.Replace("typeof(Byte)", "typeof(byte)"))); + } + + private static CSharpCompilation Compile(string source) => CSharpCompilation.Create("RetainedRules", new[] { CSharpSyntaxTree.ParseText(source) }, + References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + private static string Reduce(string source) + { + var compilation = Compile(source); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + Assert.True(new ProviderNameReducer(compilation).TryPlan(compilation.SyntaxTrees.Single().GetRoot(), Array.Empty(), out var plan)); + return plan!.Apply().ToString(); + } + private static object? Run(string source) + { + using var bytes = new MemoryStream(); + var result = Compile(source).Emit(bytes); + Assert.That(result.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var type = Assembly.Load(bytes.ToArray()).GetType("Example.Consumer")!; + return type.GetMethod("M")!.Invoke(Activator.CreateInstance(type), null); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderSyntaxCleanupTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderSyntaxCleanupTests.cs new file mode 100644 index 00000000000..28710e17350 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderSyntaxCleanupTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class ProviderSyntaxCleanupTests + { + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static p => MetadataReference.CreateFromFile(p)).ToArray(); + + [Test] + public void WholeExpressionParenthesesAreRemovedWithoutChangingResults() + { + var source = "namespace Example { public class Consumer { public int M(int a,int b,int c) { int value=(a+b); if ((value>c)) return (value-c); return (a+(b*c)); } } }"; + var output = Reduce(source); + StringAssert.DoesNotContain("if ((", output); + StringAssert.DoesNotContain("return (value-c)", output); + Assert.AreEqual(Run(source, 3, 4, 2), Run(output, 3, 4, 2)); + Assert.AreEqual(Run(source, 1, 2, 5), Run(output, 1, 2, 5)); + } + + [Test] + public void RightAssociatedFloatingPointGroupingIsPreserved() + { + var source = "namespace Example { public class Consumer { public double M(double a,double b,double c) => (a+(b+c)); } }"; + var output = Reduce(source); + StringAssert.Contains("b+c", output); + Assert.AreEqual(Run(source, 1e16, -1e16, 1d), Run(output, 1e16, -1e16, 1d)); + } + + [Test] + public void KnownFieldAndPropertyReceiversAreRemoved() + { + var source = "namespace Example { public class Consumer { private int _field=3; public int Value=>4; public int M()=>this._field+this.Value; } }"; + var output = Reduce(source); + StringAssert.DoesNotContain("this._field", output); + StringAssert.DoesNotContain("this.Value", output); + Assert.AreEqual(Run(source), Run(output)); + } + + [Test] + public void ShadowedFieldReceiverIsPreserved() + { + var source = "namespace Example { public class Consumer { private int _field=3; public int M(int _field)=>this._field+_field; } }"; + var output = Reduce(source); + StringAssert.Contains("this._field", output); + Assert.AreEqual(Run(source, 10), Run(output, 10)); + } + + [Test] + public void QueryVariableCannotCaptureRemovedThisReceiver() + { + var source = "using System.Linq; namespace Example { public class Consumer { public int Value=5; public int M()=>(from Value in new[]{2} select this.Value).Single(); } }"; + var output = Reduce(source); + StringAssert.Contains("this.Value", output); + Assert.AreEqual(Run(source), Run(output)); + } + + [Test] + public void ObjectWorkaroundAndMethodReceiverArePreserved() + { + var source = "namespace Example { public class Consumer { public int Object=>4; private int F()=>2; public int M()=>this.Object+this.F(); } }"; + var output = Reduce(source); + StringAssert.Contains("this.Object", output); + StringAssert.Contains("this.F()", output); + Assert.AreEqual(Run(source), Run(output)); + } + + [Test] + public void CombinedNestedDeletionsCannotFuseReturnAndLiteral() + { + var source = "namespace Example { public class Consumer { public int M() { return((1)+(2)); } } }"; + var output = Reduce(source); + Assert.AreEqual(3, Run(output)); + } + + [Test] + public void DelegateInvocationParenthesisChainCannotBecomeACast() + { + var source = "namespace Example { public class Consumer { public int M() { System.Func f=x=>x+1; return ((f))(1); } } }"; + var output = Reduce(source); + Assert.AreEqual(2, Run(output)); + } + + [TestCase("public int M(int x) { return(x); }", 3)] + [TestCase("public object M(object x) => (x)as object;", 3)] + [TestCase("public bool M(object x) => x is(int);", 3)] + [TestCase("public int Value=>4; public int M(int x) { return(this.Value); }", 3)] + public void WhitespaceFreeBoundariesDoNotMergeTokens(string members, int input) + { + var source = "namespace Example { public class Consumer { " + members + " } }"; + var output = Reduce(source); + Assert.AreEqual(Run(source, input), Run(output, input)); + } + + [Test] + public void PrimaryConstructorInOtherPartialCannotCaptureThisField() + { + var source = "namespace Example { public partial class Consumer { public int M()=>this.Value; } }"; + var extra = "namespace Example { public class Base { public int Value=1; } public partial class Consumer(int Value) : Base {} }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("PrimaryConstructor", new[] { tree, CSharpSyntaxTree.ParseText(extra) }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.True(new ProviderNameReducer(compilation).TryPlan(tree.GetRoot(), Array.Empty(), out var plan)); + var output = plan!.Apply().ToString(); + var after = compilation.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(output)); + using var bytes = new MemoryStream(); + var result = after.Emit(bytes); + Assert.That(result.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var type = Assembly.Load(bytes.ToArray()).GetType("Example.Consumer")!; + Assert.AreEqual(1, type.GetMethod("M")!.Invoke(Activator.CreateInstance(type, 9), null)); + StringAssert.Contains("this.Value", output); + } + + private static string Reduce(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("Cleanup", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + AssertNoErrors(compilation); + Assert.True(new ProviderNameReducer(compilation).TryPlan(tree.GetRoot(), Array.Empty(), out var plan)); + var output = plan!.Apply().ToString(); + AssertNoErrors(compilation.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(output))); + return output; + } + + private static object? Run(string source, params object[] arguments) + { + var compilation = CSharpCompilation.Create("CleanupRuntime", new[] { CSharpSyntaxTree.ParseText(source) }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var stream = new MemoryStream(); + var result = compilation.Emit(stream); + Assert.That(result.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var type = Assembly.Load(stream.ToArray()).GetType("Example.Consumer")!; + return type.GetMethod("M")!.Invoke(Activator.CreateInstance(type), arguments); + } + + private static void AssertNoErrors(Compilation compilation) => Assert.That( + compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.ToString()), Is.Empty); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Statements/ExpressionStatementEmissionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Statements/ExpressionStatementEmissionTests.cs new file mode 100644 index 00000000000..79eb9102542 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Statements/ExpressionStatementEmissionTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Expressions; +using Microsoft.TypeSpec.Generator.Statements; +using Microsoft.TypeSpec.Generator.Snippets; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests.Statements +{ + [NonParallelizable] + public class ExpressionStatementEmissionTests + { + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + + [TestCase("=")] + [TestCase("+=")] + [TestCase("-=")] + [TestCase("*=")] + [TestCase("/=")] + [TestCase("%=")] + [TestCase("&=")] + [TestCase("|=")] + [TestCase("^=")] + [TestCase("<<=")] + [TestCase(">>=")] + [TestCase(">>>=")] + [TestCase("??=")] + public void AssignmentStatementCompilesWithoutSimplification(string operation) + { + MockHelpers.LoadMockGenerator(); + var variable = new VariableExpression(typeof(int), "bytesConsumed"); + var delta = new BinaryOperatorExpression("-", new VariableExpression(typeof(int), "jsonPathLength"), new VariableExpression(typeof(int), "localLength")); + using var writer = new CodeWriter(); + writer.AppendRaw("public static class C { public static int M(int jsonPathLength, int localLength) {"); + writer.AppendRaw(operation == "??=" ? "int? bytesConsumed = 0;" : "int bytesConsumed = 0;"); + new ExpressionStatement(new BinaryOperatorExpression(operation, variable, delta)).Write(writer); + writer.AppendRaw(operation == "??=" ? "return bytesConsumed.Value; } }" : "return bytesConsumed; } }"); + var text = writer.ToString(false); + var compilation = CSharpCompilation.Create("RawAssignment", [CSharpSyntaxTree.ParseText(text)], References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty, text); + } + + [Test] + public void AddAndAssignHelperCompilesWithoutSimplification() + { + MockHelpers.LoadMockGenerator(); + var bytesConsumed = new VariableExpression(typeof(int), "bytesConsumed").As(); + var jsonPathLength = new VariableExpression(typeof(int), "jsonPathLength"); + var localLength = new VariableExpression(typeof(int), "localLength"); + using var writer = new CodeWriter(); + writer.AppendRaw("public static class C { public static int M(int bytesConsumed, int jsonPathLength, int localLength) {"); + // Faithful Sample producer: AddAndAssign wraps the assignment in ScopedApi. + bytesConsumed.AddAndAssign(jsonPathLength.As().Minus(localLength.As())).Write(writer); + writer.AppendRaw("return bytesConsumed; } }"); + var text = writer.ToString(false); + var compilation = CSharpCompilation.Create("ActualAssignmentHelper", [CSharpSyntaxTree.ParseText(text)], References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty, text); + } + + [Test] + public void MultipleTransparentWrappersKeepStatementAssignmentLegal() + { + using var writer = new CodeWriter(); + var value = new VariableExpression(typeof(int), "value"); + var wrapped = new BinaryOperatorExpression("+=", value, value).As().As(); + new ExpressionStatement(wrapped).Write(writer); + Assert.AreEqual("value += value;\n", writer.ToString(false)); + } + + [Test] + public void AssignmentInsideAnExpressionRetainsParentheses() + { + using var writer = new CodeWriter(); + var value = new VariableExpression(typeof(int), "value"); + var assignment = new BinaryOperatorExpression("+=", value, value); + new BinaryOperatorExpression("*", assignment, value).Write(writer); + Assert.AreEqual("((value += value) * value)", writer.ToString(false)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs new file mode 100644 index 00000000000..7799968027b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + private const string FlagDefinition = "using System; using Fixtures; namespace Fixtures { [AttributeUsage(AttributeTargets.All, AllowMultiple=true)] public class FlagAttribute:Attribute { public FlagAttribute() {} public FlagAttribute(int value, string tag=\"default\") {} public string Text {get;set;} public int Number; } } "; + + [TestCase("[global::Fixtures.FlagAttribute] public class Clock {}")] + [TestCase("public class Clock { [global::Fixtures.FlagAttribute] public void M() {} }")] + [TestCase("public class Clock { [return: global::Fixtures.FlagAttribute] public int M() => 1; }")] + [TestCase("public class Clock { public void M([global::Fixtures.FlagAttribute] int value) {} }")] + [TestCase("public class Clock { [global::Fixtures.FlagAttribute] public int Value; }")] + [TestCase("public class Clock { [global::Fixtures.FlagAttribute] public int Value {get;set;} }")] + [TestCase("public class Clock { public Func M() => [global::Fixtures.FlagAttribute] () => 1; }")] + public async Task StructuralAttributeSupportedDeclarationTargetsKeepMetadata(string declaration) + { + var source = FlagDefinition + declaration; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.DoesNotContain("global::Fixtures.FlagAttribute", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("7, Text=\"named\", Number=2")] + [TestCase("tag: \"explicit\", value: 7, Text=\"named\", Number=2")] + public async Task StructuralAttributeArgumentMappingAndAssignmentsRemainExact(string arguments) + { + var source = FlagDefinition + "[global::Fixtures.FlagAttribute(" + arguments + ")] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("[Flag(" + arguments + ")]", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("@FlagAttribute", "@FlagAttribute")] + [TestCase("\\u0046lagAttribute", "\\u0046lag")] + [TestCase("FlagAttrib\\u0075te", "FlagAttrib\\u0075te")] + public async Task StructuralAttributeSuffixPreservesEscapeSemantics(string token, string expected) + { + var source = FlagDefinition + "[global::Fixtures." + token + "] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("[" + expected + "]", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("asAttribute")] + [TestCase("awaitAttribute")] + [TestCase("fieldAttribute")] + public async Task StructuralAttributeKeywordAndContextualSuffixesRemain(string name) + { + var source = "using System; using Fixtures; namespace Fixtures { public class " + name + ":Attribute {} } [global::Fixtures." + name + "] public class Clock {}"; + var target = new CSharpType(name, "Fixtures", false, false, null, Array.Empty(), true, false); + var output = await Reduce(RecordedAttributeSource(source, target)); + StringAssert.Contains("[" + name + "]", output); + AssertStructuralAttributes(source, output); + } + + [Test] + public async Task StructuralAttributeTypeParameterShadowRequiresQualifiedName() + { + var source = FlagDefinition + "public class Clock { [global::Fixtures.FlagAttribute] public void M() {} }"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("[Fixtures.Flag]", output); + AssertStructuralAttributes(source, output); + } + + [Test] + public async Task StructuralAttributeDuplicateCrossRoleMetadataIsNotAuthorization() + { + var source = FlagDefinition + "[global::Fixtures.FlagAttribute] public class Clock {}"; + var original = RecordedAttributeSource(source); + var ambiguous = original.TypeReferences!.Concat(original.TypeReferences!.Select(s => s with { Role = TypeReferenceRole.WholeTypeCref })).ToArray(); + var output = await Reduce(new CodeFile(source, "Clock.cs", ambiguous)); + StringAssert.Contains("[global::Fixtures.FlagAttribute]", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("new int[] {1,2}")] + [TestCase("null")] + public async Task StructuralAttributeArrayAndEnumContractsKeepValues(string values) + { + var source = "using System; using Fixtures; namespace Fixtures { public class FlagAttribute:Attribute { public FlagAttribute(AttributeTargets targets, int[] values) {} public string[] Names {get;set;} } } " + + "[global::Fixtures.FlagAttribute(AttributeTargets.Class | AttributeTargets.Property, " + values + ", Names=new string[]{\"a\",\"b\"})] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("[Flag(AttributeTargets.Class | AttributeTargets.Property", output); + AssertStructuralAttributes(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralAttributeCapturedLambdaNameUsesOuterObserver(bool explicitOverride) + { + var source = FlagDefinition + "class Clock { static string Capture(Func value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text=null) => text; " + + "public string M() => Capture([global::Fixtures.FlagAttribute] () => 1" + (explicitOverride ? ", \"manual\"" : "") + "); }"; + var output = await Reduce(RecordedAttributeSource(source)); + Assert.That(output.Contains("[global::Fixtures.FlagAttribute]"), Is.EqualTo(!explicitOverride)); + AssertStructuralAttributes(source, output); + AssertParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralAttributeConstructorCallerDefaultsAreNotNormalized() + { + const string source = "using System; using System.Runtime.CompilerServices; using Fixtures; namespace Fixtures { public class FlagAttribute:Attribute { public FlagAttribute([CallerMemberName]string member=null, [CallerFilePath]string path=null, [CallerLineNumber]int line=0) {} } }\n[global::Fixtures.FlagAttribute] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + // A speculative-coordinate mismatch may conservatively retain. Either + // result must preserve actual emitted caller-derived attribute values. + AssertStructuralAttributes(source, output); + } + + [Test] + public async Task StructuralAttributeConstructorCaptureKeepsArgumentText() + { + const string source = "using System; using System.Runtime.CompilerServices; using Fixtures; namespace Fixtures { public class FlagAttribute:Attribute { public FlagAttribute(Type type, [CallerArgumentExpression(\"type\")] string text=null) {} } } [global::Fixtures.FlagAttribute(typeof(global::System.DateTimeOffset))] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("typeof(global::System.DateTimeOffset)", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralAttributeMultilineNameKeepsTriviaAndLaterLines(string newline) + { + var source = FlagDefinition + "[global::Fixtures." + newline + "FlagAttribute] public class Clock {}"; + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("global::Fixtures." + newline + "FlagAttribute", output); + AssertStructuralAttributes(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralAttributeVisibilityIsOccurrenceOwned(bool hiddenFirst) + { + const string hidden = "\n#line hidden\n[global::Fixtures.FlagAttribute] public class Hidden {}\n#line default\n"; + const string visible = "[global::Fixtures.FlagAttribute] public class Visible {}\n"; + var source = FlagDefinition + (hiddenFirst ? hidden + visible : visible + hidden); + var output = await Reduce(RecordedAttributeSource(source)); + StringAssert.Contains("[global::Fixtures.FlagAttribute] public class Hidden", output); + if (hiddenFirst) StringAssert.Contains("[Flag] public class Visible", output); + AssertStructuralAttributes(source, output); + } + + [TestCase("disabled")] + [TestCase("custom")] + [TestCase("header")] + public async Task StructuralAttributeWorkspaceAndHeaderGatesRemain(string mode) + { + if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + var source = FlagDefinition + "[global::Fixtures.FlagAttribute] public class Clock {}"; + var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; + var output = await Reduce(RecordedAttributeSource(source), extra); + StringAssert.Contains("[global::Fixtures.FlagAttribute]", output); + // The custom route includes the pre-existing formatter; compare the + // attribute contract while its unrelated formatting is tested elsewhere. + AssertStructuralAttributes(source, output, formatterRoute: mode == "custom"); + } + + [Test] + public void StructuralAttributeOracleRejectsChangedConstructorValuesAndNamedMembers() + { + var source = FlagDefinition + "[global::Fixtures.FlagAttribute(7, Text=\"value\", Number=2)] public class Clock {}"; + AssertStructuralAttributes(source, source.Replace("global::Fixtures.FlagAttribute", "Flag")); + Assert.Throws(() => AssertStructuralAttributes(source, source.Replace("(7,", "(8,"))); + Assert.Throws(() => AssertStructuralAttributes(source, source.Replace("Text=\"value\"", "Text=\"different\""))); + Assert.Throws(() => AssertStructuralAttributes(source, source.Replace("Number=2", "Number=3"))); + } + + [Test] + public void StructuralAttributeOperationProofRejectsChangedImplicitDefaults() + { + const string source = "using System; using System.Runtime.CompilerServices; class FlagAttribute:Attribute { public FlagAttribute([CallerLineNumber]int line=0){} }\n[Flag] class First {}\n\n[Flag] class Second {}"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("AttrDefaultProof", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var attributes = tree.GetRoot().DescendantNodes().OfType().Where(a => a.Name.ToString() == "Flag").ToArray(); + var first = (IAttributeOperation)model.GetOperation(attributes[0])!; + var second = (IAttributeOperation)model.GetOperation(attributes[1])!; + var a = (IObjectCreationOperation)first.Operation; + var b = (IObjectCreationOperation)second.Operation; + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(a.Constructor, b.Constructor), Is.True); + Assert.That(a.Arguments.Single().ArgumentKind, Is.EqualTo(b.Arguments.Single().ArgumentKind)); + Assert.That(a.Arguments.Single().Value.ConstantValue.Value, Is.Not.EqualTo(b.Arguments.Single().Value.ConstantValue.Value)); + Assert.That(StructuralAttributeRules.SameContract(first, first), Is.True); + Assert.That(StructuralAttributeRules.SameContract(second, second), Is.True); + Assert.That(StructuralAttributeRules.SameContract(first, second), Is.False, "Same constructor/default mapping but different caller-derived constants must fail"); + } + + private static CodeFile RecordedAttributeSource(string source, CSharpType? type = null) + { + type ??= new CSharpType("FlagAttribute", "Fixtures", false, false, null, Array.Empty(), true, false); + var root = CSharpSyntaxTree.ParseText(source).GetRoot(); + var sites = root.DescendantNodes().OfType().Where(a => a.Name.ToString().StartsWith("global::Fixtures.", StringComparison.Ordinal)) + .Select(a => new TypeReferenceSite(a.Name.Span, type)).ToArray(); + return new CodeFile(source, "Clock.cs", sites); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs new file mode 100644 index 00000000000..d731e4c6386 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task StructuralAttributeRecordedNameAndSuffixKeepConstructor() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;"); + writer.WriteLine($"[{typeof(ObsoleteAttribute)}(\"message\")]"); + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("[Obsolete(\"message\")]", output); + AssertStructuralAttributes(code.Content, output); + } + + [TestCase("FlagAttribute", "Flag")] + [TestCase("Tag", "Tag")] + public async Task StructuralAttributeNamedAssignmentsKeepMembersAndValues(string name, string expected) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using Fixtures; namespace Fixtures { public class " + name + ":Attribute { public " + name + "(int value) {} public string Text {get;set;} public int Number; } }"); + writer.WriteLine($"[{new CSharpType(name, "Fixtures", false, false, null, Array.Empty(), true, false)}(7, Text=\"value\", Number=3)]"); + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("[" + expected + "(7, Text=\"value\", Number=3)]", output); + AssertStructuralAttributes(code.Content, output); + } + + [Test] + public async Task StructuralAttributeArgumentTypeQualificationIsNotPartOfNameReduction() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using Fixtures; namespace Fixtures { public class FlagAttribute:Attribute { public FlagAttribute(Type type) {} } }"); + writer.WriteLine($"[{new CSharpType("FlagAttribute", "Fixtures", false, false, null, Array.Empty(), true, false)}(typeof({typeof(DateTimeOffset)}))]"); + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("[Flag(typeof(global::System.DateTimeOffset))]", output); + AssertStructuralAttributes(code.Content, output); + } + + [Test] + public async Task StructuralAttributeAmbiguousSuffixKeepsUnabbreviatedName() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using Fixtures; using Other; namespace Fixtures { public class FlagAttribute:Attribute {} } namespace Other { public class Flag:Attribute {} }"); + writer.WriteLine($"[{new CSharpType("FlagAttribute", "Fixtures", false, false, null, Array.Empty(), true, false)}]"); + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("[FlagAttribute]", output); + AssertStructuralAttributes(code.Content, output); + } + + [TestCase("missing")] + [TestCase("wrongSpan")] + [TestCase("wrongType")] + [TestCase("wrongRole")] + public async Task StructuralAttributeInvalidProvenanceRetains(string mode) + { + var original = Recorded(writer => + { + writer.WriteRawLine("using System;"); + writer.WriteLine($"[{typeof(ObsoleteAttribute)}(\"message\")]"); + writer.WriteRawLine("public class Clock {}"); + }); + var sites = original.TypeReferences!.Select(site => mode switch + { + "wrongSpan" => site with { Span = new TextSpan(site.Span.Start + 1, site.Span.Length - 1) }, + "wrongType" => site with { Type = typeof(CLSCompliantAttribute) }, + "wrongRole" => site with { Role = TypeReferenceRole.WholeTypeCref }, + _ => site + }).ToArray(); + var code = new CodeFile(original.Content, original.Name, mode == "missing" ? Array.Empty() : sites); + var output = await Reduce(code); + StringAssert.Contains("[global::System.ObsoleteAttribute(\"message\")]", output); + AssertStructuralAttributes(code.Content, output); + } + + [Test] + public async Task StructuralAttributeConditionalDocumentRemainsConservative() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;"); + writer.WriteLine($"[{typeof(ObsoleteAttribute)}(\"message\")]"); + writer.WriteRawLine("public class Clock { private int V() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 6;\n#endif\n} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("[global::System.ObsoleteAttribute(\"message\")]", output); + AssertStructuralAttributes(code.Content, output); + } + + [Test] + public void StructuralAttributeProbeWholeSpeculativeModel() + { + const string source = "using System; [global::System.ObsoleteAttribute(\"message\")] public class Clock {}"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("AttributeApiProbe", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var original = tree.GetRoot().DescendantNodes().OfType().Single(); + var candidate = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("Obsolete"), original.ArgumentList); + Assert.That(model.TryGetSpeculativeSemanticModel(original.SpanStart, candidate, out var speculative), Is.True); + Assert.That(speculative, Is.Not.Null); + var spec = speculative!; + var oldCtor = model.GetSymbolInfo(original); var newCtor = spec.GetSymbolInfo(candidate); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(oldCtor.Symbol, newCtor.Symbol), Is.True); + Assert.That(model.GetOperation(original), Is.InstanceOf()); + Assert.That(spec.GetOperation(candidate), Is.InstanceOf()); + } + + [Test] + public async Task StructuralAttributeFormerTypeOnlyPolicyNowAdmitsRecordedAttribute() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Empty { }"); + writer.WriteRawLine("namespace Sample {"); + writer.WriteLine($"[{typeof(ObsoleteAttribute)}]"); + writer.WriteRawLine("public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteLine($"public {typeof(TimeSpan)} Echo({typeof(TimeSpan)} value) => value;"); + writer.WriteLine($"public object Snapshot() => {typeof(DateTimeOffset)}.Now;"); + writer.WriteLine($"public string Name() => nameof({typeof(DateTimeOffset)});"); + writer.WriteLine($"public System.Type Kind() => typeof({typeof(DateTimeOffset)});"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("[Obsolete]", output); + StringAssert.Contains("nameof(global::System.DateTimeOffset)", output); + } + + private static void AssertStructuralAttributes(string before, string after, bool formatterRoute = false) + { + string Type(ITypeSymbol? type) => StructuralTypeNameRuleOracleTests.TypeIdentity(type); + string Constant(TypedConstant value) => value.Kind + ":" + Type(value.Type) + ":" + + (value.IsNull ? "" : value.Kind == TypedConstantKind.Array ? "[" + string.Join(",", value.Values.Select(Constant)) + "]" : + value.Value is ITypeSymbol type ? Type(type) : Convert.ToString(value.Value, System.Globalization.CultureInfo.InvariantCulture)); + (string[] Contracts, string Outside) Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("AttributeOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var attributes = tree.GetRoot().DescendantNodes().OfType().ToArray(); + var contracts = attributes.Select(attribute => + { + var info = model.GetSymbolInfo(attribute); + Assert.That(info.Symbol, Is.InstanceOf()); + Assert.That(info.CandidateReason, Is.EqualTo(CandidateReason.None)); + Assert.That(info.CandidateSymbols, Is.Empty); + var list = (AttributeListSyntax)attribute.Parent!; + var owner = list.Parent!; + var symbols = owner switch + { + CompilationUnitSyntax => new[] { list.Target?.Identifier.ValueText == "module" ? (ISymbol)compilation.SourceModule : compilation.Assembly }, + BaseFieldDeclarationSyntax field => field.Declaration.Variables.Select(v => model.GetDeclaredSymbol(v)!).ToArray(), + LambdaExpressionSyntax lambda => new[] { ((IAnonymousFunctionOperation)model.GetOperation(lambda)!).Symbol }, + _ => new[] { model.GetDeclaredSymbol(owner)! } + }; + var records = symbols.Select(symbol => + { + var values = list.Target?.Identifier.ValueText == "return" && symbol is IMethodSymbol method ? method.GetReturnTypeAttributes() : symbol.GetAttributes(); + return values.Single(a => a.ApplicationSyntaxReference?.Span == attribute.Span); + }).ToArray(); + var data = records[0]; + var members = attribute.ArgumentList?.Arguments.Where(a => a.NameEquals != null) + .Select(a => model.GetSymbolInfo(a.NameEquals!.Name).Symbol?.GetDocumentationCommentId()) ?? Array.Empty(); + return info.Symbol!.GetDocumentationCommentId() + ":" + Type(data.AttributeClass) + ":" + + string.Join("||", records.Select(record => string.Join("|", record.ConstructorArguments.Select(Constant)) + ":named=" + + string.Join("|", record.NamedArguments.Select(a => a.Key + "=" + Constant(a.Value))))) + + ":members=" + string.Join("|", members); + }).ToArray(); + var outside = tree.GetText().WithChanges(attributes.Select(attribute => new TextChange(attribute.Name.Span, "__ATTRIBUTE_NAME__"))).ToString(); + return (contracts, outside); + } + var original = Snapshot(before); var candidate = Snapshot(after); + Assert.That(candidate.Contracts, Is.EqualTo(original.Contracts), "Whole attribute constructors/typed constants/named members must remain identical"); + if (formatterRoute) + Assert.That(SyntaxFactory.AreEquivalent(CSharpSyntaxTree.ParseText(candidate.Outside).GetRoot(), CSharpSyntaxTree.ParseText(original.Outside).GetRoot()), Is.True); + else + { + Assert.That(candidate.Outside, Is.EqualTo(original.Outside), "All source including argument expressions outside attribute NAME spans must stay exact"); + Assert.That(System.Text.RegularExpressions.Regex.Matches(after, "\\r\\n|\\r|\\n").Select(m => m.Value), + Is.EqualTo(System.Text.RegularExpressions.Regex.Matches(before, "\\r\\n|\\r|\\n").Select(m => m.Value))); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs new file mode 100644 index 00000000000..c08096600bd --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("alias")] + [TestCase("warning")] + [TestCase("globalOtherDocument")] + public async Task StructuralC5CrefRetentionAndOptionalDefaultKeepOriginalImportUse(string mode) + { + const string type = "global::System.DateTimeOffset"; + var imports = mode == "globalOtherDocument" ? "" : "using System;"; + if (mode == "alias") imports += "using Alias=System.String;"; + var extraMember = mode == "alias" ? "Alias Name=>\"\";" : mode == "warning" ? "int unused;" : ""; + var source = imports + "\nclass Clock{" + extraMember + "\n/// \nvoid M(global::System.DateTimeOffset value=default(DateTimeOffset)){} }"; + var extra = mode == "globalOtherDocument" ? "global using System;" : null; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(type, StringComparison.Ordinal), type.Length), typeof(DateTimeOffset), TypeReferenceRole.WholeTypeCref); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site }), extra); + var before = C5CompositionDiagnostics(source, extra); + Assert.That(before.Any(static d => d.StartsWith("CS8019|", StringComparison.Ordinal)), Is.False); + Assert.That(C5CompositionDiagnostics(output, extra), Is.EqualTo(before), "Retaining the cref must not expose an import removed by optional-default cleanup."); + } + + [Test] + public async Task StructuralC5HandlerRetentionAndOptionalDefaultKeepOriginalImportUse() + { + const string type = "global::System.DateTimeOffset"; + const string source = "using System;class Clock{static void M(global::System.DateTimeOffset value=default(DateTimeOffset)){}static void Capture(global::System.DateTimeOffset value,string text){}static void Entry()=>Capture(global::System.DateTimeOffset.Now,$\"{1}\");}"; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(type + ".Now", StringComparison.Ordinal), type.Length), typeof(DateTimeOffset)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + var before = C5CompositionDiagnostics(source, null); + Assert.That(before.Any(static d => d.StartsWith("CS8019|", StringComparison.Ordinal)), Is.False); + Assert.That(C5CompositionDiagnostics(output, null), Is.EqualTo(before)); + } + + [TestCase("int")] + [TestCase("string")] + [TestCase("T")] + [TestCase("global::System.DateTimeOffset")] + [TestCase("global::System.String[]")] + [TestCase("global::System.Collections.Generic.IEnumerable")] + public async Task StructuralC5ImportIndependentOptionalDefaultStillReduces(string type) + { + var generic = type == "T" ? "" : ""; + var source = "using Alias=System.String;class Clock{Alias Name=>\"\";void M" + generic + "(" + type + " value=default(" + type + ")){}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=default)", output); + } + + [Test] + public async Task StructuralC5RootedOuterDefaultWithImportedGenericArgumentRetainsImportUse() + { + const string type = "global::System.DateTimeOffset"; + const string source = "using System;using Alias=System.String;class Clock{Alias Name=>\"\";\n/// \nvoid M(global::System.Collections.Generic.IEnumerable value=default(global::System.Collections.Generic.IEnumerable)){} }"; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(type, StringComparison.Ordinal), type.Length), typeof(DateTimeOffset), TypeReferenceRole.WholeTypeCref); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + Assert.That(C5CompositionDiagnostics(source, null).Any(static d => d.StartsWith("CS8019|", StringComparison.Ordinal)), Is.False); + Assert.That(C5CompositionDiagnostics(output, null), Is.EqualTo(C5CompositionDiagnostics(source, null))); + StringAssert.Contains("default(global::System.Collections.Generic.IEnumerable)", output); + } + + [Test] + public async Task StructuralC5RecordedDeclarationTypeIsNotAnUnchangedImportWitness() + { + const string type = "global::System.Collections.Generic.List"; + const string source = "using System;class Clock{void M(global::System.Collections.Generic.List value=default(global::System.Collections.Generic.List)){} }"; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(type, StringComparison.Ordinal), type.Length), typeof(System.Collections.Generic.List)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains("value=default(global::System.Collections.Generic.List)", output); + Assert.That(C5CompositionDiagnostics(output, null), Is.EqualTo(C5CompositionDiagnostics(source, null))); + } + + [Test] + public async Task StructuralC5VetoedOptionalDefaultDoesNotExposeChildImportUse() + { + const string type = "global::System.Collections.Generic.List"; + const string source = "using System;\nusing System.Collections.Generic;\nclass Clock\n{\n static void M(\n global::System.Collections.Generic.List value =\n default(global::System.Collections.Generic.List)) { }\n}"; + var site = new TypeReferenceSite(new TextSpan(source.LastIndexOf(type, StringComparison.Ordinal), type.Length), typeof(System.Collections.Generic.List)); + var before = C5CompositionDiagnostics(source, null); + Assert.That(before.Count(static d => d.StartsWith("CS8019|", StringComparison.Ordinal)), Is.EqualTo(1)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + Assert.That(C5CompositionDiagnostics(output, null), Is.EqualTo(before), "A newly vetoed whole-default erasure must not expose a child namespace rewrite that consumes an originally unused import."); + StringAssert.Contains("default(global::System.Collections.Generic.List)", output); + } + + [Test] + public async Task StructuralC5OriginallyIneligibleDefaultKeepsIndependentChildReduction() + { + const string type = "global::System.Collections.Generic.List"; + const string source = "using System; using System.Collections.Generic; class Clock { public List Used=>null; static void M(object value=default(global::System.Collections.Generic.List)) {} }"; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(type, StringComparison.Ordinal), type.Length), typeof(System.Collections.Generic.List)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains("default(List)", output, "Original C2 ineligibility must not freeze independently supported child qualification changes."); + Assert.That(C5CompositionDiagnostics(source, null), Is.Empty); + Assert.That(C5CompositionDiagnostics(output, null), Is.Empty); + } + + [TestCase(GeneratedKind.Unknown)] + [TestCase(GeneratedKind.NotGenerated)] + [TestCase(GeneratedKind.MarkedGenerated)] + public void StructuralC5CrefDiagnosticViewPreservesGeneratedClassification(GeneratedKind kind) + { + var tree = CSharpSyntaxTree.ParseText("class Clock{string? Value=>null;}", new CSharpParseOptions(LanguageVersion.Preview)); + var provider = new C5GeneratedDiagnosticFilter(kind); + var compilation = CSharpCompilation.Create("C5GeneratedFacts", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable).WithSyntaxTreeOptionsProvider(provider)); + var diagnosticView = StructuralCrefImportRules.OriginalDiagnosticCompilation(compilation); + var diagnosticProvider = diagnosticView.Options.SyntaxTreeOptionsProvider; + Assert.That(diagnosticProvider, Is.Not.Null); + Assert.That(diagnosticProvider!.IsGenerated(tree, CancellationToken.None), Is.EqualTo(kind)); + Assert.That(diagnosticProvider.TryGetDiagnosticValue(tree, "CS8019", CancellationToken.None, out _), Is.False); + Assert.That(diagnosticProvider.TryGetGlobalDiagnosticValue("CS8019", CancellationToken.None, out _), Is.False); + Assert.That(diagnosticView.SyntaxTrees.Single(), Is.SameAs(tree)); + } + + private static string[] C5CompositionDiagnostics(string source, string? extra) + { + var parse = new CSharpParseOptions(LanguageVersion.Preview, DocumentationMode.Diagnose); + var tree = CSharpSyntaxTree.ParseText(source, parse, "Clock.cs"); + var trees = extra == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(extra, parse, "Additional.cs") }; + var compilation = CSharpCompilation.Create("C5Composition", trees, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, reportSuppressedDiagnostics: true)); + return compilation.GetDiagnostics().Select(static d => d.Id + "|" + d.Severity + "|" + d.IsSuppressed + "|" + d.GetMessage()).OrderBy(static d => d, StringComparer.Ordinal).ToArray(); + } + + private sealed class C5GeneratedDiagnosticFilter(GeneratedKind kind) : SyntaxTreeOptionsProvider + { + public override GeneratedKind IsGenerated(SyntaxTree tree, CancellationToken cancellationToken) => kind; + public override bool TryGetDiagnosticValue(SyntaxTree tree, string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = ReportDiagnostic.Suppress; + return diagnosticId == "CS8019"; + } + public override bool TryGetGlobalDiagnosticValue(string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = ReportDiagnostic.Suppress; + return diagnosticId == "CS8019"; + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs new file mode 100644 index 00000000000..d952a29461a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("using System;")] + [TestCase("global using System;")] + public async Task StructuralC5CrefPreservesHiddenUnusedImportDiagnostic(string directive) + { + var code = C5DiagnosticCrefCode(directive, ""); + var output = await Reduce(code); + var before = C5CrefDiagnostics(code.Content); + Assert.That(before.Any(static d => d.StartsWith("CS8019|Hidden|", StringComparison.Ordinal)), Is.True, "The original import must genuinely be unused."); + Assert.That(C5CrefDiagnostics(output), Is.EqualTo(before), "Equal cref symbols and XML do not establish preservation of import diagnostics."); + } + + [Test] + public async Task StructuralC5CrefGenuinelyUsedImportStillSimplifies() + { + var code = C5DiagnosticCrefCode("using System;", "public DateTimeOffset Value=>default;"); + var output = await Reduce(code); + Assert.That(C5CrefDiagnostics(code.Content).Any(static d => d.StartsWith("CS8019|", StringComparison.Ordinal)), Is.False); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + Assert.That(C5CrefDiagnostics(output), Is.EqualTo(C5CrefDiagnostics(code.Content))); + } + + [Test] + public async Task StructuralC5CrefNoImportRetainsUsefulQualifiedReduction() + { + var code = C5DiagnosticCrefCode("", ""); + var output = await Reduce(code); + StringAssert.Contains("cref=\"System.DateTimeOffset\"", output); + Assert.That(C5CrefDiagnostics(output), Is.EqualTo(C5CrefDiagnostics(code.Content))); + } + + [Test] + public async Task StructuralC5CrefWorkspaceDiagnosticCertificateEnvironment() + { + var probe = new C5CrefEnvironmentProbe(); + CodeModelGenerator.Instance.AddRewriter(probe); + await Reduce(C5DiagnosticCrefCode("using System;", "public DateTimeOffset Value=>default;")); + Assert.That(probe.Seen, Is.True); + } + + private sealed class C5CrefEnvironmentProbe : LibraryRewriter + { + internal bool Seen { get; private set; } + public override SyntaxNode? VisitCompilationUnit(Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax node) + { + var compilation = SemanticModel!.Compilation; + var options = (CSharpCompilationOptions)compilation.Options; + var provider = options.SyntaxTreeOptionsProvider; + TestContext.Progress.WriteLine("C5 cref environment: " + options.GeneralDiagnosticOption + " | specific=" + options.SpecificDiagnosticOptions.Count + " | provider=" + provider?.GetType().FullName + " | imports=" + string.Join(",", options.Usings) + " | supported=" + StructuralCrefImportRules.SupportsCompilation(compilation) + " | tree=" + StructuralCrefImportRules.HasSafeOriginalTreeDiagnostics(SemanticModel)); + TestContext.Progress.WriteLine("C5 cref original diagnostics: " + string.Join(";", SemanticModel.GetDiagnostics().Select(static d => d.ToString()))); + Seen = true; + return base.VisitCompilationUnit(node); + } + } + + private static CodeFile C5DiagnosticCrefCode(string directive, string extra) + { + const string type = "global::System.DateTimeOffset"; + var source = directive + "\nclass Clock{\n/// \npublic void M(){}" + extra + "}"; + return new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(new TextSpan(source.IndexOf(type, StringComparison.Ordinal), type.Length), typeof(DateTimeOffset), TypeReferenceRole.WholeTypeCref) }); + } + + private static string[] C5CrefDiagnostics(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Preview, DocumentationMode.Diagnose), "Clock.cs"); + var compilation = CSharpCompilation.Create("C5CrefDiagnostics", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, reportSuppressedDiagnostics: true)); + return compilation.GetDiagnostics().Select(static d => d.Id + "|" + d.Severity + "|" + d.IsSuppressed + "|" + d.GetMessage()).OrderBy(static d => d, StringComparer.Ordinal).ToArray(); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs new file mode 100644 index 00000000000..03424c4165b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("global using System;")] + [TestCase("using Moment=System.DateTimeOffset;")] + [TestCase("using static System.Math;")] + public async Task StructuralC5CrefUnsupportedImportInOtherDocumentRetains(string import) + { + var code = C5DiagnosticCrefCode("", ""); + var output = await Reduce(code, import + "\nclass Other{}"); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + } + + [TestCase(DocumentationMode.Parse, false)] + [TestCase(DocumentationMode.Diagnose, false)] + [TestCase(DocumentationMode.None, false)] + [TestCase(DocumentationMode.Parse, true)] + [TestCase(DocumentationMode.Diagnose, true)] + public void StructuralC5CrefDocumentationModeAndRealImportUse(DocumentationMode mode, bool genuinelyUsed) + { + var code = C5DiagnosticCrefCode("using System;", genuinelyUsed ? "public DateTimeOffset Value=>default;" : ""); + var output = ReduceC5CrefDirect(code, new CSharpParseOptions(LanguageVersion.Preview, mode), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + StringAssert.Contains(genuinelyUsed ? "cref=\"DateTimeOffset\"" : "cref=\"global::System.DateTimeOffset\"", output); + } + + [TestCase(false)] + [TestCase(true)] + public void StructuralC5CrefFilteredUnusedDiagnosticsDoNotEstablishImportUse(bool provider) + { + var code = C5DiagnosticCrefCode("using System;", ""); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + options = provider ? options.WithSyntaxTreeOptionsProvider(new C5UnusedImportFilter()) : + options.WithSpecificDiagnosticOptions(new[] { new KeyValuePair("CS8019", ReportDiagnostic.Suppress) }); + var output = ReduceC5CrefDirect(code, new CSharpParseOptions(LanguageVersion.Preview, DocumentationMode.Diagnose), options); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + } + + [TestCase("#pragma warning disable CS8019\n")] + [TestCase("#line (100,1)-(200,10) 40 \"map.cs\"\n")] + public async Task StructuralC5CrefUnsupportedReportingAndMappingRetains(string prefix) + { + var code = C5DiagnosticCrefCode(prefix + "using System;", ""); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + } + + [TestCase(false)] + [TestCase(true)] + public void StructuralC5CrefNamespaceImportScopesWithGenuineUseStillSimplify(bool fileScoped) + { + var code = C5DiagnosticCrefCode("", "public DateTimeOffset Value=>default;"); + var prefix = fileScoped ? "namespace N;using System;\n" : "namespace N{using System;\n"; + var source = prefix + code.Content + (fileScoped ? "" : "}"); + var sites = code.TypeReferences!.Select(site => site with { Span = new TextSpan(site.Span.Start + prefix.Length, site.Span.Length) }).ToArray(); + var output = ReduceC5CrefDirect(new CodeFile(source, code.Name, sites), new CSharpParseOptions(LanguageVersion.Preview, DocumentationMode.Diagnose), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + } + + private static string ReduceC5CrefDirect(CodeFile code, CSharpParseOptions parse, CSharpCompilationOptions options) + { + var tree = CSharpSyntaxTree.ParseText(code.Content, parse, code.Name); + var compilation = CSharpCompilation.Create("C5CrefSafety", new[] { tree }, References, options); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), code.TypeReferences!); + return rewriter?.Visit(tree.GetRoot())?.ToFullString() ?? code.Content; + } + + private sealed class C5UnusedImportFilter : SyntaxTreeOptionsProvider + { + public override GeneratedKind IsGenerated(SyntaxTree tree, CancellationToken cancellationToken) => GeneratedKind.Unknown; + public override bool TryGetDiagnosticValue(SyntaxTree tree, string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = diagnosticId == "CS8019" ? ReportDiagnostic.Suppress : ReportDiagnostic.Default; + return diagnosticId == "CS8019"; + } + public override bool TryGetGlobalDiagnosticValue(string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic severity) + { + severity = ReportDiagnostic.Default; + return false; + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5HandlerCaptureRegressionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5HandlerCaptureRegressionTests.cs new file mode 100644 index 00000000000..5ce1e8d6486 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5HandlerCaptureRegressionTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false, false)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task StructuralC5HandlerConstructorCallerExpressionPreservesSiblingSource(bool conditional, bool nested) + { + const string types = "using System.Runtime.CompilerServices;[InterpolatedStringHandler]public ref struct H{public readonly string Text;public H(int literalLength,int formattedCount,Sample.Clock receiver,[CallerArgumentExpression(\"receiver\")]string text=\"\"){Text=text;}public void AppendLiteral(string text){}public void AppendFormatted(int value){}}"; + const string receiver = "global::Sample.Clock"; + var value = nested ? "Wrap(" + receiver + ".Make())" : receiver + ".Make()"; + var source = types + "namespace Sample{public class Clock{public static Clock Make()=>new Clock();private static Clock Wrap(Clock value)=>value;private static string Capture(Clock value,[InterpolatedStringHandlerArgument(\"value\")]H handler)=>handler.Text;public static string Entry()=>Capture(" + value + ",$\"{1}\");" + (conditional ? ConditionalParenthesesBranch : "") + "}}"; + var target = new CSharpType("Clock", "Sample", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(receiver, StringComparison.Ordinal), receiver.Length), target); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + var original = ExecuteC5Capture(source); + Assert.That(original, Is.EqualTo(value)); + Assert.That(ExecuteC5Capture(output), Is.EqualTo(original), "Synthesized handler constructors can capture sibling source even when the selected enclosing method has no CAE parameter."); + StringAssert.Contains(value, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralC5UnobservedStaticReceiverStillReduces(bool conditional) + { + const string receiver = "global::Sample.Clock"; + var source = "namespace Sample{public class Clock{public static int Number()=>1;public static int Entry()=>" + receiver + ".Number();" + (conditional ? ConditionalParenthesesBranch : "") + "}}"; + var target = new CSharpType("Clock", "Sample", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(receiver, StringComparison.Ordinal), receiver.Length), target); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains("Entry()=>Clock.Number()", output); + } + + private static string ExecuteC5Capture(string source) + { + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create("C5HandlerRegression", new[] { CSharpSyntaxTree.ParseText(source) }, refs, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var pe = new MemoryStream(); + var emitted = compilation.Emit(pe); + Assert.That(emitted.Success, Is.True, string.Join(";", emitted.Diagnostics)); + return (string)Assembly.Load(pe.ToArray()).GetType("Sample.Clock")!.GetMethod("Entry")!.Invoke(null, null)!; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs new file mode 100644 index 00000000000..19ca001d5b5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralConditionalParenthesesMultilineTriviaKeepsLaterCallerLine(string newline) + { + var source = "using System.Runtime.CompilerServices; class Clock { static int Line([CallerLineNumber] int line = 0) => line; " + + "public bool M(int x, int y) { bool value = (/*a*/" + newline + "(x == y)" + newline + "/*b*/); return value && Line() > 0; }" + ConditionalParenthesesBranch.Replace("\n", newline) + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.DoesNotContain("(x == y)", output); + Assert.That(System.Text.RegularExpressions.Regex.Matches(output, "\\r\\n|\\r|\\n").Select(m => m.Value), + Is.EqualTo(System.Text.RegularExpressions.Regex.Matches(source, "\\r\\n|\\r|\\n").Select(m => m.Value))); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalParenthesesHiddenAndVisibleOrderIsOccurrenceOwned(bool hiddenFirst) + { + const string hidden = "\n#line hidden\npublic bool Hidden(int x, int y) => ((x == y));\n#line default\n"; + const string visible = "public bool Visible(int x, int y) => ((x == y));"; + var source = "class Clock { " + (hiddenFirst ? hidden + visible : visible + hidden) + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("Hidden(int x, int y) => ((x == y))", output); + if (hiddenFirst) StringAssert.Contains("Visible(int x, int y) => x == y", output); + // BeforeFirstLineDirective is deliberately non-visible for this slice. + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesOuterWrapperAroundCallCanReduceWithoutChangingCapturedArgument() + { + const string source = "using System.Runtime.CompilerServices; class Clock { static bool Capture(bool value, [CallerArgumentExpression(\"value\")] string text = null) => value; " + + "public bool M(int x, int y) { return (Capture((x == y))); }" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return Capture((x == y));", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalParenthesesEnclosingCaptureProtectsLambdaAcrossReturnBoundary(bool explicitOverride) + { + var source = "using System.Runtime.CompilerServices; class Clock { static bool Capture(System.Func f, [CallerArgumentExpression(\"f\")] string text = null) => f(); " + + "public bool M(int x, int y) => Capture(() => { return ((x == y)); }" + (explicitOverride ? ", \"manual\"" : "") + ");" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return ((x == y));", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesDifferentVariantValueTypesNeedOnlyGroupingProof() + { + const string source = "class Clock { public bool M(int x, int y) {\n#if NET8_0_OR_GREATER\nint left = x; int right = y;\n#else\nlong left = x; long right = y;\n#endif\nreturn ((left == right)); } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return left == right;", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesGenericLocalFunctionVetoRemains() + { + const string source = "class Clock { public bool M(int x, int y) => ((x == y)); private int V() {\n#if NET8_0_OR_GREATER\nint Local() => 8; return Local();\n#else\nreturn 6;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> ((x == y))", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesOtherDocumentHeaderProofStillRequired() + { + const string source = "class Clock { public bool M(int x, int y) => ((x == y));" + ConditionalParenthesesBranch + "}"; + const string extra = "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif"; + var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); + StringAssert.Contains("=> ((x == y))", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesPartialExpressionDirectiveBoundaryRemainsOpaque() + { + const string source = "class Clock { public bool M(int x, int y) { return\n#if NET8_0_OR_GREATER\n((x == y))\n#else\nfalse\n#endif\n; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("((x == y))", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase("((-(x + y)))")] + [TestCase("((default(int)))")] + [TestCase("((int)(x + y))")] + public async Task StructuralConditionalParenthesesPreservesUnsupportedPayloadsButAllowsOuterCastGrouping(string expression) + { + var source = "class Clock { public int M(int x, int y) => " + expression + ";" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + var expected = expression == "((int)(x + y))" ? "(int)(x + y)" : expression; + StringAssert.Contains("=> " + expected, output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesInvocationTargetAtomicBarrierRemains() + { + const string source = "class Clock { public int M(System.Func f) => ((f))(1);" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("((f))(1)", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public void StructuralConditionalParenthesesOriginalNoncanonicalFlagsAreIncluded() + { + const string source = "class Clock { public bool M(int x, int y) => ((x == y));" + ConditionalParenthesesBranch + "}"; + var parse = new CSharpParseOptions(preprocessorSymbols: new[] { "EXPERIMENTAL", "NET10_0_OR_GREATER" }); + var tree = CSharpSyntaxTree.ParseText(source, parse); + var compilation = CSharpCompilation.Create("NoncanonicalParentheses", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), System.Array.Empty()); + Assert.That(rewriter, Is.Not.Null); + var output = rewriter!.Visit(tree.GetRoot())!.ToFullString(); + StringAssert.Contains("=> x == y", output); + AssertParenthesesOperations(source, output, parseOptions: parse); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesFinalPayloadIncludesRecordedNamespaceChildren() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; class Clock {"); + writer.WriteLine($"public bool M() => ((typeof({typeof(System.InvalidOperationException)}) == typeof({typeof(System.InvalidOperationException)})));"); + writer.WriteRawLine(ConditionalParenthesesBranch); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("=> typeof(InvalidOperationException) == typeof(InvalidOperationException)", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public void StructuralConditionalParenthesesOracleDetectsChangedOnlyFrameworkBranch() + { + const string source = "class Clock { public bool M(int x, int y) => ((x == y));" + ConditionalParenthesesBranch + "}"; + Assert.Throws(() => AssertConditionalParenthesesOperations(source, source.Replace("return 8;", "return 9;"))); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs new file mode 100644 index 00000000000..4fe60f927e3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + private const string ConditionalParenthesesBranch = "\nprivate int Variant() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 6;\n#endif\n}\n"; + + [TestCase("return ((x == y));", "return x == y;")] + [TestCase("if (((x == y))) return true; return false;", "if (x == y) return true;")] + [TestCase("while ((x < y)) { x++; } return false;", "while (x < y)")] + [TestCase("bool flag = ((x != y)); return flag;", "flag = x != y;")] + [TestCase("return ((x == y)) && ((x != 0));", "return x == y && x != 0;")] + [TestCase("return ((x == y)) ? true : false;", "return x == y ? true : false;")] + public async Task StructuralConditionalParenthesesCommonOutsideCalls(string body, string expected) + { + var source = "class Clock { public bool M(int x, int y) { " + body + " }" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(expected, output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesPreservesBoundaryCommentsAndLines() + { + const string source = "class Clock { public bool M(int x, int y) { return(/*a*/(x == y)/*b*/); }" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.DoesNotContain("return(", output); + Assert.That(output.Contains("/*a*/") && output.Contains("/*b*/"), Is.True); + Assert.That(output.Count(c => c == '\n'), Is.EqualTo(source.Count(c => c == '\n'))); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase("return Capture(((x == y)));", "Capture(((x == y)))")] + [TestCase("return Observe(() => ((x == y)));", "() => ((x == y))")] + [TestCase("return new[] { ((x == y)) }[0];", "((x == y))")] + public async Task StructuralConditionalParenthesesProtectedOwnersRetain(string body, string retained) + { + var source = "class Clock { static bool Capture(bool v) => v; static bool Observe(System.Func f) => f(); public bool M(int x, int y) { " + body + " }" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(retained, output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesBranchLocalInactiveTextRetains() + { + const string source = "class Clock { public bool M(int x, int y) {\n#if NET8_0_OR_GREATER\nreturn ((x == y));\n#else\nreturn x != y;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return ((x == y));", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase("DEBUG")] + [TestCase("HAS_FEATURE")] + public async Task StructuralConditionalParenthesesUnknownFlagsRetain(string flag) + { + var source = "class Clock { public bool M(int x, int y) => ((x == y)); private int V() {\n#if " + flag + "\nreturn 1;\n#else\nreturn 0;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> ((x == y))", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesKeepsSemanticAndOtherFamiliesParked() + { + const string source = "class Clock { private int Value; public bool M(int x, int y) => ((x == y)); public int N() => this.Value + global::System.Int32.MaxValue;" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> x == y", output); + StringAssert.Contains("this.Value", output); + StringAssert.Contains("global::System.Int32.MaxValue", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalParenthesesFormerEmptyMetadataPolicyNowAdmitsCommonArrow() + { + const string source = "class Clock { public bool M(int x, int y) => ((x == y)); int N() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 0;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> x == y", output); + AssertConditionalParenthesesOperations(source, output); + } + + private static void AssertConditionalParenthesesOperations(string before, string after) + { + foreach (var symbols in new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER" }, + new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER" } }) + AssertParenthesesOperations(before, after, parseOptions: new CSharpParseOptions(preprocessorSymbols: symbols.Prepend("EXPERIMENTAL"))); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitSafetyTests.cs new file mode 100644 index 00000000000..1ae2b14106f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitSafetyTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + private static CodeFile ConditionalStaticFactorySites(string source) + { + var target = new CSharpType("Factory", "", false, false, null, Array.Empty(), true, false); + var matches = System.Text.RegularExpressions.Regex.Matches(source, "global::Factory"); + return new CodeFile(source, "Clock.cs", matches.Select(m => new TypeReferenceSite(new TextSpan(m.Index, m.Length), target)).ToArray()); + } + + [Test] + public async Task StructuralConditionalStaticReceiverAsyncCollectionCaptureRemainsUnchanged() + { + const string types = "using System;using System.Threading.Tasks;using System.Runtime.CompilerServices;class Collection{}class Enumerator{public int Current=>1;public ValueTask MoveNextAsync()=>new ValueTask(false);}static class Recorder{public static string Text;}static class Extensions{public static Enumerator GetAsyncEnumerator(this Collection self,[CallerArgumentExpression(\"self\")]string text=null){Recorder.Text=text;return new Enumerator();}}static class Factory{public static Collection Create()=>new Collection();}"; + var source = types + "class Clock{public async Task M(){await foreach(var value in global::Factory.Create()){}return Recorder.Text;}" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("in global::Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverLambdaInsideObservedCollectionCannotEscapeTheVeto() + { + var source = ConditionalStaticImplicitTypes + "class Clock{static Collection Select(Func f)=>f();public string M(){foreach(var value in Select(()=>global::Factory.Create())){}return Recorder.Text;}" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("()=>global::Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverInnerCollectionInsideOuterBodyStillObserved() + { + var source = ConditionalStaticImplicitTypes + "class Clock{public string M(){foreach(var outer in new[]{1}){foreach(var inner in global::Factory.Create()){} }return Recorder.Text;}" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("in global::Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverSameFactoryInCollectionAndBodyHasOccurrencePermission(bool safeFirst) + { + const string safe = "var independent=global::Factory.Create();"; + var source = ConditionalStaticImplicitTypes + "class Clock{public string M(){" + (safeFirst ? safe : "") + "foreach(var value in global::Factory.Create()){var body=global::Factory.Create();}" + (!safeFirst ? safe : "") + "return Recorder.Text;}" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("independent=Factory.Create()", output); + StringAssert.Contains("in global::Factory.Create()", output); + StringAssert.Contains("body=Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverExplicitOuterCaptureObservesLoopBodyToo() + { + var source = ConditionalStaticImplicitTypes + "class Clock{static string Capture(Func f,[CallerArgumentExpression(\"f\")]string text=null)=>text;public string M()=>Capture(()=>{foreach(var value in global::Factory.Create()){var body=global::Factory.Create();}return Recorder.Text;});" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("body=global::Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverUsingResourceKeepsUsefulCoverage(bool statement) + { + CodeModelGenerator.Instance.AddMetadataReference(MetadataReference.CreateFromFile(typeof(System.Text.Json.JsonDocument).Assembly.Location)); + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Text.Json;class Clock{public int M(){"); + if (statement) + { + writer.WriteLine($"using(var document={typeof(System.Text.Json.JsonDocument)}.Parse(\"{{}}\")){{return document.RootElement.GetRawText().Length;}}"); + } + else + { + writer.WriteLine($"using var document={typeof(System.Text.Json.JsonDocument)}.Parse(\"{{}}\");"); + writer.WriteRawLine("return document.RootElement.GetRawText().Length;"); + } + writer.WriteRawLine("}" + ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains("document=JsonDocument.Parse", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverOptionalDisposalCallerInfoIsNotResourceTextCapture() + { + const string types = "using System;using System.Runtime.CompilerServices;static class Recorder{public static int Line;public static string Text;}ref struct Resource{public void Dispose([CallerLineNumber]int line=0,[CallerArgumentExpression(\"line\")]string text=null){Recorder.Line=line;Recorder.Text=text;}}static class Factory{public static Resource Create()=>default;}"; + var source = types + "class Clock{public string M(){using(var resource=global::Factory.Create()){}return Recorder.Text;}" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(ConditionalStaticFactorySites(source)); + StringAssert.Contains("resource=Factory.Create()", output); + AssertConditionalParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs new file mode 100644 index 00000000000..7b5e8826b20 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + private const string ConditionalStaticImplicitTypes = "using System;using System.Runtime.CompilerServices;public class Collection{}public class Enumerator{bool moved;public int Current=>1;public bool MoveNext(){if(moved)return false;moved=true;return true;}}public static class Recorder{public static string Text;}public static class Extensions{public static Enumerator GetEnumerator(this Collection self,[CallerArgumentExpression(\"self\")]string text=null){Recorder.Text=text;return new Enumerator();}}public static class Factory{public static Collection Create()=>new Collection();}"; + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverImplicitCollectionCaptureIsNotAnEmptyCallStack(bool bodyOccurrence) + { + var source = ConditionalStaticImplicitTypes + "public class Clock{public string M(){int result=0;foreach(var value in global::Factory.Create()){result=" + (bodyOccurrence ? "global::System.Math.Abs(value-3)" : "value") + ";}return Recorder.Text+\":\"+result;}" + ConditionalParenthesesBranch + "}"; + var factory = new CSharpType("Factory", "", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(new TextSpan(source.IndexOf("global::Factory", StringComparison.Ordinal), "global::Factory".Length), factory); + var sites = bodyOccurrence ? new[] { site, new TypeReferenceSite(new TextSpan(source.IndexOf("global::System.Math", StringComparison.Ordinal), "global::System.Math".Length), typeof(Math)) } : new[] { site }; + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("foreach(var value in global::Factory.Create())", output, + "Implicit GetEnumerator CAE observes the collection spelling"); + if (bodyOccurrence) StringAssert.Contains("result=Math.Abs(value-3)", output, + "The loop body is not part of the observed collection expression"); + AssertConditionalParenthesesOperations(source, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverSafetyTests.cs new file mode 100644 index 00000000000..359428b72d5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverSafetyTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverAllAncestorCapturesAndOverrides(bool explicitText) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{static object Inner(object value)=>value;static object Capture(object value,[System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")]string text=null)=>text;"); + writer.WriteLine($"public object M()=>Capture(Inner({typeof(DateTimeOffset)}.UtcNow){(explicitText ? ",\"manual\"" : "")});"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains(explicitText ? "Inner(DateTimeOffset.UtcNow)" : "Inner(global::System.DateTimeOffset.UtcNow)", output); + AssertStaticMemberBindings(code.Content, output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverConstructorCapturesAndOverrides(bool explicitText) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Holder{public Holder(object value,[System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")]string text=null){}}class Clock{"); + writer.WriteLine($"public object M()=>new Holder({typeof(DateTimeOffset)}.UtcNow{(explicitText ? ",\"manual\"" : "")});"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains(explicitText ? "new Holder(DateTimeOffset.UtcNow" : "new Holder(global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverReducedExtensionReceiverCapture(bool explicitText) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;static class Extensions{public static string Capture(this DateTimeOffset value,[System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")]string text=null)=>text;}class Clock{"); + writer.WriteLine($"public string M()=>{typeof(DateTimeOffset)}.UtcNow.Capture({(explicitText ? "\"manual\"" : "")});"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains(explicitText ? "=>DateTimeOffset.UtcNow.Capture" : "=>global::System.DateTimeOffset.UtcNow.Capture", output.Replace("=> ", "=>")); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverUnitCacheCannotAuthorizeAnotherUnit(bool varyingFirst) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + void Varying() + { + writer.WriteRawLine("public object Varying(){\n#if NET8_0_OR_GREATER\nvar DateTimeOffset=default(System.DateTimeOffset);\n#endif"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine("}"); + } + if (varyingFirst) Varying(); + writer.WriteLine($"public object Stable()=>{typeof(DateTimeOffset)}.UtcNow;"); + if (!varyingFirst) Varying(); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("Stable()=>DateTimeOffset.UtcNow", output.Replace("=> ", "=>")); + StringAssert.Contains("return global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverVisibilityIsPerOccurrenceAndOrderIndependent(bool hiddenFirst) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{public object M(){\n" + (hiddenFirst ? "#line hidden" : "#line default")); + writer.WriteLine($"object first={typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine(hiddenFirst ? "#line default" : "#line hidden"); + writer.WriteLine($"object second={typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine("#line default\nreturn second;}" + ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains(hiddenFirst ? "first=global::System.DateTimeOffset.UtcNow" : "first=DateTimeOffset.UtcNow", output); + StringAssert.Contains(hiddenFirst ? "second=DateTimeOffset.UtcNow" : "second=global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverOuterLexicalUnitMustBeInvariantNotJustLocalFunction() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Plain{public object Pass(object value)=>value;}class Captured{public object Pass(object value,[System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")]string text=null)=>text;}class Clock{public object M(){\n#if NET8_0_OR_GREATER\nvar api=new Captured();\n#else\nvar api=new Plain();\n#endif"); + writer.WriteLine($"object Local()=>api.Pass({typeof(DateTimeOffset)}.UtcNow);"); + writer.WriteRawLine("return Local();}}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverGlobalConstHeaderChangesRemainOpaque() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;partial class Clock{"); + writer.WriteLine($"public object M()=>{typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + const string extra = "partial class Clock{\n#if NET8_0_OR_GREATER\nconst int K=300;\n#else\nconst int K=1;\n#endif\n}"; + var output = await Reduce(code, extra); + StringAssert.Contains("global::System.DateTimeOffset.UtcNow", output); + AssertBindings(code.Content, output, extra); + } + + [Test] + public async Task StructuralConditionalStaticReceiverGenericLocalVetoStillAppliesToWholeDocument() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + writer.WriteLine($"public object M()=>{typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine("int Other(){T Local(T value)=>value;return Local(1);}" + ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverAliasFallbackUsesExpressionScope() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;using S=System;class Shadow{public string ToString(int value)=>\"shadow\";}class Clock{public string M(){var Convert=new Shadow();var System=Convert;"); + writer.WriteLine($"return {typeof(Convert)}.ToString(42);"); + writer.WriteRawLine("}" + ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains("S::Convert.ToString(42)", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverPreservesConstructedLexicalReceiver() + { + var argument = new CSharpType(typeof(Comparer<>).GetGenericArguments()[0]); + var target = new CSharpType(typeof(Comparer<>), argument); + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Collections.Generic;class Clock{"); + writer.WriteLine($"public object M()=>{target}.Default;"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + var output = await Reduce(code); + StringAssert.Contains("Comparer.Default", output); + StringAssert.DoesNotContain("global::", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase("global::System.DateTimeOffset", true)] + [TestCase("global::System./*keep*/DateTimeOffset", false)] + [TestCase("global::System.\nDateTimeOffset", false)] + public async Task StructuralConditionalStaticReceiverInternalTriviaAndLineBoundariesRemain(string spelling, bool shorten) + { + var source = "using System;class Clock{public object M()=>" + spelling + ".UtcNow;" + ConditionalParenthesesBranch + "}"; + var site = new TypeReferenceSite(new TextSpan(source.IndexOf(spelling, StringComparison.Ordinal), spelling.Length), typeof(DateTimeOffset)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains(shorten ? "=>DateTimeOffset.UtcNow" : spelling + ".UtcNow", output); + AssertConditionalParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralConditionalStaticReceiverRawAndEscapedContextualRoots(bool escaped) + { + var raw = escaped ? "@field" : "field"; + var source = "using N;namespace N{public static class " + raw + "{public static int Value=>3;}}class Clock{public int M()=>global::N." + raw + ".Value;" + ConditionalParenthesesBranch + "}"; + var spelling = "global::N." + raw; + var target = new CSharpType("field", "N", false, false, null, Array.Empty(), true, false); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(new TextSpan(source.IndexOf(spelling, StringComparison.Ordinal), spelling.Length), target) })); + StringAssert.Contains(escaped ? "@field.Value" : "N.field.Value", output); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase(0)] + [TestCase(1)] + public async Task StructuralConditionalStaticReceiverDuplicateAndCorruptProvenanceRetains(int kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + writer.WriteLine($"public object M()=>{typeof(DateTimeOffset)}.UtcNow;"); + writer.WriteRawLine(ConditionalParenthesesBranch + "}"); + }); + var sites = kind == 0 ? code.TypeReferences!.Concat(code.TypeReferences!).ToArray() : code.TypeReferences!.Select(static s => s with { Type=(CSharpType)typeof(TimeSpan) }).ToArray(); + var output = await Reduce(new CodeFile(code.Content, code.Name, sites)); + StringAssert.Contains("global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase("unknown")] + [TestCase("branchLocal")] + public async Task StructuralConditionalStaticReceiverUnprovenConfigurationOrUnitRetains(string kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + if (kind == "branchLocal") writer.WriteRawLine("public object M(){\n#if NET8_0_OR_GREATER"); + else writer.WriteRawLine("public object M(){"); + writer.WriteLine($"return {typeof(DateTimeOffset)}.UtcNow;"); + if (kind == "branchLocal") writer.WriteRawLine("#else\nreturn null;\n#endif"); + writer.WriteRawLine("}"); + writer.WriteRawLine(kind == "unknown" ? ConditionalParenthesesBranch.Replace("NET8_0_OR_GREATER", "UNKNOWN_FLAG") : ""); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset.UtcNow", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [TestCase("class Clock{object M()=>System.DateTimeOffset.UtcNow;}", "class Clock{object M()=>System.DateTime.UtcNow;}")] + [TestCase("class Clock{object M()=>System.Math.Abs(-3);}", "class Clock{object M()=>System.Math.Abs(-4);}")] + public void StructuralConditionalStaticReceiverOracleRejectsChangedReceiverOrArgument(string before, string after) + { + Assert.Throws(() => AssertParenthesesOperations(before, after)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverTests.cs new file mode 100644 index 00000000000..590330bf33b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(0, "Math.Abs(value)")] + [TestCase(1, "DateTimeOffset.UtcNow")] + [TestCase(2, "TimeSpan.Zero")] + public async Task StructuralConditionalStaticReceiverStableUnitUsesOccurrenceProof(int kind, string expected) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + if (kind == 0) writer.WriteLine($"public int M(int value)=>{typeof(Math)}.Abs(value);"); + if (kind == 1) writer.WriteLine($"public DateTimeOffset M()=>{typeof(DateTimeOffset)}.UtcNow;"); + if (kind == 2) writer.WriteLine($"public TimeSpan M()=>{typeof(TimeSpan)}.Zero;"); + writer.WriteRawLine(ConditionalParenthesesBranch); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains(expected, output); + StringAssert.DoesNotContain("global::", output); + AssertStaticMemberBindings(code.Content, output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverVariantLocalShadowCannotUseOriginalConfigurationOnly() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Shadow{public int Abs(int value)=>-value;}class Clock{public int M(int value){\n#if NET8_0_OR_GREATER\nvar Math=new Shadow();\n#endif"); + writer.WriteLine($"return {typeof(Math)}.Abs(value);"); + writer.WriteRawLine("}}"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("return Math.Abs(value)", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverOuterCapturedLambdaKeepsOriginalSpelling() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{static string Capture(Func f,[System.Runtime.CompilerServices.CallerArgumentExpression(\"f\")]string text=null)=>text;"); + writer.WriteLine($"public string M(int value)=>Capture(()=>{typeof(Math)}.Abs(value));"); + writer.WriteRawLine(ConditionalParenthesesBranch); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.Math.Abs(value)", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverHiddenPrefixRemainsUnchanged() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{\n#line hidden"); + writer.WriteLine($"public TimeSpan M()=>{typeof(TimeSpan)}.Zero;"); + writer.WriteRawLine("#line default"); + writer.WriteRawLine(ConditionalParenthesesBranch); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.TimeSpan.Zero", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralConditionalStaticReceiverUnrecordedNameDoesNotAcquirePermission() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;class Clock{"); + writer.WriteLine($"public TimeSpan M()=>{typeof(TimeSpan)}.Zero;"); + writer.WriteRawLine(ConditionalParenthesesBranch); + writer.WriteRawLine("}"); + }); + var output = await Reduce(new CodeFile(code.Content, code.Name)); + StringAssert.Contains("global::System.TimeSpan.Zero", output); + AssertConditionalParenthesesOperations(code.Content, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs new file mode 100644 index 00000000000..40624cf1311 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task StructuralCrefDeepNamespaceAliasRetainsWithoutImportDiagnosticCertificate() + { + CodeModelGenerator.Instance.AddMetadataReference(MetadataReference.CreateFromFile(typeof(System.Net.Http.HttpClient).Assembly.Location)); + const string source = "using A = System.Net.Http;\n/// \npublic class Clock {}"; + var code = RecordedCrefSource(source, typeof(System.Net.Http.HttpClient)); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.Net.Http.HttpClient\"", output); + AssertStructuralCrefs(source, output); + } + + [Test] + public async Task StructuralCrefMissingWorkspaceMetadataRetainsQualification() + { + Assert.That(CodeModelGenerator.Instance.AdditionalMetadataReferences.OfType() + .Any(reference => reference.FilePath == typeof(System.Net.Http.HttpClient).Assembly.Location), Is.False); + const string source = "using A = System.Net.Http;\n/// \npublic class Clock {}"; + var output = await Reduce(RecordedCrefSource(source, typeof(System.Net.Http.HttpClient))); + StringAssert.Contains("cref=\"global::System.Net.Http.HttpClient\"", output); + AssertStructuralCrefs(source, output); + } + + [TestCase("@class", "class")] + [TestCase("\\u0057idget", "Widget")] + public async Task StructuralCrefTerminalEscapesAreOccurrenceOwned(string token, string name) + { + var source = "using Fixtures; namespace Fixtures { public class " + token + " {} }\n/// \npublic class Clock { public " + token + " Value=>null; }"; + var output = await Reduce(RecordedCrefSource(source, CrefFixtureType(name))); + StringAssert.Contains("cref=\"" + token + "\"", output); + AssertStructuralCrefs(source, output); + } + + [Test] + public async Task StructuralCrefNongenericNestedTypeKeepsConstructedOwner() + { + const string source = "using Fixtures; namespace Fixtures { public class Outer { public class Inner {} } }\n/// \npublic class Clock { public Outer Value=>null; }"; + var owner = CrefFixtureType("Outer"); + var target = new CSharpType("Inner", "Fixtures", false, false, owner, Array.Empty(), true, false); + var output = await Reduce(RecordedCrefSource(source, target)); + StringAssert.Contains("cref=\"Outer.Inner\"", output); + AssertStructuralCrefs(source, output); + } + + [Test] + public async Task StructuralCrefLexicalTypeParameterCannotShadowDocumentedType() + { + const string source = "using System; public class Clock {\n/// \npublic void M() {} public TimeSpan Value=>default; }"; + var output = await Reduce(RecordedCrefSource(source, typeof(DateTimeOffset))); + StringAssert.Contains("cref=\"System.DateTimeOffset\"", output); + AssertStructuralCrefs(source, output); + } + + [Test] + public async Task StructuralCrefAmbiguousImportsKeepQualifiedAlternative() + { + const string source = "using Fixtures; using Other; namespace Fixtures { public class Widget {} public class FirstOnly {} } namespace Other { public class Widget {} public class OtherOnly {} }\n/// \npublic class Clock { public FirstOnly First=>null; public OtherOnly Second=>null; }"; + var output = await Reduce(RecordedCrefSource(source, CrefFixtureType("Widget"))); + StringAssert.Contains("cref=\"Fixtures.Widget\"", output); + AssertStructuralCrefs(source, output); + } + + [Test] + public async Task StructuralCrefDuplicateProvenanceIsNotAnAuthorization() + { + const string source = "using System;\n/// \npublic class Clock {}"; + var original = RecordedCrefSource(source, typeof(DateTimeOffset)); + var code = new CodeFile(source, "Clock.cs", original.TypeReferences!.Concat(original.TypeReferences!).ToArray()); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + AssertStructuralCrefs(source, output); + } + + [TestCase("global::System.Convert.ToString(int)")] + [TestCase("global::System.DateTimeOffset[]")] + public async Task StructuralCrefWholeTypeRoleDoesNotAuthorizeUnsupportedShape(string spelling) + { + var source = "using System;\n/// \npublic class Clock {}"; + var type = spelling.Contains("Convert") ? new CSharpType(typeof(Convert)) : new CSharpType(typeof(DateTimeOffset[])); + var output = await Reduce(RecordedCrefSource(source, type)); + StringAssert.Contains("cref=\"" + spelling + "\"", output); + if (spelling.EndsWith("[]", StringComparison.Ordinal)) + { + // This is malformed cref grammar, not a valid array-symbol cref. + // Preserve it exactly; a valid-target oracle cannot certify it. + Assert.That(output, Is.EqualTo(source)); + var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); + Assert.That(CSharpSyntaxTree.ParseText(source, options).GetDiagnostics().Any(static d => d.Id == "CS1584"), Is.True); + } + else AssertStructuralCrefs(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralCrefVisibilityIsNotCachedAcrossOccurrences(bool hiddenFirst) + { + const string hidden = "\n#line hidden\n/// \npublic class Hidden {}\n#line default\n"; + const string visible = "/// \npublic class Visible {}\n"; + var source = "using System;\n" + (hiddenFirst ? hidden + visible : visible + hidden) + "class Uses { public DateTimeOffset Value=>default; }\n"; + var output = await Reduce(RecordedCrefSource(source, typeof(DateTimeOffset))); + var crefs = CSharpSyntaxTree.ParseText(output).GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Select(a => a.Cref.ToString()).ToArray(); + Assert.That(crefs[hiddenFirst ? 0 : 1], Is.EqualTo("global::System.DateTimeOffset")); + if (hiddenFirst) Assert.That(crefs[1], Is.EqualTo("DateTimeOffset")); + AssertStructuralCrefs(source, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralCrefMultilinePrefixPreservesExteriorAndLaterLine(string newline) + { + var source = "using System; using System.Runtime.CompilerServices;" + newline + + "/// " + newline + + "public class Clock { static int Line([CallerLineNumber] int line = 0) => line; public int M() => Line(); }"; + var output = await Reduce(RecordedCrefSource(source, typeof(DateTimeOffset))); + StringAssert.Contains("global::System." + newline + "/// DateTimeOffset", output); + AssertStructuralCrefs(source, output); + AssertParenthesesOperations(source, output, parseOptions: new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralCrefCapturedLocalFunctionDocumentationUsesEnclosingObserver(bool explicitOverride) + { + var source = "using System; using System.Runtime.CompilerServices; class Clock { static string Capture(Func f, [CallerArgumentExpression(\"f\")] string text = null) => text; " + + "public string M() => Capture(() => {\n/// \nint Local() => 1; return Local(); }" + (explicitOverride ? ", \"manual\"" : "") + "); }"; + var output = await Reduce(RecordedCrefSource(source, typeof(DateTimeOffset))); + Assert.That(output.Contains("cref=\"global::System.DateTimeOffset\""), Is.EqualTo(!explicitOverride)); + AssertStructuralCrefs(source, output); + AssertParenthesesOperations(source, output, parseOptions: new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)); + } + + [TestCase("disabled")] + [TestCase("custom")] + [TestCase("header")] + public async Task StructuralCrefWorkspaceAndCompilationGatesRemain(string mode) + { + if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + const string source = "using System;\n/// \npublic class Clock {}"; + var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; + // Custom rewriters deliberately use the existing formatter route. + // Start formatted so its unrelated brace spacing is not a cref edit. + var formatted = source.Replace("class Clock {}", "class Clock { }"); + var output = await Reduce(RecordedCrefSource(formatted, typeof(DateTimeOffset)), extra); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + AssertStructuralCrefs(formatted, output); + } + + [Test] + public void StructuralCrefOracleRejectsChangedTargetAndOutsideXml() + { + const string source = "using System;\n/// \npublic class Clock {}"; + AssertStructuralCrefs(source, source.Replace("global::System.DateTimeOffset", "DateTimeOffset")); + Assert.Throws(() => AssertStructuralCrefs(source, source.Replace("global::System.DateTimeOffset", "TimeSpan"))); + Assert.Throws(() => AssertStructuralCrefs(source, source.Replace("summary", "remarks"))); + } + + [Test] + public void StructuralCrefOracleRejectsBareTypeThatReparsesAsMethod() + { + const string source = "using Fixtures; namespace Fixtures { public class Widget {} } public static class Factory {\n/// \npublic static Widget Widget() => null; }"; + Assert.Throws(() => AssertStructuralCrefs(source, source.Replace("global::Fixtures.Widget", "Widget"))); + } + + [Test] + public void StructuralCrefNamespaceAliasRenderingMatchesActualXmlGrammar() + { + const string source = "using A = System.Net.Http;\n/// \npublic class Clock {}"; + var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); + var tree = CSharpSyntaxTree.ParseText(source, options); + var compilation = CSharpCompilation.Create("CrefAliasProbe", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var cref = tree.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Single().Cref; + var target = (INamedTypeSymbol)model.GetSymbolInfo(cref).Symbol!; + Assert.That(model.LookupNamespacesAndTypes(cref.SpanStart, name: "A").Single(), Is.InstanceOf()); + var description = StructuralTypeNameSelector.Describe(cref); + var context = new StructuralTypeNameSelector.Context(compilation, model, cref, target, Source: description, + KnownDeclarationType: true, PreserveTerminalArguments: true, ModeOverride: StructuralTypeNameSelector.Mode.Cref); + var selected = StructuralTypeNameSelector.Choose(new[] { context }); + Assert.That(selected.Plan!.Render(description)!.ToString(), Is.EqualTo("A::HttpClient"), "Ordinary code rendering must not change"); + Assert.That(selected.Plan.RenderCrefName(description)!.ToString(), Is.EqualTo("A.HttpClient")); + foreach (var text in new[] { "A::HttpClient", "A.HttpClient", "System.Net.Http.HttpClient" }) + { + var parsed = CSharpSyntaxTree.ParseText(source.Replace("global::System.Net.Http.HttpClient", text), options); + var attributes = parsed.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().ToArray(); + if (attributes.Length == 0) + { + Assert.That(text, Is.EqualTo("A::HttpClient")); + Assert.That(parsed.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Count(), Is.EqualTo(1)); + continue; + } + var node = attributes.Single().Cref; + var info = model.GetSpeculativeSymbolInfo(cref.SpanStart, node, SpeculativeBindingOption.BindAsTypeOrNamespace); + var actual = compilation.ReplaceSyntaxTree(tree, parsed).GetSemanticModel(parsed).GetSymbolInfo(node); + Assert.That(node, Is.InstanceOf()); + Assert.That(parsed.GetDiagnostics(), Is.Empty); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(info.Symbol, target), Is.True); + Assert.That(actual.Symbol?.GetDocumentationCommentId(), Is.EqualTo(target.GetDocumentationCommentId())); + Assert.That(actual.Symbol?.ContainingAssembly?.Identity, Is.EqualTo(target.ContainingAssembly.Identity)); + } + } + + [Test] + public async Task StructuralCrefDocumentationIdTextIsNotTreatedAsCrefSyntax() + { + const string source = "using System;\n/// \npublic class Clock { public DateTimeOffset Value=>default; }"; + var original = RecordedCrefSource(source, typeof(DateTimeOffset)); + var opaque = new[] { "T:System.DateTimeOffset", "A::DateTimeOffset" }.Select(text => + new TypeReferenceSite(new Microsoft.CodeAnalysis.Text.TextSpan(source.IndexOf(text, StringComparison.Ordinal), text.Length), typeof(DateTimeOffset), TypeReferenceRole.WholeTypeCref)); + var output = await Reduce(new CodeFile(source, "Clock.cs", original.TypeReferences!.Concat(opaque).ToArray())); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + StringAssert.Contains("cref=\"T:System.DateTimeOffset\"", output); + StringAssert.Contains("cref=\"A::DateTimeOffset\"", output); + AssertStructuralCrefs(source, output); + } + + private static CSharpType CrefFixtureType(string name) => new(name, "Fixtures", false, false, null, Array.Empty(), true, false); + + private static CodeFile RecordedCrefSource(string source, CSharpType type) + { + var root = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)).GetRoot(); + var sites = root.DescendantNodes(descendIntoTrivia: true).OfType() + .Select(a => new TypeReferenceSite(a.Cref.Span, type, TypeReferenceRole.WholeTypeCref)).ToArray(); + return new CodeFile(source, "Clock.cs", sites); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefTests.cs new file mode 100644 index 00000000000..0e359edae7a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("using System;", "DateTimeOffset")] + [TestCase("using A = System;", "global::System.DateTimeOffset")] + [TestCase("using Moment = System.DateTimeOffset;", "global::System.DateTimeOffset")] + public async Task StructuralCrefRecordedNamedTypeKeepsWholeTarget(string imports, string expected) + { + var code = Recorded(writer => + { + // CodeWriter already adds System. Keep the ordinary-import + // positive diagnostic-clean and genuinely used; aliases retain + // without the separate import-usage certificate they require. + if (imports != "using System;") writer.WriteRawLine(imports); + using (new CodeWriter.XmlDocWritingScope(writer)) writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteRawLine(imports == "using System;" ? "public class Clock { public DateTimeOffset Value=>default; }" : "public class Clock {}"); + }); + if (imports != "using System;") + { + // CodeWriter adds System for the recorded type. Remove that one + // generated using AND remap its recorded spans so these cases + // actually exercise namespace aliases / closed-alias retention. + const string autoUsing = "using System;\n"; + var start = code.Content.IndexOf(autoUsing, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0)); + Assert.That(code.Content.IndexOf(autoUsing, start + autoUsing.Length, StringComparison.Ordinal), Is.EqualTo(-1)); + var sites = code.TypeReferences!.Select(site => site with + { + Span = site.Span.Start >= start + autoUsing.Length ? new TextSpan(site.Span.Start - autoUsing.Length, site.Span.Length) : site.Span + }).ToArray(); + code = new CodeFile(code.Content.Remove(start, autoUsing.Length), code.Name, sites); + } + var output = await Reduce(code); + StringAssert.Contains("cref=\"" + expected + "\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [Test] + public async Task StructuralCrefSameNamedMethodRequiresQualifiedAlternative() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Fixtures { public class Widget {} } public static class Factory {"); + using (new CodeWriter.XmlDocWritingScope(writer)) writer.WriteLine($"/// {new CSharpType("Widget", "Fixtures", false, false, null, Array.Empty(), true, false):C}"); + writer.WriteRawLine("public static Widget Widget() => null; }"); + }); + var output = await Reduce(code); + StringAssert.Contains("cref=\"Fixtures.Widget\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [Test] + public async Task StructuralCrefRecordedOccurrenceDoesNotAuthorizeUnrecordedSibling() + { + var code = Recorded(writer => + { + using (new CodeWriter.XmlDocWritingScope(writer)) + { + writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteRawLine("/// "); + } + // A real code use preserves the imported namespace independently + // of both the recorded and deliberately unrecorded crefs. + writer.WriteRawLine("public class Clock { public DateTimeOffset Value=>default; }"); + }); + var output = await Reduce(code); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [TestCase("missing")] + [TestCase("wrongSpan")] + [TestCase("wrongType")] + [TestCase("wrongRole")] + public async Task StructuralCrefInvalidOrAbsentProvenanceRetains(string mode) + { + var original = Recorded(writer => + { + writer.WriteRawLine("using System;"); + using (new CodeWriter.XmlDocWritingScope(writer)) writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteRawLine("public class Clock {}"); + }); + var sites = original.TypeReferences!.Select(site => mode switch + { + "wrongSpan" => site with { Span = new TextSpan(site.Span.Start + 1, site.Span.Length - 1) }, + "wrongType" => site with { Type = typeof(TimeSpan) }, + "wrongRole" => site with { Role = TypeReferenceRole.Code }, + _ => site + }).ToArray(); + var code = new CodeFile(original.Content, original.Name, mode == "missing" ? Array.Empty() : sites); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [Test] + public async Task StructuralCrefProseCodeAndMemberSignatureRemainUntouched() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System;"); + using (new CodeWriter.XmlDocWritingScope(writer)) + { + writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteLine($"/// {typeof(DateTimeOffset)} prose {typeof(DateTimeOffset)}."); + writer.WriteLine($"/// "); + } + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset", output); + StringAssert.Contains("prose global::System.DateTimeOffset", output); + StringAssert.Contains("cref=\"global::System.Convert.ToString(int)\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [Test] + public async Task StructuralCrefGenericDeclarationIsNotTreatedAsLexicalConstruction() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Collections.Generic;"); + using (new CodeWriter.XmlDocWritingScope(writer)) writer.WriteLine($"/// {typeof(List):C}"); + writer.WriteRawLine("public class Clock {}"); + }); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.Collections.Generic.List{T}\"", output); + AssertStructuralCrefs(code.Content, output); + } + + [Test] + public async Task StructuralCrefConditionalDocumentRemainsConservative() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; class Clock {"); + using (new CodeWriter.XmlDocWritingScope(writer)) writer.WriteLine($"/// {typeof(DateTimeOffset):C}"); + writer.WriteRawLine("public void M() {} private int V() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 6;\n#endif\n} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("cref=\"global::System.DateTimeOffset\"", output); + AssertStructuralCrefs(code.Content, output); + } + + private static void AssertStructuralCrefs(string before, string after) + { + string TypeIdentity(ITypeSymbol? type) => type switch + { + null => "", + // Cref-declared generic parameters are not lexical parameters: + // their public symbol can have no ContainingSymbol at all. + ITypeParameterSymbol parameter => "parameter:" + parameter.TypeParameterKind + ":" + parameter.Ordinal + ":" + parameter.Name + ":" + + parameter.ContainingSymbol?.GetDocumentationCommentId() + ":" + parameter.NullableAnnotation, + INamedTypeSymbol named => named.ContainingAssembly?.Identity + ":" + named.OriginalDefinition.GetDocumentationCommentId() + ":" + named.NullableAnnotation + + ":owner=" + TypeIdentity(named.ContainingType) + "<" + string.Join(",", named.TypeArguments.Select(TypeIdentity)) + ">", + IArrayTypeSymbol array => "array:" + array.Rank + ":" + TypeIdentity(array.ElementType) + ":" + array.NullableAnnotation, + _ => StructuralTypeNameRuleOracleTests.TypeIdentity(type) + }; + (string[] Targets, string Outside) Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose)); + var compilation = CSharpCompilation.Create("StructuralCrefOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error || d.Id is "CS1574" or "CS1584" or "CS0419"), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var crefs = tree.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Select(a => a.Cref).ToArray(); + var targets = crefs.Select(cref => + { + var info = model.GetSymbolInfo(cref); + Assert.That(info.CandidateReason, Is.EqualTo(CandidateReason.None)); + Assert.That(info.CandidateSymbols, Is.Empty); + Assert.That(info.Symbol, Is.Not.Null, cref.ToString()); + return info.Symbol is ITypeSymbol type ? TypeIdentity(type) : + info.Symbol!.Kind + ":" + info.Symbol.GetDocumentationCommentId() + ":" + TypeIdentity(info.Symbol.ContainingType); + }).ToArray(); + return (targets, tree.GetText().WithChanges(crefs.Select(cref => new TextChange(cref.Span, "__CREF__"))).ToString()); + } + var original = Snapshot(before); var candidate = Snapshot(after); + Assert.That(candidate.Targets, Is.EqualTo(original.Targets), "Every complete cref must retain its exact documented target"); + Assert.That(candidate.Outside, Is.EqualTo(original.Outside), "All source/XML/trivia outside cref spans must remain exact"); + Assert.That(System.Text.RegularExpressions.Regex.Matches(after, "\\r\\n|\\r|\\n").Select(m => m.Value), + Is.EqualTo(System.Text.RegularExpressions.Regex.Matches(before, "\\r\\n|\\r|\\n").Select(m => m.Value))); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs new file mode 100644 index 00000000000..02d39f92e35 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + // Portable policy controls supplement the authenticated full SDK workspace + // reproduction; these small fixtures are not claims of SDK coverage. + [NonParallelizable] + public class StructuralImmediateNamespaceCrefTests + { + private string? _previous; + private string _directory = null!; + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "structural"); + _directory = Path.Combine(Path.GetTempPath(), "typespec-c9-cref", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true}"); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + Directory.Delete(_directory, recursive: true); + } + [TestCase("type", false)] + [TestCase("constructor", false)] + [TestCase("method", false)] + [TestCase("type", true)] + public void DirectNamespaceCrefDoesNotConsumeUnusedImport(string attachment, bool fileScoped) + { + var code = Build(attachment, fileScoped); + var result = Reduce(code); + Assert.That(result.Before.GetDiagnostics().Any(d => d.Id == "CS8019"), Is.True, "Original unused-import regression anchor"); + StringAssert.Contains("cref=\"T\"", result.Text); + Preserve(code.Content, result); + } + + [Test] + public void SameNamespaceForeignImportDoesNotBecomeUsed() + { + var code = Build("type", false, "using Foreign;", "namespace Foreign { public class T {} }"); + var result = Reduce(code); + StringAssert.Contains("cref=\"T\"", result.Text); + Preserve(code.Content, result); + } + + [TestCase("ancestor")] + [TestCase("shadow")] + [TestCase("conditional")] + [TestCase("pragma")] + [TestCase("enhanced-line")] + [TestCase("hidden")] + [TestCase("unrecorded")] + public void UnsupportedCertificateDoesNotRelaxOriginalRetention(string kind) + { + var code = kind == "ancestor" ? Build("ancestor", false) : Build("method", false, + kind == "pragma" ? "#pragma warning restore\n" : + kind == "enhanced-line" ? "#line (100,1)-(200,10) 40 \"map.cs\"\n" : "", + kind == "conditional" ? "#if EXPERIMENTAL\nclass Alternate {}\n#endif" : "", + shadow: kind == "shadow", hidden: kind == "hidden"); + if (kind == "unrecorded") code = new CodeFile(code.Content, code.Name, Array.Empty()); + var result = Reduce(code); + StringAssert.Contains("cref=\"global::N.T\"", result.Text); + Preserve(code.Content, result); + } + + [TestCase("global using Foreign;")] + [TestCase("using Alias = Foreign;")] + [TestCase("using static Foreign.Utility;")] + public void UnsupportedImportElsewhereRemainsBarrier(string import) + { + var code = Build("type", false, "", "namespace Foreign { public static class Utility {} }"); + var result = Reduce(code, import + "\nnamespace Other { class Additional {} }"); + StringAssert.Contains("cref=\"global::N.T\"", result.Text); + Preserve(code.Content, result); + } + + private static CodeFile Build(string attachment, bool fileScoped, string prefix = "", string other = "", bool shadow = false, bool hidden = false) + { + using var writer = new CodeWriter(); + writer.WriteRawLine(prefix); + writer.WriteRawLine("using N;"); + writer.WriteRawLine(fileScoped ? "namespace N;" : "namespace N {"); + writer.WriteRawLine("public partial class T {}"); + if (attachment == "ancestor") writer.WriteRawLine("namespace Inner {"); + if (attachment == "constructor") writer.WriteRawLine("public partial class T {"); + if (attachment == "method") writer.WriteRawLine("public class C {" + (shadow ? "public void T() {}" : "")); + if (hidden) writer.WriteRawLine("#line hidden"); + using (new CodeWriter.XmlDocWritingScope(writer)) + writer.WriteLine($"/// {new CSharpType("T", "N", false, false, null, Array.Empty(), true, false):C}"); + writer.WriteRawLine(attachment == "constructor" ? "public T() {}" : attachment == "method" ? "public void M() {}" : "public class C {}"); + if (hidden) writer.WriteRawLine("#line default"); + if (attachment is "constructor" or "method" or "ancestor") writer.WriteRawLine("}"); + if (!fileScoped) writer.WriteRawLine("}"); + writer.WriteRawLine(other); + return new CodeFile(writer.ToString(), "ImmediateNamespace.cs", writer.TypeReferences); + } + + private sealed record Result(string Text, CSharpCompilation Before, CSharpCompilation After, SyntaxTree Original, SyntaxTree Candidate); + private static Result Reduce(CodeFile code, string? other = null) + { + using var workspace = new AdhocWorkspace(); + var project = workspace.AddProject("ImmediateNamespaceCref", LanguageNames.CSharp) + .WithParseOptions(new CSharpParseOptions(preprocessorSymbols: new[] { "EXPERIMENTAL" }, documentationMode: DocumentationMode.Parse)) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + metadataReferenceResolver: new WorkspaceMetadataReferenceResolver(), nullableContextOptions: NullableContextOptions.Disable)) + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + var document = project.AddDocument(code.Name, SourceText.From(code.Content), new[] { "Generated" }); + project = document.Project; + if (other != null) project = project.AddDocument("Other.cs", SourceText.From(other)).Project; + document = project.GetDocument(document.Id)!; + var compilation = (CSharpCompilation)project.GetCompilationAsync().GetAwaiter().GetResult()!; + var tree = document.GetSyntaxTreeAsync().GetAwaiter().GetResult()!; + var root = tree.GetRoot(); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(root, code.TypeReferences!); + var text = rewriter?.Visit(root)?.ToFullString() ?? code.Content; + var candidate = project.GetDocument(document.Id)!.WithText(SourceText.From(text)).Project; + var after = (CSharpCompilation)candidate.GetCompilationAsync().GetAwaiter().GetResult()!; + return new Result(text, compilation, after, tree, candidate.GetDocument(document.Id)!.GetSyntaxTreeAsync().GetAwaiter().GetResult()!); + } + + private static void Preserve(string original, Result result) + { + var prefix = "global::N."; + var oldCref = result.Original.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Single().Cref; + var changed = original != result.Text; + var start = oldCref.SpanStart; + if (changed) Assert.That(result.Text, Is.EqualTo(original.Remove(start, prefix.Length)), "Only the witnessed namespace prefix may be deleted"); + var removed = changed ? prefix.Length : 0; + string LocationKey(Location location, bool project) + { + if (!location.IsInSource) return location.Kind.ToString(); + var span = location.SourceSpan;var tree = location.SourceTree!; + if (project && ReferenceEquals(tree, result.Original)) + { + Assert.That(span.Start < start + removed && span.End > start && removed != 0, Is.False, "No diagnostic intersection may be waived"); + int Map(int p) => p <= start ? p : p - removed; + span = TextSpan.FromBounds(Map(span.Start), Map(span.End));tree = result.Candidate; + } + return tree.FilePath + "|" + span.Start + ":" + span.End + "|" + tree.GetLineSpan(span) + "|" + tree.GetMappedLineSpan(span); + } + string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.Select(d => d.Id + "|" + d.Severity + "|" + d.IsSuppressed + "|" + d.WarningLevel + "|" + d.GetMessage() + "|" + LocationKey(d.Location, project) + "|" + string.Join(";", d.AdditionalLocations.Select(l => LocationKey(l, project)))).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.That(Bag(result.After.GetDiagnostics(), false), Is.EqualTo(Bag(result.Before.GetDiagnostics(), true)), "Complete compiler diagnostic multiset"); + using var oldPe = new MemoryStream();using var oldXml = new MemoryStream();using var newPe = new MemoryStream();using var newXml = new MemoryStream(); + var before = result.Before.Emit(oldPe, xmlDocumentationStream: oldXml);var after = result.After.Emit(newPe, xmlDocumentationStream: newXml); + Assert.That(after.Success, Is.EqualTo(before.Success)); + Assert.That(Bag(after.Diagnostics, false), Is.EqualTo(Bag(before.Diagnostics, true)), "Complete emit diagnostic multiset"); + if (before.Success) Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray()), "Successful emitted XML must remain identical"); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs new file mode 100644 index 00000000000..fb080477a66 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task NamespacePrefixSelectionIsUniformForNamedTypeShapes() + { + var type = typeof(Dictionary>); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {type} Value => default;"); + writer.WriteLine($"public object Create() => new {type}();"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Dictionary> Value", output); + StringAssert.Contains("new Dictionary>()", output); + StringAssert.DoesNotContain("global::", output); + AssertOperationBindings(code.Content, output); + Assert.AreEqual(1, Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralTypeNames.Visit")); + } + + [Test] + public async Task CachedQualifierChoicePreservesEachOccurrencesArgumentSyntax() + { + const string source = "using System; using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List First => default; public global::System.Collections.Generic.List Second => default; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List)))); + StringAssert.Contains("List First", output); + StringAssert.Contains("List Second", output); + Assert.AreEqual(1, Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralTypeNames.CacheHit")); + } + + [Test] + public async Task NamespaceChoiceDoesNotCopyUnrecordedArgumentReductionsAcrossOccurrences() + { + const string source = "using System; using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List First => default; public global::System.Collections.Generic.List Second => default; } }"; + var sites = PrefixSites(source, typeof(List)).ToList(); + var inner = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType() + .First(static name => name.ToString() == "global::System.DateTimeOffset"); + sites.Add(new TypeReferenceSite(inner.Span, typeof(DateTimeOffset))); + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("List First", output); + StringAssert.Contains("List Second", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List", output); + } + + [Test] + public async Task RetainedOuterQualifierDoesNotDiscardAnIndependentlyReducedChild() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public class List {} public class System {} public class Collections {} public class Generic {}"); + writer.WriteLine($"public {typeof(List)} Value => default;"); + writer.WriteRawLine("} }"); + }); + StringAssert.Contains("global::System.Collections.Generic.List Value", await Reduce(code)); + } + + [Test] + public async Task ArityLookupCanIgnoreAnInapplicableTypeParameter() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(List)} Value => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("List Value", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List", output); + } + + [Test] + public async Task ClosedTypeAliasCannotRemoveTheRetainedTerminalArguments() + { + const string source = "using Integers = System.Collections.Generic.List; namespace Sample { public class Clock { public global::System.Collections.Generic.List First => default; public global::System.Collections.Generic.List Second => default; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List)))); + StringAssert.Contains("System.Collections.Generic.List First", output); + StringAssert.DoesNotContain("Integers First", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List", output); + StringAssert.Contains("System.Collections.Generic.List Second", output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task QualifierChoicePreservesNullableArgumentSyntaxInEitherOrder(bool nullableFirst) + { + const string nullable = "public global::System.Collections.Generic.List Nullable => default;"; + const string plain = "public global::System.Collections.Generic.List Plain => default;"; + var source = "#nullable enable\nusing NullableStrings = System.Collections.Generic.List; namespace Sample { public class Clock { " + + (nullableFirst ? nullable + plain : plain + nullable) + " } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List)))); + StringAssert.Contains("System.Collections.Generic.List Nullable", output); + StringAssert.Contains("System.Collections.Generic.List Plain", output); + StringAssert.DoesNotContain("NullableStrings Nullable", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List", output); + Assert.AreEqual(2, Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralTypeNames.Decision")); + } + + [Test] + public async Task NamespacePrefixRetainsTypeParameterOwnerIdentities() + { + var parameter = new CSharpType(typeof(NamespaceParameter<>).GetGenericArguments()[0]); + var list = new CSharpType(typeof(List<>), new[] { parameter }); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {list} Value => default;"); + writer.WriteLine($"public {list} M() {{"); + writer.WriteLine($"{list} Local() => default;"); + writer.WriteRawLine("return default; } } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("List Value", output); + StringAssert.Contains("List M()", output); + StringAssert.Contains("List Local()", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List", output); + } + + [Test] + public async Task NamespaceAliasRendersTheRetainedTerminalArguments() + { + const string source = "using G = System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List Value => default; } }"; + StringAssert.Contains("G::List Value", await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List))))); + } + + [Test] + public async Task NongenericOwnerAndTerminalArityUseTheSameNamespaceRule() + { + var owner = new CSharpType("Owner", "Sample", false, false, null, Array.Empty(), true, false); + var box = new CSharpType("Box", "Sample", false, false, owner, new CSharpType[] { typeof(int) }, true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Owner { public class Box {} } public class Clock {"); + writer.WriteLine($"public {box} Value => default;"); + writer.WriteRawLine("} }"); + }); + StringAssert.Contains("Owner.Box Value", await Reduce(code)); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public async Task NamespacePrefixEvidenceMustBeValidBeforePlanCacheReuse(int kind) + { + const string source = "using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List First => default; public global::System.Collections.Generic.List Second => default; } }"; + var sites = PrefixSites(source, typeof(List)).ToList(); + var second = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType().Last().Type; + sites[1] = kind switch + { + 0 => sites[1] with { Span = second.Span }, + 1 => sites[1] with { Type = typeof(HashSet) }, + 2 => sites[1] with { Role = TypeReferenceRole.WholeTypeCref }, + _ => sites[1] + }; + if (kind == 3) sites.Add(sites[1]); + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("List First", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List First", output); + StringAssert.Contains("global::System.Collections.Generic.List Second", output); + } + + [TestCase(false)] + [TestCase(true)] + public void InvalidOrUnboundArgumentGraphsKeepTheirNamespace(bool unbound) + { + var argument = unbound ? "" : "Missing"; + var source = "namespace Sample { public class Clock { public System.Type Kind => typeof(global::System.Collections.Generic.List<" + argument + ">); } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var name = tree.GetRoot().DescendantNodes().OfType().Single(static node => node.Right is GenericNameSyntax); + var generic = (GenericNameSyntax)name.Right; + var compilation = CSharpCompilation.Create("NamespaceInvalidControl", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.AreEqual(!unbound, compilation.GetDiagnostics().Any(static d => d.Severity == DiagnosticSeverity.Error)); + var site = new TypeReferenceSite(TextSpan.FromBounds(name.SpanStart, generic.Identifier.Span.End), typeof(List)); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), new[] { site }); + Assert.That(rewriter, Is.Not.Null); + StringAssert.Contains(name.ToString(), rewriter!.Visit(tree.GetRoot())!.ToFullString()); + } + + [Test] + public async Task NamespacePrefixPlansCannotBypassAttributeOrSourceTextGuards() + { + var type = typeof(List); + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class HolderAttribute : Attribute { public HolderAttribute(Type value) {} } public class Clock {"); + writer.WriteLine($"public {type} M([Holder(typeof({type}))] int value) => new {type}();"); + writer.WriteRawLine("public object Captured() {"); + writer.WriteLine($"{type} seed = default;"); + writer.WriteLine($"return Capture(new {type}());"); + writer.WriteRawLine("} private string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("List M(", output); + StringAssert.Contains("Holder(typeof(global::System.Collections.Generic.List))", output); + StringAssert.Contains("=> new List()", output); + StringAssert.Contains("Capture(new global::System.Collections.Generic.List())", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task NamespacePrefixPreservesTheTerminalIdentifierToken() + { + const string source = "using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.@List Value => default; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List)))); + StringAssert.Contains("@List Value", output); + StringAssert.DoesNotContain("global::", output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task WarmNamespacePlanCannotBypassOriginalTrivia(bool argumentComment) + { + var second = argumentComment ? "global::System.Collections.Generic.List" : "global::System.Collections.Generic./* keep */List"; + var source = "using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List First => default; public " + second + " Second => default; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, typeof(List)))); + StringAssert.DoesNotContain("global::System.Collections.Generic.List First", output); + StringAssert.Contains(second + " Second", output); + } + + [Test] + public async Task OwnerAliasRetainsTheTerminalAndItsArguments() + { + const string source = "using O = Sample.Owner; namespace Sample { public class Owner { public class Box {} } public class Clock { public class Owner {} public global::Sample.Owner.Box Value => default; } }"; + var owner = new CSharpType("Owner", "Sample", false, false, null, Array.Empty(), true, false); + var box = new CSharpType("Box", "Sample", false, false, owner, new CSharpType[] { typeof(int) }, true, false); + StringAssert.Contains("O.Box Value", await Reduce(new CodeFile(source, "Clock.cs", PrefixSites(source, box)))); + } + + [TestCase(false)] + [TestCase(true)] + public async Task NamespacePrefixesRespectConditionalVariantEligibility(bool genericLocalFunction) + { + var parameter = new CSharpType(typeof(NamespaceParameter<>).GetGenericArguments()[0]); + var list = new CSharpType(typeof(List<>), new[] { parameter }); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {list} Value => default;"); + writer.WriteRawLine("public object M() {\n#if NET8_0_OR_GREATER"); + if (genericLocalFunction) writer.WriteRawLine("void Local() {} "); + writer.WriteLine($"{list} inactive = default;"); + writer.WriteRawLine("#else\nint boundary = 0;"); + writer.WriteLine($"{list} active = default;"); + writer.WriteRawLine("#endif"); + writer.WriteLine($"return typeof({list});"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + if (genericLocalFunction) StringAssert.Contains("global::System.Collections.Generic.List Value", output); + else + { + StringAssert.DoesNotContain("global::System.Collections.Generic.List Value", output); + StringAssert.Contains("List Value", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.List active", output); + } + StringAssert.Contains("global::System.Collections.Generic.List inactive", output); + static string[] Disabled(string source) => CSharpSyntaxTree.ParseText(source).GetRoot().DescendantTrivia() + .Where(static trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static trivia => trivia.ToFullString()).ToArray(); + Assert.That(Disabled(output), Is.EqualTo(Disabled(code.Content))); + foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER"), new CSharpParseOptions().WithPreprocessorSymbols("NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER") }) + AssertOperationBindings(code.Content, output, options); + } + + private static TypeReferenceSite[] PrefixSites(string source, CSharpType type) => CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes() + .OfType().Select(property => + { + var name = (QualifiedNameSyntax)property.Type; + var generic = (GenericNameSyntax)name.Right; + return new TypeReferenceSite(TextSpan.FromBounds(name.SpanStart, generic.Identifier.Span.End), type); + }).ToArray(); + + private sealed class NamespaceParameter { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs new file mode 100644 index 00000000000..ddc9b26856b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs @@ -0,0 +1,401 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task NamespaceRolesIncludeOperatorAndConversionTypes() + { + var clock = new CSharpType("Clock", "Sample", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public static {clock} operator +(Clock left, Clock right) => left;"); + writer.WriteLine($"public static explicit operator {typeof(DateTimeOffset)}(Clock value) => default;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("static Clock operator +", output); + StringAssert.Contains("operator DateTimeOffset(Clock", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeRefAndTupleTypeSlots() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"private static {typeof(DateTimeOffset)} _stamp;"); + writer.WriteLine($"public ref {typeof(DateTimeOffset)} Value => ref _stamp;"); + writer.WriteLine($"public ({typeof(DateTimeOffset)} Stamp, {typeof(TimeSpan)} Duration) Pair => default;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("ref DateTimeOffset Value", output); + StringAssert.Contains("(DateTimeOffset Stamp, TimeSpan Duration)", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeFunctionPointerTypeSlots() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public unsafe class Clock {"); + writer.WriteLine($"public delegate*<{typeof(DateTimeOffset)}, {typeof(TimeSpan)}> Pointer;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code, allowUnsafe: true); + StringAssert.Contains("delegate*", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeForeachAndUnambiguousPatterns() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public void M(object value, System.Collections.Generic.IEnumerable values) {"); + writer.WriteLine($"foreach ({typeof(DateTimeOffset)} stamp in values) {{ }}"); + writer.WriteLine($"if (value is {typeof(DateTimeOffset)} matched) {{ }}"); + writer.WriteLine($"if (value is {typeof(DateTimeOffset)} {{ Year: 2020 }}) {{ }}"); + writer.WriteRawLine("} } }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("foreach (DateTimeOffset stamp", output); + StringAssert.Contains("is DateTimeOffset matched", output); + StringAssert.Contains("is DateTimeOffset { Year", output); + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + } + + [Test] + public async Task NamespaceRolesIncludeAsTypeOperands() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public object M(object value) => value as {typeof(Exception)};"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("value as Exception", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeSizeofAndStackallocTypes() + { + var point = new CSharpType("Point", "Sample", true, false, null, Array.Empty(), true, true); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public struct Point { public int X; } public unsafe class Clock {"); + writer.WriteLine($"public int Size() => sizeof({point});"); + writer.WriteLine($"public void Allocate() {{ var buffer = stackalloc {point}[1]; }}"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code, allowUnsafe: true); + var tree = CSharpSyntaxTree.ParseText(code.Content); + var name = tree.GetRoot().DescendantNodes().OfType().Single(static node => node.Parent is ArrayTypeSyntax); + Assert.True(SyntaxFacts.IsInTypeOnlyContext(name)); + Assert.False(SyntaxFacts.IsInTypeOnlyContext((ExpressionSyntax)name.Parent!), "Classifier expects the original named slot, not its promoted stackalloc wrapper"); + StringAssert.Contains("sizeof(Point)", output); + StringAssert.Contains("stackalloc Point[1]", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludePrimaryConstructorBases() + { + var baseType = new CSharpType("Base", "Sample", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Base(int value) { }"); + writer.WriteLine($"public class Clock(int value) : {baseType}(value) {{ }}"); + writer.WriteRawLine("}"); + }); + var output = await ReduceRole(code); + StringAssert.Contains(": Base(value)", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeExplicitInterfaceSpecifiers() + { + var iface = new CSharpType("IWidget", "Sample", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public interface IWidget { void M(); int P { get; } } public class Clock : IWidget {"); + writer.WriteLine($"void {iface}.M() {{ }}"); + writer.WriteLine($"int {iface}.P => 0;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("void IWidget.M()", output); + StringAssert.Contains("int IWidget.P", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task NamespaceRolesIncludeExplicitLambdaReturnTypes() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"System.Func make = {typeof(Exception)} () => new System.Exception();"); + writer.WriteRawLine("return make; } } }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("make = Exception () =>", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task AmbiguousBarePatternAndUnprotectedCastKeepTheirGuards() + { + var marker = new CSharpType("Marker", "Sample", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Marker {} public class Clock { public bool M(object value) { const int Marker = 0;"); + writer.WriteLine($"return value is {marker};"); + writer.WriteRawLine("}"); + writer.WriteLine($"public object Cast(object value) => ({marker})value;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("is global::Sample.Marker", output); + StringAssert.Contains("(global::Sample.Marker)value", output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ExplicitInterfaceScopeDoesNotShareMethodTypeParameterFacts(bool returnFirst) + { + var iface = new CSharpType("I", "N", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine(returnFirst ? "namespace N { public interface I { global::N.I M(I value); } }" : "namespace N { public interface I { void M(I value); } }"); + writer.WriteRawLine("namespace Sample { public class Clock : N.I {"); + if (returnFirst) writer.WriteLine($"{iface} {iface}.M(I value) {{"); + else writer.WriteLine($"void {iface}.M(I value) {{"); + writer.WriteLine($"{iface} other = null;"); + writer.WriteRawLine(returnFirst ? "return other; } } }" : "} } }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("I.M(I value)", output); + StringAssert.DoesNotContain("global::N.I.M", output); + StringAssert.Contains("N.I other", output); + if (returnFirst) StringAssert.Contains("N.I I.M", output); + } + + [Test] + public async Task DeclarationPatternContextualIdentifierKeepsTypeGrammarAfterSerialization() + { + var marker = new CSharpType("var", "N", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace N { public class var {} } namespace Sample { public class Clock { public bool M(object value) {"); + writer.WriteLine($"return value is {marker} captured;"); + writer.WriteRawLine("} } }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("is @var captured", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task WrapperNormalizationPreservesProtectedAndUnprotectedCastPolicies() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public object Protected(object value) => ({typeof(Exception)}[])(value);"); + writer.WriteLine($"public object Array(object value) => ({typeof(Exception)}[])value;"); + writer.WriteLine($"public object Nullable(object value) => ({typeof(DateTimeOffset)}?)value;"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("(Exception[])(value)", output); + StringAssert.Contains("(global::System.Exception[])value", output); + StringAssert.Contains("(global::System.DateTimeOffset?)value", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task LambdaRoleInsideCapturedArgumentRemainsExactAfterWarmDecision() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"Func outside = {typeof(Exception)} () => new Exception();"); + writer.WriteLine($"return Capture({typeof(Exception)} () => new Exception());"); + writer.WriteRawLine("} private object Capture(Func make, [CallerArgumentExpression(\"make\")] string text = \"\") => text; } }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("outside = Exception () =>", output); + StringAssert.Contains("Capture(global::System.Exception () =>", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase("using System; class C { public static explicit operator DateTimeOffset(C value) => default; }", "DateTimeOffset", "TimeSpan", false)] + [TestCase("unsafe class C { public delegate* P; }", "DateTimeOffset", "TimeSpan", true)] + [TestCase("interface I { void M(); } interface J { void M(); } class C : I, J { public void M() {} void I.M() {} }", "void I.M", "void J.M", false)] + public void RoleOracleRejectsChangedReturnPointerOrInterface(string source, string before, string after, bool allowUnsafe) + { + Assert.Throws(() => AssertRoleBindings(source, source.Replace(before, after), allowUnsafe)); + } + + [Test] + public void RoleOracleRejectsTypePatternBecomingAConstantPattern() + { + const string source = "namespace N { public class T {} } class C { const int T = 0; bool M(object value) => value is global::N.T; }"; + Assert.Throws(() => AssertRoleBindings(source, source.Replace("global::N.T", "T"), false)); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + public async Task AliasQualifiedOuterTypeCannotBypassPolicyAfterWarmLookup(int kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Linq; using System.Collections.Generic; public class Box {} public class Outer { public class Inner {} } namespace Sample { public class Clock { public object M(object value, IEnumerable> values) {"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + switch (kind) + { + case 0: writer.WriteLine($"return (global::Box<{typeof(Exception)}>)value;"); break; + case 1: writer.WriteLine($"var query = from global::Box<{typeof(Exception)}> item in values select item; return query;"); break; + case 2: writer.WriteLine($"var query = from item in values join global::Box<{typeof(Exception)}> other in values on 0 equals 0 select item; return query;"); break; + case 3: writer.WriteLine($"global::Outer<{typeof(Exception)}>.Inner nested = default; return nested;"); break; + case 4: writer.WriteLine($"return value is global::Box<{typeof(Exception)}>;"); break; + default: writer.WriteLine($"return ((global::Box<{typeof(Exception)}> First, int Second))value;"); break; + } + writer.WriteRawLine("} } }"); + }); + var output = await ReduceRole(code); + StringAssert.DoesNotContain("global::System.Exception seed", output); + StringAssert.Contains("", output, "The enclosing role veto must precede warm decision-cache reuse"); + } + + [Test] + public async Task CompilerRolesCoverScopedReferencesAndFixedDeclarations() + { + var point = new CSharpType("Point", "Sample", true, false, null, Array.Empty(), true, true); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public struct Point { public int X; } public unsafe class Clock {"); + writer.WriteLine($"public void M(scoped ref {typeof(DateTimeOffset)} value) {{ scoped ref {typeof(DateTimeOffset)} alias = ref value; }}"); + writer.WriteRawLine("public void Pin(Point[] values) {"); + writer.WriteLine($"fixed ({point}* pointer = values) {{ }}"); + writer.WriteRawLine("} } }"); + }); + var output = await ReduceRole(code, allowUnsafe: true); + StringAssert.Contains("scoped ref DateTimeOffset", output); + StringAssert.Contains("fixed (Point* pointer", output); + StringAssert.DoesNotContain("global::", output); + } + + [Test] + public async Task ExplicitIndexerAndEventRolesPreserveImplementedMembers() + { + var iface = new CSharpType("ISlot", "Sample", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public interface ISlot { object this[int index] { get; } event Action Changed; } public class Clock : ISlot {"); + writer.WriteLine($"object {iface}.this[int index] => null;"); + writer.WriteLine($"event Action {iface}.Changed {{ add {{ }} remove {{ }} }}"); + writer.WriteRawLine("} }"); + }); + var output = await ReduceRole(code); + StringAssert.Contains("ISlot.this[int index]", output); + StringAssert.Contains("ISlot.Changed", output); + StringAssert.DoesNotContain("global::", output); + } + + private static async Task ReduceRole(CodeFile code, bool allowUnsafe = false) + { + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(code); + string? output = null; + await foreach (var file in workspace.GetGeneratedFilesAsync()) if (file.Name == code.Name) output = file.Text; + Assert.NotNull(output); + AssertRoleBindings(code.Content, output!, allowUnsafe); + return output!; + } + + private static void AssertRoleBindings(string before, string after, bool allowUnsafe) + { + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("NamespaceRoleOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: allowUnsafe)); + Assert.That(compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var result = new List(); + foreach (var node in tree.GetRoot().DescendantNodes()) + { + var type = node switch + { + OperatorDeclarationSyntax declaration => declaration.ReturnType, + ConversionOperatorDeclarationSyntax declaration => declaration.Type, + RefTypeSyntax reference => reference.Type, + TupleElementSyntax element => element.Type, + FunctionPointerParameterSyntax parameter => parameter.Type, + ForEachStatementSyntax statement => statement.Type, + DeclarationPatternSyntax pattern => pattern.Type, + RecursivePatternSyntax pattern => pattern.Type, + TypePatternSyntax pattern => pattern.Type, + PrimaryConstructorBaseTypeSyntax @base => @base.Type, + ExplicitInterfaceSpecifierSyntax specifier => specifier.Name, + SizeOfExpressionSyntax size => size.Type, + StackAllocArrayCreationExpressionSyntax stack => stack.Type, + ParenthesizedLambdaExpressionSyntax lambda => lambda.ReturnType, + BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.AsExpression) => binary.Right as TypeSyntax, + _ => null + }; + if (type != null) + { + var symbol = model.GetTypeInfo(type).Type ?? model.GetSymbolInfo(type).Symbol as ITypeSymbol; + result.Add(node.Kind() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(symbol)); + } + if (node is VariableDeclaratorSyntax variable && model.GetDeclaredSymbol(variable) is ILocalSymbol local) + result.Add("local:" + StructuralTypeNameRuleOracleTests.TypeIdentity(local.Type)); + if (node is TypeDeclarationSyntax && model.GetDeclaredSymbol(node) is INamedTypeSymbol declared) + result.Add("base:" + StructuralTypeNameRuleOracleTests.TypeIdentity(declared.BaseType)); + if (node is BinaryExpressionSyntax isExpression && isExpression.IsKind(SyntaxKind.IsExpression)) + result.Add("is-type:" + StructuralTypeNameRuleOracleTests.TypeIdentity(model.GetTypeInfo(isExpression.Right).Type) + ":" + model.GetSymbolInfo(isExpression.Right).Symbol?.Kind); + if (node is IsPatternExpressionSyntax expression && model.GetOperation(expression.Pattern) is IPatternOperation patternOperation) + result.Add("pattern:" + patternOperation.Kind + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(patternOperation.NarrowedType) + ":" + + (patternOperation is IConstantPatternOperation constant ? constant.Value.ConstantValue.ToString() : "")); + if (node is PropertyDeclarationSyntax or IndexerDeclarationSyntax && model.GetDeclaredSymbol(node) is IPropertySymbol property) + result.Add("property-interface:" + string.Join("|", property.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + if (node is EventDeclarationSyntax && model.GetDeclaredSymbol(node) is IEventSymbol @event) + result.Add("event-interface:" + string.Join("|", @event.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + if (node is BaseMethodDeclarationSyntax && model.GetDeclaredSymbol(node) is IMethodSymbol method) + result.Add("method:" + method.RefKind + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(method.ReturnType) + ":" + + string.Join("|", method.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + } + return result.ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Namespace-only rewriting must preserve types and implemented interface members"); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs new file mode 100644 index 00000000000..72d043d1239 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + [TestCase(9)] + [TestCase(10)] + [TestCase(11)] + public async Task UnobservedCallSourceAllowsNamespaceReduction(int kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M(object value) {"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + switch (kind) + { + case 0: writer.WriteLine($"return Pass(new {typeof(Exception)}());"); break; + case 1: writer.WriteLine($"return Pass(typeof({typeof(Exception)}));"); break; + case 2: writer.WriteLine($"return Pass(default({typeof(Exception)}));"); break; + case 3: writer.WriteLine($"return Pass(new {typeof(Exception)}[1]);"); break; + case 4: writer.WriteLine($"return Pass(({typeof(Exception)})(value));"); break; + case 5: writer.WriteLine($"return new Consumer(new {typeof(Exception)}());"); break; + case 6: writer.WriteLine($"return new {typeof(Exception)}().IdentityExtension();"); break; + case 7: writer.WriteLine($"return Generic<{typeof(Exception)}>();"); break; + case 8: writer.WriteLine($"return CaptureOther(\"fixed\", new {typeof(Exception)}());"); break; + case 9: writer.WriteLine($"return Capture(new {typeof(Exception)}(), text: \"chosen\");"); break; + case 10: writer.WriteLine($"return new CapturedConsumer(new {typeof(Exception)}(), text: \"chosen\");"); break; + default: writer.WriteLine($"return new {typeof(Exception)}().CapturedExtension(text: \"chosen\");"); break; + } + writer.WriteRawLine("} private object Pass(object value) => value; private object Generic() => typeof(T);"); + writer.WriteRawLine("private string CaptureOther(object first, object value, [CallerArgumentExpression(\"first\")] string text = \"\") => text;"); + writer.WriteRawLine("private string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; }"); + writer.WriteRawLine("public class Consumer { public Consumer(object value) {} } public class CapturedConsumer { public CapturedConsumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} }"); + writer.WriteRawLine("public static class Extensions { public static object IdentityExtension(this object value) => value; public static string CapturedExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::System.Exception", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task OrdinaryConstructorInitializerSourceCanReduce() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Base { public Base(object value) {} } public class Clock : Base {"); + writer.WriteLine($"public Clock() : base(new {typeof(Exception)}()) {{ }}"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("base(new Exception())", output); + StringAssert.DoesNotContain("global::", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task OuterCapturedArgumentStillProtectsNestedOrdinaryCall(bool receiver) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + if (receiver) writer.WriteLine($"return Pass(new {typeof(Exception)}()).CapturedExtension();"); + else writer.WriteLine($"return Capture(Pass(new {typeof(Exception)}()));"); + writer.WriteRawLine("} private object Pass(object value) => value; private string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; }"); + writer.WriteRawLine("public static class Extensions { public static string CapturedExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Pass(new global::System.Exception())", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task DynamicCallsRemainConservative() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public object M(dynamic api) {"); + writer.WriteLine($"return api.Pass(new {typeof(Exception)}());"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task DelegateInvokeCallerExpressionRemainsExact() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public delegate string CaptureDelegate(object value, [CallerArgumentExpression(\"value\")] string text = \"\"); public class Clock { public string M(CaptureDelegate capture) {"); + writer.WriteLine($"return capture(new {typeof(Exception)}());"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ReorderedArgumentsUseParameterIdentityRatherThanEvaluationOrder(bool captureValue) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"return Capture(value: new {typeof(Exception)}(), first: \"fixed\");"); + writer.WriteRawLine("} private object Capture(object first, object value, [CallerArgumentExpression(\"" + (captureValue ? "value" : "first") + "\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + if (captureValue) StringAssert.Contains("new global::System.Exception()", output); + else StringAssert.DoesNotContain("global::System.Exception", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task MultipleOmittedCaptureParametersProtectBothSources() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"return Capture(new {typeof(Exception)}(), typeof({typeof(Exception)}));"); + writer.WriteRawLine("} private object Capture(object first, object second, [CallerArgumentExpression(\"first\")] string a = \"\", [CallerArgumentExpression(\"second\")] string b = \"\") => a + b; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + StringAssert.Contains("typeof(global::System.Exception)", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task EnclosingCallProofCoversLambdaSource(bool captured) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"return Capture(() => typeof({typeof(Exception)}));"); + writer.WriteRawLine("} private object Capture(Func make" + (captured ? ", [CallerArgumentExpression(\"make\")] string text = \"\"" : "") + ") => make; } }"); + }); + var output = await Reduce(code); + if (captured) StringAssert.Contains("typeof(global::System.Exception)", output); + else StringAssert.DoesNotContain("global::System.Exception", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task DelegateContractNotImplementationAttributesControlsCapture() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public delegate object PlainDelegate(object value, string text = \"\"); public class Clock { public object M() { PlainDelegate callback = Target;"); + writer.WriteLine($"return callback(new {typeof(Exception)}());"); + writer.WriteRawLine("} private object Target(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::System.Exception", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task PartialMethodCaptureAttributesAreReadFromCombinedSymbol(bool onImplementation) + { + var attribute = "[System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] "; + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public partial class Clock { public object M() {"); + writer.WriteLine($"return Capture(new {typeof(Exception)}());"); + writer.WriteRawLine("} private partial object Capture(object value, " + (onImplementation ? "" : attribute) + "string text = \"\");"); + writer.WriteRawLine("private partial object Capture(object value, " + (onImplementation ? attribute : "") + "string text) => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task MultilineRemovedPrefixCannotShiftCallerLineNumber() + { + const string source = "using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() { var value = Pass(new global::System.\nException()); return Line(); } private object Pass(object value) => value; private int Line([CallerLineNumber] int line = 0) => line; } }"; + var node = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType().Single(static name => name.Right.Identifier.ValueText == "Exception"); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(node.Span, typeof(Exception)) })); + StringAssert.Contains("global::System.\nException", output); + AssertOperationBindings(source, output); + } + + [TestCase(0, false)] + [TestCase(0, true)] + [TestCase(1, false)] + [TestCase(1, true)] + [TestCase(2, false)] + [TestCase(2, true)] + public async Task AlternateCallRepresentationsHonorActualCaptureMapping(int kind, bool explicitText) + { + var suffix = explicitText ? ", text: \"chosen\"" : ""; + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Consumer { public Consumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } public class Base { public Base(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } public class Clock : Base {"); + if (kind == 2) writer.WriteLine($"public Clock() : base(new {typeof(Exception)}(){suffix}) {{ }}"); + else + { + writer.WriteRawLine("public Clock() : base(null) {} public object M() {"); + if (kind == 0) writer.WriteLine($"Consumer result = new(new {typeof(Exception)}(){suffix}); return result;"); + else writer.WriteLine($"return Extensions.CapturedExtension(new {typeof(Exception)}(){suffix});"); + writer.WriteRawLine("}"); + } + writer.WriteRawLine("} public static class Extensions { public static string CapturedExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + if (explicitText) StringAssert.DoesNotContain("global::System.Exception", output); + else StringAssert.Contains("new global::System.Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task CapturedCallDoesNotContaminateAnOrdinarySiblingCall() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"var first = Capture(new {typeof(Exception)}());"); + writer.WriteLine($"return Pass(new {typeof(Exception)}());"); + writer.WriteRawLine("} private object Pass(object value) => value; private object Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Capture(new global::System.Exception())", output); + StringAssert.Contains("Pass(new Exception())", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public void OperationOracleObservesImplicitCreationAndConstructorInitializerCapture(bool initializer) + { + const string helpers = "using System; using System.Runtime.CompilerServices; class Consumer { public Consumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } "; + var source = helpers + (initializer + ? "class C : Consumer { public C() : base(new global::System.Exception()) {} }" + : "class C { object M() { Consumer result = new(new global::System.Exception()); return result; } }"); + Assert.Throws(() => AssertOperationBindings(source, source.Replace("global::System.Exception", "Exception"))); + } + + [Test] + public async Task ConditionalReceiverBindingCannotReuseOriginalCallProof() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Captured { public object Pass(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } public class Plain { public object Pass(object value) => value; } public class Clock { public object M() {\n#if NET8_0_OR_GREATER\nvar api = new Captured();\n#else\nvar api = new Plain();\n#endif"); + writer.WriteLine($"return api.Pass(new {typeof(Exception)}());"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER") }) + AssertOperationBindings(code.Content, output, options); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaCacheTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaCacheTests.cs new file mode 100644 index 00000000000..e4b7c23ef6a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaCacheTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false)] + [TestCase(true)] + public async Task WarmNamespacePlansRestoreEachOccurrencesOwnBoundaryComments(bool generic) + { + var type = generic ? typeof(List) : typeof(DateTimeOffset); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public void M() {"); + writer.WriteLine($"{type} seed = default;"); + writer.WriteRawLine("// unique second occurrence"); + writer.WriteLine($"{type} second = default;"); + writer.WriteRawLine("/* unique third occurrence */"); + writer.Append($"{type}"); + writer.WriteRawLine(" /* unique trailing occurrence */ third = default;"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::", output); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertOperationBindings(code.Content, output); + Assert.That(Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralTypeNames.CacheHit"), Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public async Task InternalArgumentCommentRemainsExactWhileItsRecordedChildCanShorten() + { + const string source = "using System; using System.Collections.Generic; namespace Sample { public class Clock { public global::System.Collections.Generic.List<\n/* keep child boundary */global::System.DateTimeOffset> Value => default; } }"; + var root = CSharpSyntaxTree.ParseText(source).GetRoot(); + var outer = root.DescendantNodes().OfType().Single(static name => name.Right is GenericNameSyntax); + var child = root.DescendantNodes().OfType().Single(static name => name.ToString() == "global::System.DateTimeOffset"); + var sites = new[] + { + new TypeReferenceSite(TextSpan.FromBounds(outer.SpanStart, outer.Right.Identifier.Span.End), typeof(List)), + new TypeReferenceSite(child.Span, typeof(DateTimeOffset)) + }; + Assert.That(StructuralTypeNameSelector.Describe(outer, preserveBoundaryComments: true).SignificantTrivia, Is.True); + Assert.That(StructuralTypeNameSelector.Describe(child).SignificantTrivia, Is.True); + Assert.That(StructuralTypeNameSelector.Describe(child, preserveBoundaryComments: true).SignificantTrivia, Is.False); + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("global::System.Collections.Generic.List<\n/* keep child boundary */DateTimeOffset> Value", output); + AssertBoundaryTriviaIdentity(source, output); + AssertOperationBindings(source, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaTests.cs new file mode 100644 index 00000000000..89e06614b84 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceTriviaTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("/// Keep exact. ", false)] + [TestCase("/// Keep exact. ", true)] + [TestCase("/** Keep documentation exact. */", false)] + [TestCase("/** Keep documentation exact. */", true)] + [TestCase("// Keep leading comment exact.", false)] + [TestCase("// Keep leading comment exact.", true)] + [TestCase("/* Keep leading comment exact. */", false)] + [TestCase("/* Keep leading comment exact. */", true)] + public async Task OuterBoundaryCommentsArePreservedDuringNamespaceReduction(string comment, bool generic) + { + var type = generic ? typeof(List) : typeof(DateTimeOffset); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public interface I {"); + writer.WriteLine($"{type} M();"); + writer.WriteRawLine("} public class Clock : I {"); + writer.WriteRawLine(comment); + writer.WriteLine($"{type} I.M() => default;"); + writer.WriteRawLine("} }"); + }); + var original = BoundaryReference(code, code.TypeReferences!.Last()); + Assert.That(original.GetLeadingTrivia().Any(static trivia => trivia.ToFullString().Contains("Keep")), Is.True); + Assert.That(StructuralTypeNameSelector.Describe(original).SignificantTrivia, Is.True); + Assert.That(StructuralTypeNameSelector.Describe(original, preserveBoundaryComments: true).SignificantTrivia, Is.False); + var output = await Reduce(code); + var terminal = generic ? "List" : "DateTimeOffset"; + StringAssert.Contains(terminal + " I.M()", output); + StringAssert.DoesNotContain((generic ? "global::System.Collections.Generic." : "global::System.") + terminal + " I.M()", output); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertOperationBindings(code.Content, output); + } + + [TestCase("/* keep tail */", false)] + [TestCase("// keep tail\n", false)] + [TestCase("/* keep tail */", true)] + [TestCase("// keep tail\n", true)] + public async Task TrailingBoundaryCommentsArePreservedDuringNamespaceReduction(string comment, bool generic) + { + var type = generic ? typeof(List) : typeof(DateTimeOffset); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public interface I {"); + writer.WriteLine($"{type} M();"); + writer.WriteRawLine("} public class Clock : I {"); + writer.Append($"{type}"); + writer.WriteRawLine(" " + comment); + writer.WriteRawLine("I.M() => default; } }"); + }); + var output = await Reduce(code); + var original = BoundaryReference(code, code.TypeReferences!.Last()); + Assert.That(original.GetTrailingTrivia().Any(static trivia => trivia.ToFullString().Contains("keep tail")), Is.True); + Assert.That(StructuralTypeNameSelector.Describe(original).SignificantTrivia, Is.True); + Assert.That(StructuralTypeNameSelector.Describe(original, preserveBoundaryComments: true).SignificantTrivia, Is.False); + StringAssert.DoesNotContain(generic ? "global::System.Collections.Generic.List" : "global::System.DateTimeOffset", output); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task BoundaryCommentsDoNotBypassCapturedSourceProtection() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock {"); + writer.WriteRawLine("public object M() => Capture(() => {"); + writer.WriteRawLine("// captured boundary comment"); + writer.WriteLine($"{typeof(DateTimeOffset)} value = default;"); + writer.WriteRawLine("return value; });"); + writer.WriteRawLine("private object Capture(Func value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset value", output); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task BoundaryDirectivesRemainConservative() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public interface I { System.DateTimeOffset M(); } public class Clock : I {"); + writer.WriteRawLine("#region keep directive exact"); + writer.WriteLine($"{typeof(DateTimeOffset)} I.M() => default;"); + writer.WriteRawLine("#endregion\n} }"); + }); + var output = await Reduce(code); + var original = BoundaryReference(code, code.TypeReferences!.Last()); + Assert.That(StructuralTypeNameSelector.Describe(original, preserveBoundaryComments: true).Directives, Is.True); + StringAssert.Contains("global::System.DateTimeOffset I.M()", output); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertOperationBindings(code.Content, output); + } + + private static NameSyntax BoundaryReference(CodeFile code, TypeReferenceSite site) => CSharpSyntaxTree.ParseText(code.Content).GetRoot() + .DescendantNodes().OfType().Single(name => name.SpanStart == site.Span.Start && + (name switch { QualifiedNameSyntax qualified => qualified.Right.Identifier.Span.End, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.Span.End, + SimpleNameSyntax simple => simple.Identifier.Span.End, _ => -1 }) == site.Span.End); + + private static void AssertBoundaryTriviaIdentity(string before, string after) + { + static string[] Significant(string source) => CSharpSyntaxTree.ParseText(source).GetRoot().DescendantTrivia() + .Where(static trivia => !trivia.IsKind(SyntaxKind.WhitespaceTrivia) && !trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + .Select(static trivia => trivia.RawKind + ":" + trivia.ToFullString()).ToArray(); + Assert.That(Significant(after), Is.EqualTo(Significant(before))); + static string[] Endings(string source) => System.Text.RegularExpressions.Regex.Matches(source, "\\r\\n|\\r|\\n") + .Select(static match => match.Value).ToArray(); + Assert.That(Endings(after), Is.EqualTo(Endings(before))); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultReflectionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultReflectionTests.cs new file mode 100644 index 00000000000..68258ffd0c4 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultReflectionTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public void StructuralOptionalDefaultReflectionSeparatesMissingNullAndDbNullOnValidDeclarations() + { + const string source = "using System.Runtime.InteropServices; public class Clock { public static void M([Optional] object missing, object explicitNull=null){} public static void N(object required){} }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("DefaultReflectionKinds", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + using var bytes = new MemoryStream(); + Assert.That(compilation.Emit(bytes).Success, Is.True); + var type = Assembly.Load(bytes.ToArray()).GetType("Clock")!; + var parameters = type.GetMethod("M")!.GetParameters(); + var required = type.GetMethod("N")!.GetParameters().Single(); + Assert.That(parameters[0].IsOptional, Is.True); + Assert.That(parameters[0].HasDefaultValue, Is.False); + Assert.That(parameters[0].DefaultValue, Is.SameAs(Missing.Value)); + Assert.That(parameters[1].IsOptional, Is.True); + Assert.That(parameters[1].HasDefaultValue, Is.True); + Assert.That(parameters[1].DefaultValue, Is.Null); + Assert.That(required.IsOptional, Is.False); + Assert.That(required.HasDefaultValue, Is.False); + Assert.That(required.DefaultValue, Is.SameAs(DBNull.Value)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs new file mode 100644 index 00000000000..127e2713eda --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("DayOfWeek")] + [TestCase("Value")] + [TestCase("System.Collections.Generic.List")] + public async Task StructuralOptionalDefaultSupportedConstructedTypesKeepReflectionDefaults(string type) + { + var source = "using System; struct Value {} class Clock { public static object M(" + type + " value = default(" + type + ")) => value; public object Call() => M(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value = default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultNullableConstrainedParameterRetainsWithoutExactAnnotationProof() + { + const string source = "#nullable enable\nclass Clock { public static T? M(T? value = default(T?)) where T:class => value; public object? Call() => M(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("T? value = default(T?)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultNullableValueRetainsWithoutExactAnnotationProof() + { + const string source = "class Clock { public int? M(int? value=default(int?))=>value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=default(int?)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultAliasResolvesSameDeclaredType() + { + const string source = "using Token=System.Threading.CancellationToken; class Clock { public void M(Token value=default(Token)) {} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultDelegateAndLocalFunctionMetadataKeepDefaults() + { + const string source = "using System.Threading; delegate void D(CancellationToken token=default(CancellationToken)); class Clock { public int M(){ int Local(int value=default(int))=>value; return Local(); } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("token=default)", output); + StringAssert.Contains("value=default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralOptionalDefaultEnclosingCaptureProtectsLocalFunctionDeclaration(bool explicitOverride) + { + var source = "using System; using System.Runtime.CompilerServices; class Clock { static string Capture(Func f,[CallerArgumentExpression(\"f\")]string text=null)=>text; " + + "public string M()=>Capture(()=>{int Local(int value=default(int))=>value;return Local();}" + (explicitOverride ? ",\"manual\"" : "") + "); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + Assert.That(output.Contains("value=default(int)"), Is.EqualTo(!explicitOverride)); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultCallerInfoEffectiveArgumentsStayExact() + { + const string source = "using System.Runtime.CompilerServices; class Clock { static int Line([CallerLineNumber]int line=default(int))=>line; public int M()=>Line(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("line=default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase("dynamic")] + [TestCase("System.Collections.Generic.List")] + public async Task StructuralOptionalDefaultDynamicGraphsRemainTyped(string type) + { + var source = "class Clock { public object M(" + type + " value=default(" + type + "))=>value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("default(" + type + ")", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultPrimaryConstructorOwnerRemainsExcluded() + { + const string source = "class Clock(int value=default(int)) { public int M()=>value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=default(int)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultIndexerOwnerRemainsExcluded() + { + const string source = "class Clock { public int this[int value=default(int)]=>value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=default(int)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultParenthesizedDeclarationIsNotDirectDefault() + { + const string source = "class Clock { public int M(int value=(default(int)))=>value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value=(default(int))", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralOptionalDefaultInternalLineBreakKeepsLaterCallerLine(string newline) + { + var source = "using System.Runtime.CompilerServices; class Clock { static int M(int value=default(" + newline + "int))=>value; static int Line([CallerLineNumber]int line=0)=>line; public int Call()=>M()+Line(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("default(" + newline + "int)", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralOptionalDefaultHiddenAndVisibleOrderIsOccurrenceOwned(bool hiddenFirst) + { + const string hidden = "\n#line hidden\npublic int Hidden(int value=default(int))=>value;\n#line default\n"; + const string visible = "public int Visible(int value=default(int))=>value;\n"; + var source = "class Clock {" + (hiddenFirst ? hidden + visible : visible + hidden) + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("Hidden(int value=default(int))", output); + if (hiddenFirst) StringAssert.Contains("Visible(int value=default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase("disabled")] + [TestCase("custom")] + [TestCase("header")] + public async Task StructuralOptionalDefaultWorkspaceAndHeaderGatesRemain(string mode) + { + if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + const string source = "class Clock { public int M(int value=default(int))=>value; }"; + var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; + var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); + StringAssert.Contains("default(int)", output); + AssertOptionalDefaultContracts(source, output, formatterRoute: mode == "custom"); + } + + [Test] + public void StructuralOptionalDefaultOracleRejectsChangedNullableAndPrimitiveDefaults() + { + const string nullable = "class Clock { public int? M(int? value=default(int))=>value; }"; + Assert.Throws(() => AssertOptionalDefaultContracts(nullable, nullable.Replace("default(int)", "default"))); + const string primitive = "class Clock { public int M(int value=default(int))=>value; }"; + Assert.Throws(() => AssertOptionalDefaultContracts(primitive, primitive.Replace("default(int)", "1"))); + AssertOptionalDefaultContracts(primitive, primitive.Replace("default(int)", "default")); + } + + [Test] + public void StructuralOptionalDefaultNullableSemanticRepresentationsDoNotAuthorizeBySpelling() + { + foreach (var source in new[] + { + "class Clock { public int? M(int? value=default(int?))=>value; }", + "#nullable enable\nclass Clock { public T? M(T? value=default(T?)) where T:class=>value; }" + }) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("DefaultNullableProbe", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var parameter = tree.GetRoot().DescendantNodes().OfType().Single(); + var symbol = (IParameterSymbol)model.GetDeclaredSymbol(parameter)!; + var expression = (Microsoft.CodeAnalysis.CSharp.Syntax.DefaultExpressionSyntax)parameter.Default!.Value; + var declared = model.GetTypeInfo(parameter.Type!); var operand = model.GetTypeInfo(expression.Type); + Assert.That(symbol.IsOptional && symbol.HasExplicitDefaultValue, Is.True); + Assert.That(symbol.Type.NullableAnnotation, Is.EqualTo(NullableAnnotation.Annotated)); + Assert.That(operand.Type?.NullableAnnotation, Is.EqualTo(NullableAnnotation.None)); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(symbol.Type, operand.Type), Is.False); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(declared.Type, operand.Type), Is.True, + "Type-syntax equality must not bypass the stronger chosen parameter-symbol contract"); + } + } + + [Test] + public void StructuralOptionalDefaultOracleDoesNotConflateMissingAndNull() + { + const string before = "using System.Runtime.InteropServices; class Clock { public object M([Optional] object value)=>value; }"; + const string after = "using System.Runtime.InteropServices; class Clock { public object M(object value=default(object))=>value; }"; + Assert.Throws(() => AssertOptionalDefaultContracts(before, after)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs new file mode 100644 index 00000000000..fd2edf9a63a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("CancellationToken")] + [TestCase("int")] + [TestCase("string")] + [TestCase("DateTimeOffset")] + public async Task StructuralOptionalDefaultExactParameterTypeKeepsMetadataAndCalls(string type) + { + var source = "using System; using System.Threading; class Clock { public static object M(" + type + " value = default(" + type + ")) => value; public object Call() => M(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value = default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultConstructorParameterKeepsMetadata() + { + const string source = "using System.Threading; class Clock { public Clock(CancellationToken token = default(CancellationToken)) {} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("token = default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultGenericParameterKeepsLexicalIdentity() + { + const string source = "class Clock { public static T M(T value = default(T)) => value; public object Call() => M(); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("T value = default)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultBoundaryCommentsRemainOwned() + { + const string source = "class Clock { public int M(int value = /*a*/default(int)/*b*/) => value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("/*a*/default/*b*/", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase("int?", "int")] + [TestCase("long", "int")] + public async Task StructuralOptionalDefaultDifferentParameterTypeRetains(string parameter, string operand) + { + var source = "class Clock { public object M(" + parameter + " value = default(" + operand + ")) => value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("default(" + operand + ")", output); + AssertOptionalDefaultContracts(source, output); + } + + [TestCase("default(/*keep*/int)")] + [TestCase("default(\nint)")] + public async Task StructuralOptionalDefaultInteriorTriviaOrLinesRetain(string expression) + { + var source = "class Clock { public int M(int value = " + expression + ") => value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(expression, output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultOtherExpressionContextsStayParked() + { + const string source = "class Clock { static int Echo(int value) => value; public int M() { int value = default(int); return Echo(default(int)) + value; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("value = default(int)", output); + StringAssert.Contains("Echo(default(int))", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public async Task StructuralOptionalDefaultConditionalDocumentRemainsConservative() + { + const string source = "using System.Threading; class Clock { public void M(CancellationToken token = default(CancellationToken)) {} private int V() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 6;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("default(CancellationToken)", output); + AssertOptionalDefaultContracts(source, output); + } + + [Test] + public void StructuralOptionalDefaultOlderLanguageVersionRetainsTypedForm() + { + const string source = "class Clock { public int M(int value = default(int)) => value; }"; + var parse = new CSharpParseOptions(LanguageVersion.CSharp7); + var tree = CSharpSyntaxTree.ParseText(source, parse); + var compilation = CSharpCompilation.Create("DefaultLanguage", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), Array.Empty()); + Assert.That(rewriter, Is.Not.Null); + var output = rewriter!.Visit(tree.GetRoot())!.ToFullString(); + StringAssert.Contains("default(int)", output); + AssertOptionalDefaultContracts(source, output, parse); + } + + [Test] + public void StructuralOptionalDefaultInvalidReferenceDefaultIsNotAValidFastPathInput() + { + const string source = "class Clock { public object M(object value = default(string)) => value; }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("InvalidReferenceDefault", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Any(static d => d.Id == "CS1763"), Is.True); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), Array.Empty()); + var output = rewriter?.Visit(tree.GetRoot())?.ToFullString() ?? source; + StringAssert.Contains("default(string)", output); + var after = compilation.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(output)); + Assert.That(after.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.Id), + Is.EqualTo(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.Id))); + } + + private static void AssertOptionalDefaultContracts(string before, string after, CSharpParseOptions? parse = null, bool formatterRoute = false) + { + string RuntimeValue(object? value) => value == null ? "" : ReferenceEquals(value, Missing.Value) ? "" : + ReferenceEquals(value, DBNull.Value) ? "" : value.GetType().FullName + ":" + Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture); + string[] ReflectionDefaults(CSharpCompilation compilation) + { + using var bytes = new MemoryStream(); + var emitted = compilation.Emit(bytes); + Assert.That(emitted.Diagnostics.Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var assembly = Assembly.Load(bytes.ToArray()); + return assembly.GetTypes().OrderBy(static type => type.FullName, StringComparer.Ordinal).SelectMany(type => + type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly).Cast() + .Concat(type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))) + .SelectMany(method => method.GetParameters().Select(parameter => method.DeclaringType!.FullName + ":" + method.Name + ":" + + string.Join(",", method.GetParameters().Select(static p => p.ParameterType.ToString())) + ":" + parameter.Position + ":" + + parameter.IsOptional + ":" + parameter.HasDefaultValue + ":" + RuntimeValue(parameter.DefaultValue) + ":" + RuntimeValue(parameter.RawDefaultValue))) + .OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + } + (string[] Parameters, string[] Reflection, string Outside) Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, parse); + var compilation = CSharpCompilation.Create("OptionalDefaultOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var parameters = tree.GetRoot().DescendantNodes().OfType().ToArray(); + var values = parameters.Select(parameter => + { + var symbol = (IParameterSymbol)model.GetDeclaredSymbol(parameter)!; + var value = symbol.HasExplicitDefaultValue ? symbol.ExplicitDefaultValue : null; + return symbol.ContainingSymbol.GetDocumentationCommentId() + ":" + symbol.ContainingSymbol.Name + ":" + symbol.Ordinal + ":" + symbol.Name + ":" + + StructuralTypeNameRuleOracleTests.TypeIdentity(symbol.Type) + ":" + symbol.RefKind + ":" + symbol.IsParams + ":" + symbol.IsOptional + ":" + + symbol.HasExplicitDefaultValue + ":" + value?.GetType().FullName + ":" + Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture); + }).ToArray(); + var outside = tree.GetText().WithChanges(parameters.Where(p => p.Default != null).Select(p => new TextChange(p.Default!.Value.Span, "__OPTIONAL_DEFAULT__"))).ToString(); + return (values, ReflectionDefaults(compilation), outside); + } + var original = Snapshot(before); var candidate = Snapshot(after); + Assert.That(candidate.Parameters, Is.EqualTo(original.Parameters), "Parameter type/ref/optional/default metadata must remain exact"); + Assert.That(candidate.Reflection, Is.EqualTo(original.Reflection), "Emitted reflection defaults must distinguish null, Missing and DBNull"); + if (formatterRoute) + Assert.That(SyntaxFactory.AreEquivalent(CSharpSyntaxTree.ParseText(candidate.Outside, parse).GetRoot(), CSharpSyntaxTree.ParseText(original.Outside, parse).GetRoot()), Is.True); + else + { + Assert.That(candidate.Outside, Is.EqualTo(original.Outside), "All source outside parameter default expressions must stay exact"); + Assert.That(System.Text.RegularExpressions.Regex.Matches(after, "\\r\\n|\\r|\\n").Select(m => m.Value), + Is.EqualTo(System.Text.RegularExpressions.Regex.Matches(before, "\\r\\n|\\r|\\n").Select(m => m.Value))); + } + AssertParenthesesOperations(before, after, parseOptions: parse); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarSafetyTests.cs new file mode 100644 index 00000000000..c4a88436fa7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarSafetyTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("public int M(long value)=>checked(((int)value));", "checked((int)value)")] + [TestCase("public int M(bool value)=>((bool)value)?1:2;", "=>(bool)value?1:2")] + [TestCase("public long M(int value){long result;result=((long)value);return result;}", "result=(long)value")] + [TestCase("public T? M()where T:struct=>((T?)default(T?));", "=>(T?)default(T?)")] + [TestCase("public object M(object value)=>((System.Collections.Generic.IList)value);", "=>(System.Collections.Generic.IList)value")] + [TestCase("public object M(object value)=>((System.Exception[])value);", "=>(System.Exception[])value")] + public async Task StructuralParenthesesGrammarCastTypesAndContextsPreserveOperations(string member, string expected) + { + var source = "class Clock{" + member + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralParenthesesGrammarCastChainTransfersEveryTriviaAndCallerLine(string ending) + { + var source = "class Clock{public int M(int value){var result=(/*a*/(" + ending + "(int)value" + ending + ")/*b*/);return result+Line();}static int Line([System.Runtime.CompilerServices.CallerLineNumber]int line=0)=>line;}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("result=/*a*/", output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralParenthesesGrammarCastCaptureAndExplicitOverride(bool explicitText) + { + var source = "class Clock{public string M(int value)=>Capture(()=>{return((int)value);}" + (explicitText ? ",\"manual\"" : "") + ");static string Capture(System.Func f,[System.Runtime.CompilerServices.CallerArgumentExpression(\"f\")]string text=null)=>text;}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(explicitText ? "return(int)value" : "return((int)value)", output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralParenthesesGrammarConstructorCaptureAndOverride(bool explicitText) + { + var source = "class Holder{public Holder(System.Func f,[System.Runtime.CompilerServices.CallerArgumentExpression(\"f\")]string text=null){}}class Clock{public object M(int value)=>new Holder(()=>{return((int)value);}" + (explicitText ? ",\"manual\"" : "") + ");}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(explicitText ? "return(int)value" : "return((int)value)", output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [Test] + public async Task StructuralParenthesesGrammarCommonConditionalCastUsesEveryVariantProof() + { + var source = "class Clock{public long M(int value)=>((long)value);" + ConditionalParenthesesBranch + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=>(long)value", output); + AssertConditionalParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [Test] + public async Task StructuralParenthesesGrammarConditionalLocalTypeMayVaryWithoutChangingGrouping() + { + const string source = "class Clock{public long M(){\n#if NET8_0_OR_GREATER\nint value=1;\n#else\nlong value=2;\n#endif\nreturn ((long)value);}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return (long)value", output); + AssertConditionalParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [Test] + public async Task StructuralParenthesesGrammarBranchLocalCastHasNoAlternateCounterpart() + { + const string source = "class Clock{public int M(int value){\n#if NET8_0_OR_GREATER\nreturn ((int)value);\n#else\nreturn ((int)value);\n#endif\n}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + Assert.That(output, Is.EqualTo(source)); + AssertConditionalParenthesesOperations(source, output); + } + + [Test] + public void StructuralParenthesesGrammarCastOriginalNoncanonicalConfigurationIsIncluded() + { + var source = "class Clock{public long M(int value)=>((long)value);" + ConditionalParenthesesBranch + "}"; + var parse = new CSharpParseOptions(preprocessorSymbols: new[] { "EXPERIMENTAL", "NET10_0_OR_GREATER" }); + var tree = CSharpSyntaxTree.ParseText(source, parse); + var compilation = CSharpCompilation.Create("C4Noncanonical", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), Array.Empty()); + Assert.That(rewriter, Is.Not.Null); + var output = rewriter!.Visit(tree.GetRoot())!.ToFullString(); + StringAssert.Contains("=>(long)value", output); + AssertParenthesesOperations(source, output, parseOptions: parse); + AssertConditionalParenthesesOperations(source, output); + } + + [TestCase("public object M(int value)=>((global::System.DateTimeOffset)default).ToString();", "((global::System.DateTimeOffset)default).ToString()")] + [TestCase("public int M()=>((System.Func)(()=>3))();", "((System.Func)(()=>3))()")] + [TestCase("public bool M(int value)=>Echo(((int)value)<1,0>1);static bool Echo(bool a,bool b)=>a&&b;", "Echo(((int)value)<1,0>1)")] + [TestCase("public object this[int value]=>value;public object M(int value)=>this[((int)value)];", "this[((int)value)]")] + public async Task StructuralParenthesesGrammarCastReceiverAndRelationalArgumentBarriers(string member, string expected) + { + var source = "class Clock{" + member + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralParenthesesGrammarConstructorInitializerRoleStaysOutsideSlice() + { + const string source = "class Parent{public Parent(int value){}}class Clock:Parent{public Clock(int value):base(((int)value)) {}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("base(((int)value))", output); + AssertParenthesesOperations(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralParenthesesGrammarFinalVisitedThisChildSurvivesRemovalOrRetention(bool receiver) + { + var source = "class Clock{private int Value;public object M()=>" + (receiver ? "((long)this.Value).ToString()" : "((long)this.Value)") + ";}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(receiver ? "((long)Value).ToString()" : "=>(long)Value", output); + AssertParenthesesOperations(source, output, normalizeThisReceiver: true); + } + + [TestCase("class Clock{int M(int v)=>((int)v);}", "class Clock{int M(int v)=>((short)v);}")] + [TestCase("class Clock{int M(int v)=>((int)v);}", "class Clock{int M(int v)=>((int)(v+1));}")] + [TestCase("class Clock{int M(int a,int b,int c)=>(a-b)-c;}", "class Clock{int M(int a,int b,int c)=>a-(b-c);}")] + public void StructuralParenthesesGrammarOperationOracleRejectsCastTargetOperandOrAssociationCorruption(string before, string after) + { + Assert.Throws(() => AssertParenthesesOperations(before, after)); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarTests.cs new file mode 100644 index 00000000000..94e6e0c90db --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("public int M(int value)=>((int)value);", "=> (int)value;")] + [TestCase("public int M(int value){return (((int)value));}", "return (int)value;")] + [TestCase("public int M(int value)=>Echo(((int)value));private int Echo(int value)=>value;", "Echo((int)value)")] + [TestCase("public long M(int value){var result=((long)value);return result;}", "result=(long)value;")] + [TestCase("public long M(int value,int other)=>((long)value)+other;", "=>(long)value+other;")] + [TestCase("public object M()=>((global::System.Exception)null);", "=>(global::System.Exception)null;")] + [TestCase("public object M()=>((global::System.DateTimeOffset)default);", "=>(global::System.DateTimeOffset)default;")] + [TestCase("public object M(int value)=>((global::Fixtures.Number)value);", "=>(global::Fixtures.Number)value;")] + public async Task StructuralParenthesesGrammarOuterCastGroupingPreservesConversion(string member, string expected) + { + // No writer-name provenance: this candidate is parentheses-only and + // must not resurrect deferred C3 type-name qualification changes. + const string number = "namespace Fixtures{public struct Number{public static explicit operator Number(int value)=>default;}}"; + var source = number + "class Clock{" + member + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + // The input arrow's spacing is preserved; only removed tokens change. + StringAssert.Contains(expected.Replace("=> ", "=>"), output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase("public object M(int value)=>(global::Fixtures.Number)(+value);", "(global::Fixtures.Number)(+value)")] + [TestCase("public int M(object value)=>((global::System.Exception)value).HResult;", "((global::System.Exception)value).HResult")] + [TestCase("public long M(int value)=>-((long)value);", "-((long)value)")] + [TestCase("public int M(int value=((int)0))=>value;", "value=((int)0)")] + [TestCase("public string M(int value)=>Capture(()=>{return((int)value);});static string Capture(System.Func f,[System.Runtime.CompilerServices.CallerArgumentExpression(\"f\")]string text=null)=>text;", "return((int)value)")] + public async Task StructuralParenthesesGrammarCastBarriersAndSourceObservationRemain(string member, string expected) + { + const string number = "namespace Fixtures{public struct Number{public static explicit operator Number(int value)=>default;}}"; + var source = number + "class Clock{" + member + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarVisibilityTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarVisibilityTests.cs new file mode 100644 index 00000000000..b270efaff08 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesGrammarVisibilityTests.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task StructuralParenthesesGrammarHiddenCastGroupRetainsEveryDelimiter() + { + const string source = "class Clock{public int M(int value){\n#line hidden\nreturn (((int)value));\n#line default\n}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("return (((int)value));", output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralParenthesesGrammarCastVisibilityIsPerOccurrenceAndOrderIndependent(bool hiddenFirst) + { + var source = "class Clock{public long M(int value){\n" + (hiddenFirst ? "#line hidden" : "#line default") + + "\nlong first=(((long)value));\n" + (hiddenFirst ? "#line default" : "#line hidden") + + "\nlong second=(((long)value));\n#line default\nreturn first+second;}}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(hiddenFirst ? "first=(((long)value))" : "first=(long)value", output); + StringAssert.Contains(hiddenFirst ? "second=(long)value" : "second=(((long)value))", output); + AssertParenthesesOperations(source, output); + AssertBoundaryTriviaIdentity(source, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs new file mode 100644 index 00000000000..0eee88ada4b --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("leftCoalesce", "(x ?? y) ?? z")] + [TestCase("delegateTarget", "((f))(1)")] + [TestCase("asFusion", "(x)as object")] + [TestCase("minusFusion", "x-(-y)")] + [TestCase("optionalDescendant", "value = (1 + (2))")] + [TestCase("relationalSibling", "Echo(a < b, (x + y))")] + public async Task StructuralParenthesesRetainsParserAndAncestralBarriers(string kind, string expected) + { + var member = kind switch + { + "leftCoalesce" => "public string M(string x, string y, string z) => (x ?? y) ?? z;", + "delegateTarget" => "public int M(System.Func f) => ((f))(1);", + "asFusion" => "public object M(object x) => (x)as object;", + "minusFusion" => "public int M(int x, int y) => x-(-y);", + "optionalDescendant" => "public int M(int value = (1 + (2))) => value;", + _ => "public int M(int a, int b, int x, int y) => Echo(a < b, (x + y)); private int Echo(bool condition, int value) => value;" + }; + var code = ParenthesesCode(member); + var output = await Reduce(code); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(code.Content, output); + } + + [TestCase("-")] + [TestCase("+")] + [TestCase("~")] + public async Task StructuralParenthesesCastGroupRemovalPreservesOperandBarrierAndNamespaceShortening(string unary) + { + var type = new CSharpType("Number", "Fixtures", true, false, null, Array.Empty(), true, true); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Fixtures { public struct Number { public static explicit operator Number(int value) => default; } } namespace Sample { public class Clock {"); + writer.WriteLine($"public object M(int value) => (({type})({unary}value));"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("=> (Number)(" + unary + "value)", output); + StringAssert.DoesNotContain("global::Fixtures.Number", output); + AssertParenthesesOperations(code.Content, output); + AssertBoundaryTriviaIdentity(code.Content, output); + } + + [Test] + public async Task StructuralParenthesesPreservesSourceCapturedByAnOuterLambdaCall() + { + var code = ParenthesesCode("public object M(int x, int y) => Capture(() => { return ((x + y)); }); private object Capture(System.Func value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text = \"\") => text;"); + var output = await Reduce(code); + StringAssert.Contains("return ((x + y))", output); + AssertParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralParenthesesPreservesReducedExtensionReceiverCapture() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public static class Extensions { public static object Capture(this Func value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text = \"\") => text; } public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Seed => default;"); + writer.WriteRawLine("public object M(int x, int y) => ((Func)(() => { return ((x + y)); })).Capture(); } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("return ((x + y))", output); + AssertParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralParenthesesUnknownDynamicCallRetainsArgumentText() + { + var code = ParenthesesCode("public object M(dynamic receiver, int x, int y) => receiver.Use((x + y));"); + var output = await Reduce(code); + StringAssert.Contains("Use((x + y))", output); + AssertParenthesesOperations(code.Content, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralParenthesesPreservesEveryDelimiterTriviaAndLaterLineNumber(string ending) + { + var member = "public int M(int x, int y) { var value = (/* outer open */" + ending + + "// inner open leading" + ending + "( /* inner open trailing */ x + y /* inner close leading */ ) // inner close trailing" + ending + + ") /* outer close trailing */; return Line() + value; } private int Line([System.Runtime.CompilerServices.CallerLineNumber] int line = 0) => line;"; + var code = ParenthesesCode(member); + var output = await Reduce(code); + var value = CSharpSyntaxTree.ParseText(output).GetRoot().DescendantNodes().OfType().Single(static variable => variable.Identifier.ValueText == "value"); + Assert.That(value.Initializer!.Value, Is.Not.TypeOf()); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertParenthesesOperations(code.Content, output); + } + + [Test] + public void StructuralParenthesesZeroWidthElasticTriviaDoesNotSeparateTokens() + { + var tree = CSharpSyntaxTree.ParseText("class Clock { int M(int x) { return(x); } }"); + var wrapper = tree.GetRoot().DescendantNodes().OfType().Single(); + var replacement = wrapper.Expression.WithLeadingTrivia(SyntaxFactory.ElasticMarker); + Assert.That(StructuralParenthesesRules.KeepsTokenBoundaries(wrapper, replacement), Is.False); + } + + [Test] + public async Task StructuralParenthesesEmptyMetadataCannotAuthorizeUnrecordedNameReduction() + { + const string source = "class Clock { object M() => ((global::System.DateTimeOffset.Now)); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> global::System.DateTimeOffset.Now;", output); + AssertParenthesesOperations(source, output); + } + + [TestCase("disabled")] + [TestCase("custom")] + [TestCase("conditional")] + [TestCase("headerProof")] + public async Task StructuralParenthesesEmptyMetadataRetainsConfigurationAndCustomGates(string mode) + { + if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + var conditional = mode == "conditional" ? "int N() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 0;\n#endif\n}" : ""; + var source = "class Clock { public bool M(int x, int y) => ((x == y)); " + conditional + " }"; + var extra = mode == "headerProof" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; + var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); + if (mode == "conditional") StringAssert.Contains("=> x == y", output); + else StringAssert.Contains("=> ((x == y))", output); + AssertParenthesesOperations(source, output); + } + + [Test] + public void StructuralParenthesesInvalidInputCannotCreateARewriteWithoutMetadata() + { + var tree = CSharpSyntaxTree.ParseText("class Clock { int M() => ((1 + )); }"); + Assert.That(tree.GetRoot().ContainsDiagnostics, Is.True); + var compilation = CSharpCompilation.Create("InvalidParentheses", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), Array.Empty()), Is.Null); + } + + [Test] + public async Task StructuralParenthesesLazyArgumentFactsAreRequestedOnlyForRealCandidates() + { + var code = ParenthesesCode("public int M(int value) => Echo(value); private int Echo(int value) => value;"); + await Reduce(code); + StringAssert.DoesNotContain("StructuralParentheses.ArgumentListScan", GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary()); + } + + [Test] + public async Task StructuralParenthesesSiblingCandidatesShareOneOriginalArgumentListFact() + { + var code = ParenthesesCode("public int M(int a, int b, int c, int d) => Echo((a + b), (c + d)); private int Echo(int x, int y) => x + y;"); + var output = await Reduce(code); + StringAssert.Contains("Echo(a + b, c + d)", output); + Assert.That(Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralParentheses.ArgumentListScan"), Is.EqualTo(1)); + AssertParenthesesOperations(code.Content, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesTests.cs new file mode 100644 index 00000000000..9683fffbda9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("condition", "if (x == y)")] + [TestCase("return", "return x == y;")] + [TestCase("arrow", "=> x == y;")] + [TestCase("local", "value = x + y;")] + [TestCase("leftAssociation", "=> x - y - z;")] + [TestCase("higherPrecedence", "=> x + y * z;")] + [TestCase("coalesceRight", "=> x ?? y ?? z;")] + [TestCase("argument", "Echo(x + y)")] + public async Task StructuralParenthesesNonReassociatingCasesKeepOperations(string kind, string expected) + { + var member = kind switch + { + "condition" => "public bool M(int x, int y) { if (((x == y))) return true; return false; }", + "return" => "public bool M(int x, int y) { return ((x == y)); }", + "arrow" => "public bool M(int x, int y) => ((x == y));", + "local" => "public int M(int x, int y) { var value = ((x + y)); return value; }", + "leftAssociation" => "public int M(int x, int y, int z) => (x - y) - z;", + "higherPrecedence" => "public int M(int x, int y, int z) => x + (y * z);", + "coalesceRight" => "public string M(string x, string y, string z) => x ?? (y ?? z);", + _ => "public int M(int x, int y) => Echo((x + y)); private int Echo(int value) => value;" + }; + var code = ParenthesesCode(member); + var output = await Reduce(code); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(code.Content, output); + AssertBoundaryTriviaIdentity(code.Content, output); + } + + [TestCase("rightAssociation", "x - (y - z)")] + [TestCase("integerReassociation", "x + (y + z)")] + [TestCase("floatingReassociation", "x + (y + z)")] + [TestCase("checkedReassociation", "x + (y + z)")] + [TestCase("plusFusion", "x+(+y)")] + [TestCase("conditionalAccess", "(value?.Trim()).Length")] + [TestCase("genericAmbiguity", "Echo((a < b), (c > d))")] + [TestCase("optionalDefault", "value = (1)")] + [TestCase("interpolation", "{(flag ? 1 : 2)}")] + [TestCase("stackalloc", "(stackalloc byte[8])")] + public async Task StructuralParenthesesUncertainGrammarAndReassociationRemain(string kind, string expected) + { + var member = kind switch + { + "rightAssociation" => "public int M(int x, int y, int z) => x - (y - z);", + "integerReassociation" => "public int M(int x, int y, int z) => x + (y + z);", + "floatingReassociation" => "public double M(double x, double y, double z) => x + (y + z);", + "checkedReassociation" => "public int M(int x, int y, int z) => checked(x + (y + z));", + "plusFusion" => "public int M(int x, int y) => x+(+y);", + "conditionalAccess" => "public int M(string value) => (value?.Trim()).Length;", + "genericAmbiguity" => "public bool M(int a, int b, int c, int d) => Echo((a < b), (c > d)); private bool Echo(bool x, bool y) => x && y;", + "optionalDefault" => "public int M(int value = (1)) => value;", + "interpolation" => "public string M(bool flag) => $\"value:{(flag ? 1 : 2)}\";", + _ => "public int M() { System.Span value = (stackalloc byte[8]); return value.Length; }" + }; + var code = ParenthesesCode(member); + var output = await Reduce(code); + StringAssert.Contains(expected, output); + AssertParenthesesOperations(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralParenthesesCapturedSourceAndExplicitOverride(bool explicitText) + { + var member = "public object M(int x, int y) => Capture((x + y)" + (explicitText ? ", \"manual\"" : "") + "); private object Capture(object value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text = \"\") => text;"; + var code = ParenthesesCode(member); + var output = await Reduce(code); + StringAssert.Contains(explicitText ? "Capture(x + y, \"manual\")" : "Capture((x + y))", output); + AssertParenthesesOperations(code.Content, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralParenthesesPreservesCommentsAndPhysicalLines(string ending) + { + var member = "public int M(int x, int y) { return ( /* before */ (" + ending + "x + y" + ending + ") /* after */ ); }"; + var code = ParenthesesCode(member); + var output = await Reduce(code); + var tree = CSharpSyntaxTree.ParseText(output); + Assert.That(tree.GetRoot().DescendantNodes().OfType().Single().Expression, Is.Not.TypeOf()); + AssertBoundaryTriviaIdentity(code.Content, output); + AssertParenthesesOperations(code.Content, output); + } + + [Test] + public async Task StructuralParenthesesWorksWithoutRecordedTypeReferences() + { + const string source = "namespace Sample { public class Clock { public bool M(int x, int y) => ((x == y)); } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> x == y;", output); + AssertParenthesesOperations(source, output); + } + + [Test] + public async Task StructuralParenthesesRetainsCastOperandAndSeparatesKeywordTokens() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public object M(object value) => ({typeof(Exception)})(value);"); + writer.WriteRawLine("public int N(int x, int y) { return((x + y)); } } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("(Exception)(value)", output); + StringAssert.Contains("return(", output); + AssertParenthesesOperations(code.Content, output); + } + + [Test] + public void StructuralParenthesesOracleDetectsChangedParenthesizedArrowOperator() + { + const string before = "class Clock { int M(int x, int y) => ((x - y)); }"; + const string after = "class Clock { int M(int x, int y) => ((x + y)); }"; + Assert.Throws(() => AssertParenthesesOperations(before, after)); + } + + [Test] + public void StructuralParenthesesOracleIgnoresOnlyExplicitArgumentSyntaxRepresentation() + { + const string before = "class Clock { int M(int x, int y) => Echo((x + y)); int Echo(int value) => value; }"; + const string after = "class Clock { int M(int x, int y) => Echo(x + y); int Echo(int value) => value; }"; + AssertParenthesesOperations(before, after); + } + + [Test] + public void StructuralParenthesesOracleDetectsChangedAssociation() + { + const string before = "class Clock { int M(int x, int y, int z) { return x - (y - z); } }"; + const string after = "class Clock { int M(int x, int y, int z) { return (x - y) - z; } }"; + Assert.Throws(() => AssertParenthesesOperations(before, after)); + } + + [Test] + public void StructuralParenthesesOracleDetectsChangedCapturedConstant() + { + const string prefix = "class Clock { object M(int x, int y) => Capture("; + const string suffix = "); object Capture(object value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text = \"\") => text; }"; + // Roslyn strips outer argument parentheses when synthesizing CAE text. + // These equivalent typeof values instead have genuinely different text. + Assert.Throws(() => AssertParenthesesOperations(prefix + "typeof(global::System.Int32)" + suffix, prefix + "typeof(int)" + suffix)); + } + + private static CodeFile ParenthesesCode(string member) => Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Seed => default;"); + writer.WriteRawLine(member); + writer.WriteRawLine("} }"); + }); + + private static void AssertParenthesesOperations(string before, string after, bool normalizeThisReceiver = false, CSharpParseOptions? parseOptions = null) + { + static string Member(ISymbol? symbol) => symbol == null ? "" : symbol.OriginalDefinition.GetDocumentationCommentId() + ":" + + StructuralTypeNameRuleOracleTests.TypeIdentity(symbol.ContainingType) + ":" + (symbol is IMethodSymbol method + ? string.Join(",", method.TypeArguments.Select(StructuralTypeNameRuleOracleTests.TypeIdentity)) : ""); + static string Conversion(Microsoft.CodeAnalysis.Operations.CommonConversion conversion) => conversion.Exists + ":" + conversion.IsIdentity + ":" + + conversion.IsNumeric + ":" + conversion.IsReference + ":" + conversion.IsUserDefined + ":" + Member(conversion.MethodSymbol); + string Fingerprint(IOperation operation) + { + if (operation is IParenthesizedOperation parenthesized) return Fingerprint(parenthesized.Operand); + var detail = operation switch + { + IBinaryOperation binary => binary.OperatorKind + ":" + binary.IsChecked + ":" + binary.IsLifted + ":" + Member(binary.OperatorMethod), + IUnaryOperation unary => unary.OperatorKind + ":" + unary.IsChecked + ":" + unary.IsLifted + ":" + Member(unary.OperatorMethod), + IConversionOperation conversion => conversion.IsChecked + ":" + conversion.IsTryCast + ":" + conversion.Conversion.IsIdentity + ":" + Member(conversion.OperatorMethod), + IInvocationOperation invocation => Member(invocation.TargetMethod), + ITypeOfOperation typeOf => StructuralTypeNameRuleOracleTests.TypeIdentity(typeOf.TypeOperand), + IArgumentOperation argument => argument.Parameter?.Ordinal + ":" + argument.Parameter?.RefKind + ":" + argument.ArgumentKind + ":" + + Conversion(argument.InConversion) + ":" + Conversion(argument.OutConversion), + IFieldReferenceOperation field => Member(field.Field), + IPropertyReferenceOperation property => Member(property.Property), + IParameterReferenceOperation parameter => parameter.Parameter.Ordinal + ":" + parameter.Parameter.Name, + ILocalReferenceOperation local => local.Local.Name, + IInstanceReferenceOperation instance => instance.ReferenceKind.ToString(), + _ => "" + }; + var constant = operation.ConstantValue.HasValue ? (operation.ConstantValue.Value?.GetType().FullName ?? "null") + ":" + + Convert.ToString(operation.ConstantValue.Value, System.Globalization.CultureInfo.InvariantCulture) : ""; + // Roslyn can report explicit source arguments implicit when their + // syntax is parenthesized. ArgumentKind/conversions/constant values, + // rather than this representation flag, prove argument semantics. + var implicitFlag = operation is IArgumentOperation { ArgumentKind: ArgumentKind.Explicit } || + normalizeThisReceiver && operation is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance } + ? false : operation.IsImplicit; + return operation.Kind + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(operation.Type) + ":" + implicitFlag + ":" + constant + ":" + detail + + "[" + string.Join("|", operation.ChildOperations.Select(Fingerprint)) + "]"; + } + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, parseOptions); + var compilation = CSharpCompilation.Create("ParenthesesOperationOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + return tree.GetRoot().DescendantNodes().OfType().Select(method => + model.GetOperation(method) ?? throw new InvalidOperationException("Method operation missing: " + method.Identifier.ValueText)) + .Select(Fingerprint).ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Normalized operation trees, conversions, calls and constants must remain identical"); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverCompositionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverCompositionTests.cs new file mode 100644 index 00000000000..09978375e0a --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverCompositionTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverComposesSeparatelyRecordedGenericChildrenWithoutChangingCapturedSource(bool captured) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock {"); + if (captured) writer.WriteLine($"public object M() => Capture({typeof(Comparer)}.Default);"); + else writer.WriteLine($"public object M() => {typeof(Comparer)}.Default;"); + writer.WriteRawLine("private object Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + Assert.That(code.TypeReferences!.Count, Is.EqualTo(2), "Receiver and generic child must both have independent writer provenance"); + var output = await Reduce(code); + StringAssert.Contains(captured + ? "Capture(global::System.Collections.Generic.Comparer.Default)" + : "=> Comparer.Default", output); + AssertStaticMemberBindings(code.Content, output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs new file mode 100644 index 00000000000..52b166976f3 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task StaticReceiverDoesNotConfuseAValueWithTheSameNamedType() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} M(System.DateTimeOffset DateTimeOffset, object System) => {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + }); + var tree = CSharpSyntaxTree.ParseText(code.Content); + var compilation = CSharpCompilation.Create("SameTypeValueControl", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var parameter = tree.GetRoot().DescendantNodes().OfType().First(); + var parameterSymbol = (IParameterSymbol)model.GetDeclaredSymbol(parameter)!; + var receiver = tree.GetRoot().DescendantNodes().OfType().Single(static member => member.Name.Identifier.ValueText == "Now").Expression; + var originalType = (INamedTypeSymbol)model.GetSymbolInfo(receiver).Symbol!; + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(parameterSymbol.Type, originalType), Is.True, + "Equal value type is deliberately not evidence of a type-bound replacement"); + var output = await Reduce(code); + StringAssert.Contains("public DateTimeOffset M(", output); + StringAssert.Contains("=> global::System.DateTimeOffset.Now", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverConstructionUsesTheOriginalLexicalTypeParameter() + { + var parameter = new CSharpType(typeof(StaticReceiverParameter<>).GetGenericArguments()[0]); + var comparer = new CSharpType(typeof(Comparer<>), new[] { parameter }); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public object M() => {comparer}.Default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Comparer.Default", output); + StringAssert.DoesNotContain("global::System.Collections.Generic.Comparer", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverSiblingLambdasHaveIndependentValueLookup() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public object M() {"); + writer.WriteRawLine("System.Func blocked = (DateTimeOffset, System) =>"); + writer.WriteLine($"{typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("System.Func first = () =>"); + writer.WriteLine($"{typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("System.Func last = () =>"); + writer.WriteLine($"{typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("return last(); } } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("blocked = (DateTimeOffset, System) =>\nglobal::System.DateTimeOffset.Now", output); + StringAssert.Contains("first = () =>\nDateTimeOffset.Now", output); + StringAssert.Contains("last = () =>\nDateTimeOffset.Now", output); + AssertStaticMemberBindings(code.Content, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task MultilineStandaloneStaticReceiverCannotShiftLaterCallerLineNumber(string ending) + { + var source = "using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() { object value = global::System." + ending + + "DateTimeOffset.Now; return Line(); } private int Line([CallerLineNumber] int line = 0) => line; } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .Single(static member => member.Name.Identifier.ValueText == "Now").Expression; + var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), typeof(DateTimeOffset)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains("global::System." + ending + "DateTimeOffset.Now", output); + AssertBoundaryTriviaIdentity(source, output); + AssertStaticMemberBindings(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverProvenanceRejectsMissingOrWrongRecordedTargets(bool wrongType) + { + const string source = "using System; namespace Sample { public class Clock { public object M() => global::System.DateTimeOffset.Now; } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .Single(static member => member.Name.Identifier.ValueText == "Now").Expression; + var span = TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End); + var sites = wrongType ? new[] { new TypeReferenceSite(span, typeof(TimeSpan)) } : Array.Empty(); + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("global::System.DateTimeOffset.Now", output); + AssertStaticMemberBindings(source, output); + } + + [Test] + public async Task StaticReceiverInternalTriviaAndNonReceiverFamiliesRemainUnchanged() + { + const string source = "using System; namespace Sample { public class Clock { public object M() => global::System./* keep */DateTimeOffset.Now; public string N() => nameof(global::System.DateTimeOffset.Now); public object P(object value) => (value); public object T() => this; } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .First(static member => member.Name.Identifier.ValueText == "Now").Expression; + var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), typeof(DateTimeOffset)); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains("global::System./* keep */DateTimeOffset.Now", output); + StringAssert.Contains("nameof(global::System.DateTimeOffset.Now)", output); + StringAssert.Contains("P(object value) => value", output); + StringAssert.Contains("=> this", output); + AssertBoundaryTriviaIdentity(source, output); + AssertStaticMemberBindings(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverNameofAndDynamicCallsRemainQualifiedWithoutConditionalGuard(bool dynamicCall) + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + if (dynamicCall) writer.WriteLine($"public object M(dynamic value) => {typeof(Convert)}.ToString(value);"); + else writer.WriteLine($"public string M() => nameof({typeof(DateTimeOffset)}.Now);"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains(dynamicCall ? "global::System.Convert.ToString(value)" : "nameof(global::System.DateTimeOffset.Now)", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverOwnerAliasPreservesNestedTypeAndMember() + { + var owner = new CSharpType("Owner", "Fixtures", false, false, null, Array.Empty(), true, false); + var box = new CSharpType("Box", "Fixtures", false, false, owner, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using O = Fixtures.Owner; namespace Fixtures { public class Owner { public class Box { public static int Value => 1; } } } namespace Sample { public class Clock { public class Box {} public class Owner {} public class Fixtures {}"); + writer.WriteLine($"public object M() => {box}.Value;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("O.Box.Value", output); + AssertStaticMemberBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverContextualIdentifierKeepsSerializedParseAndTerminalToken(bool escaped) + { + var token = escaped ? "@await" : "await"; + var method = escaped ? "public async Task M() { await Task.CompletedTask; " : "public object M() { "; + var source = "using System.Threading.Tasks; using Fixtures; namespace Fixtures { public class @await { public static object Value => null; } } namespace Sample { public class Clock { " + method + "return global::Fixtures." + token + ".Value; } } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .Single(static member => member.Name.Identifier.ValueText == "Value").Expression; + var original = CSharpCompilation.Create("ContextualOriginal", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(original.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty, + "The contextual-keyword fixture must be valid before any reduction"); + var type = new CSharpType("await", "Fixtures", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), type); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + if (escaped) StringAssert.Contains("@await.Value", output); + AssertStaticMemberBindings(source, output); + } + + [Test] + public void StaticReceiverInvalidAsyncIdentifierCannotCreateARewrite() + { + const string source = "namespace Fixtures { public class @await { public static object Value => null; } } namespace Sample { public class Clock { public async System.Threading.Tasks.Task M() { return global::Fixtures.await.Value; } } }"; + var tree = CSharpSyntaxTree.ParseText(source); + Assert.That(tree.GetDiagnostics().Any(static diagnostic => diagnostic.Id == "CS4003"), Is.True); + var compilation = CSharpCompilation.Create("InvalidReceiverInput", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .Single(static member => member.Name.Identifier.ValueText == "Value").Expression; + var type = new CSharpType("await", "Fixtures", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), type); + Assert.That(new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), new[] { site }), Is.Null); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverFieldKeywordCannotBecomeABackingFieldExpression(bool escaped) + { + var token = escaped ? "@field" : "field"; + var source = "using Fixtures; namespace Fixtures { public class @field { public static object Value => null; } } namespace Sample { public class Clock { public object P => global::Fixtures." + token + ".Value; } }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("FieldOriginal", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty); + var receiver = (MemberAccessExpressionSyntax)tree.GetRoot().DescendantNodes().OfType() + .Single(static member => member.Name.Identifier.ValueText == "Value").Expression; + var type = new CSharpType("field", "Fixtures", false, false, null, Array.Empty(), true, false); + var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), type); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); + StringAssert.Contains(escaped ? "=> @field.Value" : "=> global::Fixtures.field.Value", output); + AssertStaticMemberBindings(source, output); + } + + private sealed class StaticReceiverParameter { } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverTests.cs new file mode 100644 index 00000000000..cc97beed9d5 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("field")] + [TestCase("property")] + [TestCase("method")] + [TestCase("genericReceiver")] + [TestCase("methodGroup")] + [TestCase("explicitMethodArguments")] + public async Task StaticReceiverQualifierPreservesTheCompleteMemberAndCall(string kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + switch (kind) + { + case "field": writer.WriteLine($"public object M() => {typeof(Guid)}.Empty;"); break; + case "property": writer.WriteLine($"public object M() => {typeof(DateTimeOffset)}.Now;"); break; + case "method": writer.WriteLine($"public string M() => {typeof(Convert)}.ToString(42);"); break; + case "genericReceiver": writer.WriteLine($"public int M() => {typeof(Comparer)}.Default.Compare(1, 2);"); break; + case "methodGroup": writer.WriteLine($"public System.Func M() => {typeof(Convert)}.ToString;"); break; + default: writer.WriteLine($"public object M() => {typeof(Array)}.Empty();"); break; + } + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + var expected = kind switch + { + "field" => "Guid.Empty", "property" => "DateTimeOffset.Now", "method" => "Convert.ToString(42)", + "genericReceiver" => "Comparer.Default.Compare(1, 2)", "methodGroup" => "Convert.ToString;", _ => "Array.Empty()" + }; + StringAssert.Contains(expected, output); + StringAssert.DoesNotContain("global::", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverMissingMetadataRemainsQualified() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public object M() => {typeof(Enumerable)}.Empty();"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.Linq.Enumerable.Empty()", output); + Assert.That(Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralStaticReceivers.MetadataRejected"), Is.EqualTo(1)); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverNamespaceAliasRetainsTheFullMember() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using S = System; namespace Sample { public class Clock { public class System {} public class Convert {}"); + writer.WriteLine($"public string M() => {typeof(Convert)}.ToString(42);"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("S::Convert.ToString(42)", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task StaticReceiverDecisionsDoNotLeakAcrossNestedValueScopes() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock { public object M() {"); + writer.WriteLine($"object first = {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("{ object DateTimeOffset = 0; object System = 0;"); + writer.WriteLine($"object blocked = {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("}"); + writer.WriteLine($"object last = {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("return last; } } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("first = DateTimeOffset.Now", output); + StringAssert.Contains("blocked = global::System.DateTimeOffset.Now", output); + StringAssert.Contains("last = DateTimeOffset.Now", output); + AssertStaticMemberBindings(code.Content, output); + } + + [TestCase("parameter")] + [TestCase("lambda")] + [TestCase("query")] + public async Task StaticReceiverRejectsCompetingValuesAtTheActualPosition(string kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Linq; namespace Sample { public class Clock {"); + if (kind == "parameter") writer.WriteRawLine("public object M(object DateTimeOffset, object System) =>"); + else if (kind == "lambda") writer.WriteRawLine("public System.Func M(object System) => DateTimeOffset =>"); + else writer.WriteRawLine("public object M(object System) => from DateTimeOffset in new[] { 1 } select"); + writer.WriteLine($"{typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset.Now", output); + AssertStaticMemberBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StaticReceiverObservesOmittedCapturedSourceAndExplicitOverrides(bool explicitText) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock {"); + if (explicitText) writer.WriteLine($"public object M() => Capture({typeof(DateTimeOffset)}.Now, \"manual\");"); + else writer.WriteLine($"public object M() => Capture({typeof(DateTimeOffset)}.Now);"); + writer.WriteRawLine("private object Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.Contains(explicitText ? "Capture(DateTimeOffset.Now, \"manual\")" : "Capture(global::System.DateTimeOffset.Now)", output); + AssertStaticMemberBindings(code.Content, output); + } + + [Test] + public async Task ConditionalStaticReceiverProofDoesNotAuthorizeDynamicCallsOrNameof() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public string Name() => nameof({typeof(DateTimeOffset)}.Now);"); + writer.WriteLine($"public object Dynamic(dynamic value) => {typeof(Convert)}.ToString(value);"); + writer.WriteLine($"public object Ordinary() => {typeof(DateTimeOffset)}.Now;"); + writer.WriteRawLine("public int Conditional() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 0;\n#endif\n} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("nameof(global::System.DateTimeOffset.Now)", output); + StringAssert.Contains("global::System.Convert.ToString(value)", output); + StringAssert.Contains("Ordinary() => DateTimeOffset.Now", output); + AssertStaticMemberBindings(code.Content, output); + } + + private static void AssertStaticMemberBindings(string before, string after) + { + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("StaticReceiverOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var result = new List(); + foreach (var access in tree.GetRoot().DescendantNodes().OfType()) + { + var symbol = model.GetSymbolInfo(access).Symbol; + if (symbol is not (IMethodSymbol or IFieldSymbol or IPropertySymbol or IEventSymbol) || !symbol.IsStatic) continue; + var methodArguments = symbol is IMethodSymbol method ? string.Join(",", method.TypeArguments.Select(StructuralTypeNameRuleOracleTests.TypeIdentity)) : ""; + result.Add(symbol.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(symbol.ContainingType) + ":" + methodArguments); + } + return result.ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Static fields/properties/method groups and constructions must keep identity"); + AssertOperationBindings(before, after); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs new file mode 100644 index 00000000000..3ca660f3c51 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase("class Clock { private int Value; public ref int M() => ref this.Value; }")] + [TestCase("class Clock { private int Value; static void R(ref int x) { x++; } static void O(out int x) { x = 1; } static int I(in int x) => x; public int M() { R(ref this.Value); O(out this.Value); return I(in this.Value); } }")] + [TestCase("class Clock { private int storage; private ref int Value => ref storage; public ref int M() => ref this.Value; }")] + [TestCase("class Clock { private int storage; private ref readonly int Value => ref storage; public ref readonly int M() => ref this.Value; }")] + [TestCase("class Clock { private int Value; public int M() { ref int x = ref this.Value; x = 1; return this.Value; } }")] + [TestCase("class Clock { private readonly int Value = 1; public int M() => this.Value; }")] + [TestCase("class Clock { private volatile int Value; public int M() => this.Value; }")] + [TestCase("class Base { protected int Value; } class Clock(int seed) : Base { public int M() => this.Value; }")] + [TestCase("class Clock(int Value) { private int Value; public int M() => this.Value; }")] + [TestCase("struct Clock { private int Value; public Clock(int x) { this.Value = x; } public readonly int M() => this.Value; }")] + [TestCase("class Base { protected virtual T Value { get; set; } = default; } class Clock : Base { public T M() => this.Value; }")] + [TestCase("class Base { protected int Value; } class Clock : Base { protected new int Value; public int M() => this.Value; }")] + [TestCase("using static Holder; class Holder { public static int Value; } class Clock { private int Value; public int M() => this.Value; }")] + [TestCase("#nullable enable\nclass Base where T : class { protected T? Value; } class Clock : Base where T : class { public T? M() => this.Value; }")] + public async Task StructuralThisReceiverStorageAndConstructedOwnerIdentity(string source) + { + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.DoesNotContain("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase("public int M() { int first = this.Value; int Value = 1; return first + Value; }")] + [TestCase("public int M() { foreach (int Value in new[] { 1 }) { return this.Value; } return 0; }")] + [TestCase("public int M() { try { throw new System.Exception(); } catch (System.Exception Value) { return this.Value; } }")] + [TestCase("public int M(object x) { if (x is int Value) return this.Value; return 0; }")] + [TestCase("public int M() { int N(int Value) => this.Value; return N(1); }")] + [TestCase("public System.Func M() => delegate(int Value) { return this.Value; };")] + public async Task StructuralThisReceiverAdditionalLexicalShadowsStayQualified(string member) + { + var source = "class Clock { private int Value; " + member + " }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverAccessorValueCannotCaptureField() + { + const string source = "class Clock { private int value; public int P { set { this.value = value; } } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this.value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase(true)] + [TestCase(false)] + public async Task StructuralThisReceiverPrimaryConstructorOtherPartialStaysQualified(bool otherPartial) + { + const string body = "namespace Sample { public partial class Clock { public int M() => this.Value; } }"; + const string extra = "namespace Sample { public class Base { public int Value = 1; } public partial class Clock(int Value) : Base {} }"; + var source = otherPartial ? body : body + extra; + var output = await Reduce(new CodeFile(source, "Clock.cs"), otherPartial ? extra : null); + StringAssert.Contains("this.Value", output); + AssertThisMemberBindings(source, output, otherPartial ? extra : null); + var trees = otherPartial ? new[] { CSharpSyntaxTree.ParseText(output), CSharpSyntaxTree.ParseText(extra) } : new[] { CSharpSyntaxTree.ParseText(output) }; + var compilation = CSharpCompilation.Create("PrimaryWarningsAsErrors", trees, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithSpecificDiagnosticOptions( + new[] { new System.Collections.Generic.KeyValuePair("CS9179", ReportDiagnostic.Error) })); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + } + + [TestCase("public dynamic M(dynamic d) => this.Value + d;")] + [TestCase("public dynamic M(dynamic d) => d + this.Value;")] + [TestCase("public dynamic M(dynamic d) => this.Value += d;")] + [TestCase("public dynamic M(dynamic d) => d = this.Value;")] + [TestCase("public dynamic M(dynamic d) => d.Call(this.Value);")] + [TestCase("public dynamic M(dynamic d) => d[this.Value];")] + public async Task StructuralThisReceiverDynamicParticipatingContextsStayQualified(string member) + { + var source = "class Clock { private int Value; " + member + " }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase("this/*a*/.Value")] + [TestCase("this./*a*/Value")] + [TestCase("this .Value")] + [TestCase("this. Value")] + [TestCase("this\r\n.Value")] + [TestCase("this\n.Value")] + public async Task StructuralThisReceiverInternalTokenTriviaAndLineEndingsRetained(string access) + { + var source = "class Clock { private int Value; public int M() => " + access + "; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(access, output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverHiddenLineDoesNotSuppressVisibleSiblingProof() + { + const string source = "class Clock { private int Value;\n#line hidden\npublic int M() => this.Value;\n#line default\npublic int N() => this.Value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("M() => this.Value", output); + StringAssert.Contains("N() => Value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase("Object")] + [TestCase("@Object")] + [TestCase("field")] + public async Task StructuralThisReceiverContextualAndObjectWorkaroundRemain(string identifier) + { + var source = "class Clock { private int " + identifier + "; public int M() => this." + identifier + "; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this." + identifier, output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverEscapedFieldAndBoundaryTriviaSurviveComposition() + { + const string source = "class Clock { private int @field; public int M() { return(/*a*/this.@field/*b*/); } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.DoesNotContain("this.@field", output); + StringAssert.Contains("@field", output); + Assert.That(output.Count(c => c == '\n'), Is.EqualTo(source.Count(c => c == '\n'))); + Assert.That(output.Contains("/*a*/") && output.Contains("/*b*/"), Is.True); + AssertThisMemberBindings(source, output); + } + + [TestCase("public int M() => (this.Callback)(1);")] + [TestCase("public int M() => ((this.Callback))(1);")] + [TestCase("public int M() => this.Callback(1);")] + public async Task StructuralThisReceiverDelegateInvocationParserBoundariesStayQualified(string member) + { + var source = "class Clock { private System.Func Callback = x => x; " + member + " }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this.Callback", output); + AssertThisMemberBindings(source, output); + } + + [TestCase("disabled")] + [TestCase("custom")] + [TestCase("conditional")] + [TestCase("headerProof")] + public async Task StructuralThisReceiverEmptyMetadataRespectsWorkspaceGates(string mode) + { + if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + var conditional = mode == "conditional" ? "\n#if NET8_0_OR_GREATER\nint N() => 8;\n#endif\n" : ""; + var source = "class Clock { private int Value; public int M() => this.Value; " + conditional + " }"; + var extra = mode == "headerProof" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; + var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); + StringAssert.Contains("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public void StructuralThisReceiverPrimaryConstructorDiagnosticChangesDespiteEqualBinding() + { + const string source = "namespace Sample { public partial class Clock { public int M() => this.Value; } }"; + const string extra = "namespace Sample { public class Base { public int Value = 1; } public partial class Clock(int Value) : Base {} }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("PrimaryLookup", new[] { tree, CSharpSyntaxTree.ParseText(extra) }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var member = tree.GetRoot().DescendantNodes().OfType().Single(); + var model = compilation.GetSemanticModel(tree); + var replacement = SyntaxFactory.IdentifierName(((IdentifierNameSyntax)member.Name).Identifier); + var info = model.GetSpeculativeSymbolInfo(member.SpanStart, replacement, SpeculativeBindingOption.BindAsExpression); + Assert.That(info.Symbol, Is.InstanceOf()); + var changed = CSharpSyntaxTree.ParseText(source.Replace("this.Value", "Value")); + var after = compilation.ReplaceSyntaxTree(tree, changed); + var value = changed.GetRoot().DescendantNodes().OfType().Last(); + var actual = after.GetSemanticModel(changed).GetSymbolInfo(value); + Assert.That(actual.Symbol, Is.InstanceOf()); + Assert.That(actual.Symbol!.GetDocumentationCommentId(), Is.EqualTo(info.Symbol!.GetDocumentationCommentId())); + Assert.That(compilation.GetDiagnostics().Any(static d => d.Id == "CS9179"), Is.False); + Assert.That(after.GetDiagnostics().Any(static d => d.Id == "CS9179"), Is.True, + "Standalone symbol equality cannot prove diagnostic equivalence"); + } + + [Test] + public void StructuralThisReceiverOracleAcceptsOnlyEquivalentContainingInstance() + { + const string prefix = "class Clock { private int Value; public int M(Clock other, int value) => "; + AssertThisMemberBindings(prefix + "this.Value; }", prefix + "Value; }"); + Assert.Throws(() => AssertThisMemberBindings(prefix + "this.Value; }", prefix + "other.Value; }")); + Assert.Throws(() => AssertThisMemberBindings(prefix + "this.Value; }", prefix + "value; }")); + Assert.Throws(() => AssertThisMemberBindings(prefix + "this.Value + 1; }", prefix + "Value - 1; }")); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSourceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSourceTests.cs new file mode 100644 index 00000000000..fa2945949f1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSourceTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false)] + [TestCase(true)] + public async Task StructuralThisReceiverOuterCaptureProtectsNestedLambda(bool explicitOverride) + { + var source = "using System; using System.Runtime.CompilerServices; class Clock { private int Value; " + + "static int Inner(int x) => x; static string Capture(Func f, [CallerArgumentExpression(\"f\")] string text = null) => text; " + + "public string M() => Capture(() => Inner(this.Value)" + (explicitOverride ? ", \"manual\"" : "") + "); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + Assert.That(output.Contains("this.Value"), Is.EqualTo(!explicitOverride)); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverUnrelatedCapturedArgumentCanReduce() + { + const string source = "using System.Runtime.CompilerServices; class Clock { private int Value; " + + "static int Capture(int observed, int other, [CallerArgumentExpression(\"observed\")] string text = null) => other; " + + "public int M() => Capture(1, this.Value); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("Capture(1, Value)", output); + AssertThisMemberBindings(source, output); + } + + [TestCase(true)] + [TestCase(false)] + public async Task StructuralThisReceiverExtensionReceiverSourceObservation(bool observesReceiver) + { + var source = "using System.Runtime.CompilerServices; static class Extensions { public static int Observe(this int receiver, int argument, " + + "[CallerArgumentExpression(\"" + (observesReceiver ? "receiver" : "argument") + "\")] string text = null) => receiver; } " + + "class Clock { private int Value; public int M() => this.Value.Observe(1); }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + Assert.That(output.Contains("this.Value"), Is.EqualTo(observesReceiver)); + AssertThisMemberBindings(source, output); + } + + [TestCase("\n")] + [TestCase("\r\n")] + public async Task StructuralThisReceiverMultilinePrefixPreservesLaterLineNumber(string newline) + { + var source = "using System.Runtime.CompilerServices; class Clock { private int Value; " + + "static int Line([CallerLineNumber] int line = 0) => line; public int M() { int x = this" + newline + ".Value; return x + Line(); } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this" + newline + ".Value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase(true)] + [TestCase(false)] + public async Task StructuralThisReceiverIndependentMethodScopeOrder(bool shadowFirst) + { + const string shadowed = "public int A(int Value) => this.Value; "; + const string unshadowed = "public int B() => this.Value; "; + var source = "class Clock { private int Value; " + (shadowFirst ? shadowed + unshadowed : unshadowed + shadowed) + "}"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("A(int Value) => this.Value", output); + StringAssert.Contains("B() => Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverNoTypeSitesDoesNotAuthorizeStaticOrNamespaceEdits() + { + const string source = "class Clock { private int Value; public int M() => this.Value + global::System.Int32.MaxValue; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.DoesNotContain("this.Value", output); + StringAssert.Contains("global::System.Int32.MaxValue", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public void StructuralThisReceiverOracleDetectsCallerSourceConstantCorruption() + { + const string source = "using System.Runtime.CompilerServices; class Clock { private int Value; " + + "static int Capture(int x, [CallerArgumentExpression(\"x\")] string text = null) => x; public int M() => Capture(this.Value); }"; + Assert.Throws(() => AssertThisMemberBindings(source, source.Replace("Capture(this.Value)", "Capture(Value)"))); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs new file mode 100644 index 00000000000..42e8f446da9 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task StructuralThisReceiverBoundDataMemberReadsAndWritesKeepIdentity(bool property, bool write) + { + var declaration = property ? "private int Value { get; set; }" : "private int Value;"; + var method = write ? "public void M(int value) { this.Value = value; }" : "public int M() => this.Value;"; + var source = "class Clock { " + declaration + method + " }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(write ? "{ Value = value; }" : "=> Value;", output); + StringAssert.DoesNotContain("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverInheritedAndConstructedMembersKeepIdentity() + { + const string source = "class Base { protected T Value; } class Clock : Base { public T M() => this.Value; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> Value;", output); + AssertThisMemberBindings(source, output); + } + + [TestCase("parameter")] + [TestCase("lambda")] + [TestCase("query")] + [TestCase("local")] + public async Task StructuralThisReceiverActualSiteValuesRetainQualification(string kind) + { + var method = kind switch + { + "parameter" => "public int M(int Value) => this.Value;", + "lambda" => "public System.Func M() => Value => this.Value;", + "query" => "public object M() => from Value in new[] { 1 } select this.Value;", + _ => "public int M() { int Value = 1; return this.Value; }" + }; + var source = "using System.Linq; class Clock { private int Value; " + method + " }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this.Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverPositionProofDoesNotLeakAcrossBlocks() + { + const string source = "class Clock { private int Value; public int M() { int first = this.Value; { int Value = 0; int blocked = this.Value; } int last = this.Value; return last; } }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("first = Value", output); + StringAssert.Contains("blocked = this.Value", output); + StringAssert.Contains("last = Value", output); + AssertThisMemberBindings(source, output); + } + + [TestCase(false)] + [TestCase(true)] + public async Task StructuralThisReceiverPreservesCaptureAndExplicitOverrides(bool explicitText) + { + var source = "class Clock { private int Value; public object M() => Capture(this.Value" + (explicitText ? ", \"manual\"" : "") + "); private object Capture(object value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string text = \"\") => text; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains(explicitText ? "Capture(Value, \"manual\")" : "Capture(this.Value)", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverRetainsMethodEventNameofDynamicAndInitializerBoundaries() + { + const string source = "class Clock { private int Value; private dynamic Data; private int Other() => 1; public event System.Action Changed; public void E() { this.Changed += () => {}; } public int M() => this.Other(); public object D() => this.Data; public string N() => nameof(this.Value); public object I() => new Clock { Value = this.Value }; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + foreach (var retained in new[] { "this.Other()", "this.Changed", "this.Data", "nameof(this.Value)", "Value = this.Value" }) StringAssert.Contains(retained, output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverConditionalDocumentRemainsConservative() + { + const string source = "class Clock { private int Value; public int M() => this.Value; public int C() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 0;\n#endif\n} }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("=> this.Value", output); + AssertThisMemberBindings(source, output); + } + + [Test] + public async Task StructuralThisReceiverDoesNotRemoveInternalCommentsOrPhysicalLines() + { + const string source = "class Clock { private int Value; public int M() => this./* keep */Value; public int N() => this.\nValue; }"; + var output = await Reduce(new CodeFile(source, "Clock.cs")); + StringAssert.Contains("this./* keep */Value", output); + StringAssert.Contains("this.\nValue", output); + AssertBoundaryTriviaIdentity(source, output); + AssertThisMemberBindings(source, output); + } + + [Test] + public void StructuralThisReceiverSpeculationRequiresDetachedReplacement() + { + const string source = "class Clock { private int Value; public int M(int Value) => this.Value; }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("ThisSpeculationProbe", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var member = tree.GetRoot().DescendantNodes().OfType().Single(); + var name = (IdentifierNameSyntax)member.Name; + var attached = model.GetSpeculativeSymbolInfo(member.SpanStart, name, SpeculativeBindingOption.BindAsExpression); + var replacement = SyntaxFactory.IdentifierName(name.Identifier).WithTriviaFrom(member); + var detached = model.GetSpeculativeSymbolInfo(member.SpanStart, replacement, SpeculativeBindingOption.BindAsExpression); + Assert.That(attached.Symbol, Is.InstanceOf(), "Attached member.Name is promoted to its original access"); + Assert.That(replacement.Parent, Is.Null); + Assert.That(detached.Symbol, Is.InstanceOf(), "Actual standalone replacement must observe lexical shadowing"); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(attached.Symbol, detached.Symbol), Is.False); + } + + [TestCase(false)] + [TestCase(true)] + public void StructuralThisReceiverSharedStaticMaterializerReturnsDetachedExpression(bool generic) + { + var source = "using System; using System.Collections.Generic; class Clock { object M() => " + + (generic ? "global::System.Collections.Generic.Comparer.Default" : "global::System.DateTimeOffset.Now") + "; }"; + var tree = CSharpSyntaxTree.ParseText(source); + var compilation = CSharpCompilation.Create("DetachedStaticMaterialization", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var model = compilation.GetSemanticModel(tree); + var member = tree.GetRoot().DescendantNodes().OfType().First(); + var receiver = member.Expression; + var target = (INamedTypeSymbol)model.GetSymbolInfo(receiver).Symbol!; + var description = StructuralTypeNameSelector.Describe(receiver); + var selected = StructuralTypeNameSelector.Choose(new[] { new StructuralTypeNameSelector.Context(compilation, model, receiver, target, + Source: description, PreserveTerminalArguments: true, ModeOverride: StructuralTypeNameSelector.Mode.Expression) }); + var expression = selected.Plan!.RenderExpression(description)!; + Assert.That(expression.Parent, Is.Null); + Assert.That(SymbolEqualityComparer.IncludeNullability.Equals(target, + model.GetSpeculativeSymbolInfo(receiver.SpanStart, expression, SpeculativeBindingOption.BindAsExpression).Symbol), Is.True); + } + + private static void AssertThisMemberBindings(string before, string after, string? additional = null) + { + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var trees = additional == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(additional) }; + var compilation = CSharpCompilation.Create("ThisMemberOracle", trees, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var values = new List(); + foreach (var expression in tree.GetRoot().DescendantNodes().OfType()) + { + var operation = model.GetOperation(expression); + if (operation == null || operation.Syntax.Span != expression.Span) continue; + var member = operation switch + { + IFieldReferenceOperation field => (ISymbol)field.Field, + IPropertyReferenceOperation property => property.Property, + _ => null + }; + if (member == null || member.IsStatic) continue; + var instance = ((IMemberReferenceOperation)operation).Instance; + var instanceIdentity = instance is IInstanceReferenceOperation receiver ? receiver.ReferenceKind.ToString() : instance?.Kind.ToString(); + values.Add(member.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(member.ContainingType) + ":" + + instanceIdentity + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(instance?.Type)); + } + return values.ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Exact data members and semantic containing-instance identities must survive"); + AssertParenthesesOperations(before + "\n" + additional, after + "\n" + additional, normalizeThisReceiver: true); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs new file mode 100644 index 00000000000..e1678d2df67 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs @@ -0,0 +1,461 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public partial class StructuralTypeNameIntegrationTests + { + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + private string? _previous; + private string _directory = null!; + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "structural"); + _directory = Path.Combine(Path.GetTempPath(), "typespec-structural", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true}"); + GeneratedCodeWorkspace.PostProcessingProfile = new GeneratedCodeWorkspacePostProcessingProfile(); + } + + [TearDown] + public void TearDown() + { + GeneratedCodeWorkspace.PostProcessingProfile = null; + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + Directory.Delete(_directory, recursive: true); + } + + [Test] + public void StructuralModeRecordsExistingTypedReferenceMetadata() + { + using var writer = new CodeWriter(); + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + writer.ToString(); + Assert.That(writer.TypeReferences, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task TypeOnlyNamesReduceDuringExistingMemberRemovalButOtherFamiliesStayUntouched() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Empty { }"); + writer.WriteRawLine("namespace Sample {"); + writer.WriteLine($"[{typeof(ObsoleteAttribute)}]"); + writer.WriteRawLine("public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteLine($"public {typeof(TimeSpan)} Echo({typeof(TimeSpan)} value) => value;"); + writer.WriteLine($"public object Snapshot() => {typeof(DateTimeOffset)}.Now;"); + writer.WriteLine($"public string Name() => nameof({typeof(DateTimeOffset)});"); + writer.WriteLine($"public System.Type Kind() => typeof({typeof(DateTimeOffset)});"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("namespace Empty", output); + StringAssert.Contains("public DateTimeOffset Timestamp", output); + StringAssert.Contains("TimeSpan Echo(TimeSpan value)", output); + StringAssert.Contains("Snapshot() => DateTimeOffset.Now", output); + StringAssert.Contains("nameof(global::System.DateTimeOffset)", output); + StringAssert.Contains("typeof(DateTimeOffset)", output); + StringAssert.Contains("[Obsolete]", output); + var profile = GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(); + StringAssert.Contains("StructuralTypeNames", profile); + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", profile); + StringAssert.DoesNotContain("ProviderReducer.Reduce", profile); + Assert.AreEqual(1, Count(profile, "MemberRemoverRewriter")); + Assert.AreEqual(1, Count(profile, "StructuralTypeNames.Visit")); + Assert.AreEqual(3, Count(profile, "StructuralTypeNames.Region")); + Assert.AreEqual(3, Count(profile, "StructuralTypeNames.Decision")); + Assert.AreEqual(1, Count(profile, "StructuralTypeNames.CacheHit")); + } + + [Test] + public async Task MethodTypeParameterDoesNotPolluteSiblingDeclarationScope() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Echo({typeof(DateTimeOffset)} value) => value;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("public DateTimeOffset Timestamp", output); + // A safe namespace-qualified form is preferable to unnecessarily + // retaining global::. The rule oracle already pins this selection. + StringAssert.Contains("System.DateTimeOffset Echo(System.DateTimeOffset value)", output); + StringAssert.Contains("StructuralTypeNames.Visit", GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary()); + } + + [Test] + public async Task MissingMetadataKeepsNamesWithoutFallingBackToRoslyn() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(new CodeFile(code.Content, code.Name)); + StringAssert.Contains("global::System.DateTimeOffset", output); + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary()); + } + + [Test] + public async Task DecisionCacheCannotRewriteAReferenceWhoseRecordedIdentityIsWrong() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} First => default;"); + writer.WriteLine($"public {typeof(TimeSpan)} Second => default;"); + writer.WriteRawLine("} }"); + }); + var references = code.TypeReferences!.Select(static site => site.Type.Name == "TimeSpan" ? site with { Type = (CSharpType)typeof(DateTimeOffset) } : site).ToArray(); + var output = await Reduce(new CodeFile(code.Content, code.Name, references)); + StringAssert.Contains("DateTimeOffset First", output); + StringAssert.Contains("global::System.TimeSpan Second", output); + } + + [Test] + public async Task InvalidSpanCannotRewriteAnUnprovenName() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + var invalid = code.TypeReferences!.Select(static site => site with { Span = new Microsoft.CodeAnalysis.Text.TextSpan(site.Span.Start + 1, site.Span.Length) }).ToArray(); + var output = await Reduce(new CodeFile(code.Content, code.Name, invalid)); + StringAssert.Contains("global::System.DateTimeOffset", output); + } + + [Test] + public async Task CustomRewriterRetainsItsSemanticContractWithoutStructuralOrRoslynReduction() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + var probe = new Probe(); + CodeModelGenerator.Instance.AddRewriter(probe); + var output = await Reduce(code); + Assert.True(probe.SawModel); + StringAssert.Contains("global::System.DateTimeOffset", output); + StringAssert.DoesNotContain("Roslyn.Simplifier.ReduceAsync", GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary()); + } + + [Test] + public async Task LocalFunctionNestedTypeAndDelegateOwnIndependentTypeParameterScopes() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} M() {{"); + writer.WriteLine($"{typeof(DateTimeOffset)} first = default;"); + writer.WriteLine($"{typeof(DateTimeOffset)} Local({typeof(DateTimeOffset)} value) => value;"); + writer.WriteLine($"{typeof(DateTimeOffset)} last = default;"); + writer.WriteRawLine("return first; }"); + writer.WriteLine($"public delegate {typeof(DateTimeOffset)} Factory({typeof(DateTimeOffset)} value);"); + writer.WriteRawLine("public class Inner {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("DateTimeOffset first", output); + StringAssert.Contains("DateTimeOffset last", output); + StringAssert.Contains("System.DateTimeOffset Local(System.DateTimeOffset value)", output); + StringAssert.Contains("System.DateTimeOffset Factory(System.DateTimeOffset value)", output); + StringAssert.Contains("System.DateTimeOffset Timestamp", output); + } + + [Test] + public async Task SameSimpleMetadataNameInDifferentNamespacesHasDistinctDecisions() + { + var a = new CSharpType("Widget", "A", false, false, null, Array.Empty(), true, false); + var b = new CSharpType("Widget", "B", false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace A { public class Widget {} } namespace B { public class Widget {} }"); + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {a} First => default;"); + writer.WriteLine($"public {b} Second => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("A.Widget First", output); + StringAssert.Contains("B.Widget Second", output); + } + + [Test] + public async Task ConcurrentDocumentsHaveIndependentTraversalAndSemanticState() + { + var a = Recorded(writer => + { + writer.WriteRawLine("namespace A { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + var b = Recorded(writer => + { + writer.WriteRawLine("namespace B { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(a); + await workspace.AddGeneratedFile(new CodeFile(b.Content, "Other.cs", b.TypeReferences)); + var output = new Dictionary(); + await foreach (var file in workspace.GetGeneratedFilesAsync()) output.Add(file.Name, file.Text); + StringAssert.Contains("System.DateTimeOffset Timestamp", output["Clock.cs"]); + StringAssert.Contains("public DateTimeOffset Timestamp", output["Other.cs"]); + AssertBindings(a.Content, output["Clock.cs"]); + AssertBindings(b.Content, output["Other.cs"]); + } + + [Test] + public async Task GenericContainingOwnerIsOutsideTheFirstSlice() + { + const string source = "namespace Sample { public class Outer { public class Inner {} } public class Clock { public global::Sample.Outer.Inner Value => default; } }"; + var outer = new CSharpType("Outer", "Sample", false, false, null, new CSharpType[] { typeof(int) }, true, false); + var inner = new CSharpType("Inner", "Sample", false, false, outer, Array.Empty(), true, false); + var node = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType().Single().Type; + var code = new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(node.Span, inner) }); + StringAssert.Contains("global::Sample.Outer.Inner Value", await Reduce(code)); + } + + [Test] + public async Task ExternalConditionalHeaderKeepsOrdinaryDocumentQualified() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public partial class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + var extra = "namespace Sample { public partial class Clock {\n#if NET6_0_OR_GREATER\npublic class DateTimeOffset {}\n#endif\n} }"; + StringAssert.Contains("global::System.DateTimeOffset Timestamp", await Reduce(code, extra)); + } + + [Test] + public async Task DisabledReductionKeepsNamesAndDoesNotStartStructuralSelection() + { + MockHelpers.LoadMockGenerator(outputPath: _directory, + configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("} }"); + }); + StringAssert.Contains("global::System.DateTimeOffset Timestamp", await Reduce(code)); + StringAssert.DoesNotContain("StructuralTypeNames.Visit", GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary()); + } + + [TestCase(false)] + [TestCase(true)] + public void ActualOutputOracleDetectsChangedLocalOrDelegateReturnType(bool delegateType) + { + var source = delegateType + ? "namespace Sample { public delegate global::System.DateTimeOffset Factory(); }" + : "namespace Sample { public class Clock { public void M() { global::System.DateTimeOffset value = default; } } }"; + var changed = source.Replace("DateTimeOffset", "TimeSpan", StringComparison.Ordinal); + Assert.Throws(() => AssertBindings(source, changed), + "The output oracle must not silently omit these declaration types"); + } + + [TestCase(false)] + [TestCase(true)] + public async Task DeclarationGenericArgumentSupportAndContainingSegmentLimitAreExplicit(bool containingSegment) + { + var source = containingSegment + ? "using System; namespace Sample { public class Outer { public class Inner {} } public class Clock { public global::Sample.Outer.Inner Value => default; } }" + : "using System; using System.Collections.Generic; namespace Sample { public class Clock { public List Value => default; } }"; + var site = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType() + .Single(static node => node.ToString() == "global::System.DateTimeOffset"); + var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(site.Span, typeof(DateTimeOffset)) })); + if (containingSegment) StringAssert.Contains("Outer.Inner", output); + else StringAssert.Contains("List", output); + } + + [Test] + public async Task GenericDeclarationArgumentsIgnoreBlockLocalValueShadowing() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System.Collections.Generic; namespace Sample { public class Clock { public void M() {"); + writer.WriteLine($"List<{typeof(DateTimeOffset)}> first = default;"); + writer.WriteRawLine("{ int DateTimeOffset = 0;"); + writer.WriteLine($"List<{typeof(DateTimeOffset)}> second = default;"); + writer.WriteRawLine("}"); + writer.WriteLine($"List<{typeof(DateTimeOffset)}> last = default;"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + StringAssert.Contains("List second", output); + Assert.AreEqual(2, Count(GeneratedCodeWorkspace.PostProcessingProfile!.GetSummary(), "StructuralTypeNames.CacheHit")); + } + + [Test] + public async Task ExistingAliasAndNongenericNestedOwnerUseTheProductionSelector() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using DTO = System.DateTimeOffset; namespace Sample { public class DateTimeOffset {} public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteLine($"public {typeof(Environment.SpecialFolder)} Folder => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("DTO Timestamp", output); + StringAssert.Contains("Environment.SpecialFolder Folder", output); + } + + [TestCase(0)] // Embedded comment. + [TestCase(1)] // Wrong provenance role. + [TestCase(2)] // Duplicate exact span. + [TestCase(3)] // Nonglobal original root. + public async Task LaterUnprovenOccurrenceCannotReuseACleanCachedDecision(int kind) + { + var second = kind == 0 ? "global::System./*keep*/DateTimeOffset" : kind == 3 ? "System.DateTimeOffset" : "global::System.DateTimeOffset"; + var source = "using System; namespace Sample { public class Clock { public global::System.DateTimeOffset First => default; public " + second + " Second => default; } }"; + var properties = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType().ToArray(); + var sites = new List + { + new(properties[0].Type.Span, typeof(DateTimeOffset)), + new(properties[1].Type.Span, typeof(DateTimeOffset), kind == 1 ? TypeReferenceRole.WholeTypeCref : TypeReferenceRole.Code) + }; + if (kind == 2) sites.Add(sites[1]); + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); + StringAssert.Contains("DateTimeOffset First", output); + StringAssert.Contains(second + " Second", output); + } + + [Test] + public async Task RecordedReferencesInExcludedContextsRemainUnchangedAfterADeclarationDecision() + { + var code = Recorded(writer => + { + writer.WriteRawLine("namespace Sample { public sealed class HolderAttribute : global::System.Attribute { public HolderAttribute(global::System.Type type) {} }"); + writer.WriteLine($"[Holder(typeof({typeof(Exception)}))]"); + writer.WriteLine($"public class Clock : {typeof(Exception)} {{"); + writer.WriteLine($"public {typeof(Exception)} Value => default;"); + writer.WriteLine($"public object Cast(object value) => ({typeof(Exception)})value;"); + writer.WriteLine($"public object Create() => new {typeof(Exception)}();"); + writer.WriteLine($"public global::System.Type Kind() => typeof({typeof(Exception)});"); + writer.WriteLine($"public string Name() => nameof({typeof(Exception)});"); + writer.WriteLine($"public bool Same() => {typeof(Exception)}.ReferenceEquals(null, null);"); + writer.WriteLine($"public void Call() => Use<{typeof(Exception)}>();"); + writer.WriteRawLine("private void Use() {} }"); + writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + StringAssert.Contains("Exception Value", output); + foreach (var supported in new[] { "Clock : Exception", "new Exception()", "typeof(Exception)", "Use()" }) + StringAssert.Contains(supported, output); + AssertOperationBindings(code.Content, output); + StringAssert.Contains("Same() => Exception.ReferenceEquals(null, null)", output); + foreach (var preserved in new[] { "Holder(typeof(global::System.Exception))", "(global::System.Exception)value", "nameof(global::System.Exception)" }) + StringAssert.Contains(preserved, output); + } + + private static int Count(string profile, string name) => int.Parse(profile.Split('\n').Single(line => line.StartsWith(name + ", ", StringComparison.Ordinal)).Split(',')[1]); + + private static CodeFile Recorded(Action emit) + { + // Allows the before-implementation run to test workspace behavior + // independently of the separate structural-mode metadata-capture gap. + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "true"); + try + { + using var writer = new CodeWriter(); + emit(writer); + return new CodeFile(writer.ToString(), "Clock.cs", writer.TypeReferences); + } + finally { Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "structural"); } + } + + private static async Task Reduce(CodeFile code, string? extra = null) + { + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); + await workspace.AddGeneratedFile(code); + if (extra != null) await workspace.AddGeneratedFile(new CodeFile(extra, "Additional.cs")); + string? result = null; + await foreach (var file in workspace.GetGeneratedFilesAsync()) if (file.Name == code.Name) result = file.Text; + Assert.NotNull(result); + AssertBindings(code.Content, result!, extra); + return result!; + } + + private static void AssertBindings(string before, string after, string? additional = null) + { + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source); + var trees = additional == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(additional) }; + var compilation = CSharpCompilation.Create("StructuralIntegrationOracle", trees, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + // Name substitutions and empty-namespace pruning preserve typed + // declaration order. Its ordinal is a stable structural key even + // for locals/local functions whose documentation IDs collide. + var result = new List(); + foreach (var node in tree.GetRoot().DescendantNodes().Where(static node => node is VariableDeclaratorSyntax or PropertyDeclarationSyntax or + MethodDeclarationSyntax or LocalFunctionStatementSyntax or ParameterSyntax or DelegateDeclarationSyntax)) + { + var symbol = model.GetDeclaredSymbol(node); + var type = symbol switch + { + IFieldSymbol field => field.Type, + ILocalSymbol local => local.Type, + IPropertySymbol property => property.Type, + INamedTypeSymbol { TypeKind: TypeKind.Delegate } @delegate => @delegate.DelegateInvokeMethod!.ReturnType, + IMethodSymbol method => method.ReturnType, + IParameterSymbol parameter => parameter.Type, + _ => null + }; + if (type == null) continue; + result.Add(result.Count + ":" + symbol!.Kind + ":" + symbol.Name + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(type)); + } + return result.ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Declaration type identities must survive actual workspace output"); + } + + private sealed class Probe : LibraryRewriter + { + internal bool SawModel { get; private set; } + public override SyntaxNode? VisitCompilationUnit(CompilationUnitSyntax node) + { + SawModel = SemanticModel != null; + return base.VisitCompilationUnit(node); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs new file mode 100644 index 00000000000..1fcb3891c65 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [Test] + public async Task TypeOnlyExpressionContextsUseStructuralNamesWithoutChangingOperations() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock {"); + writer.WriteLine($"public object Create() => new {typeof(Exception)}();"); + writer.WriteLine($"public Type Kind() => typeof({typeof(Exception)});"); + writer.WriteLine($"public object Zero() => default({typeof(Exception)});"); + writer.WriteLine($"public object Array() => new {typeof(Exception)}[1];"); + writer.WriteLine($"public object Cast(object value) => ({typeof(Exception)})(value);"); + writer.WriteLine($"public void Catch() {{ try {{ throw new Exception(); }} catch ({typeof(Exception)} error) {{ Console.WriteLine(error); }} }}"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + var tree = CSharpSyntaxTree.ParseText(code.Content); + var model = CSharpCompilation.Create("TypeContextBinding", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)).GetSemanticModel(tree); + var creationType = tree.GetRoot().DescendantNodes().OfType().First().Type; + Assert.That(model.GetSymbolInfo(creationType).Symbol, Is.InstanceOf(), "Creation names bind canonically through SymbolInfo; TypeInfo can be empty"); + StringAssert.DoesNotContain("global::System.Exception", output); + foreach (var syntax in new[] { "new Exception()", "typeof(Exception)", "default(Exception)", "new Exception[1]", "(Exception)(value)", "catch (Exception error)" }) + StringAssert.Contains(syntax, output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task BaseListAndMemberDeclarationDoNotShareDifferentLookupPositions() + { + var target = new CSharpType("Base", "N", false, false, null, System.Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using N; namespace N { public class Base {} } namespace Sample {"); + writer.WriteLine($"public class Clock : {target} {{"); + writer.WriteRawLine("public class Base {}"); + writer.WriteLine($"public {target} Value => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::N.Base", output); + StringAssert.Contains("N.Base Value", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task ConstraintTypeNamesPreserveGenericParameterCaptureGuards() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock {"); + writer.WriteLine($"public void M() where T : {typeof(Exception)} {{ }}"); + writer.WriteLine($"public void Other() where T : {typeof(Exception)} {{ }}"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("where T : System.Exception", output); + StringAssert.Contains("where T : Exception", output); + StringAssert.DoesNotContain("global::System.Exception", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task StableConditionalDocumentReducesOnlyActiveSyntaxAndPreservesEveryConfiguration() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock {"); + writer.WriteLine($"public {typeof(DateTimeOffset)} Timestamp => default;"); + writer.WriteRawLine("public object M() {"); + writer.WriteRawLine("#if NET8_0_OR_GREATER"); + writer.WriteLine($"{typeof(DateTimeOffset)} inactive = default;"); + writer.WriteRawLine("#else"); + writer.WriteLine($"{typeof(DateTimeOffset)} boundary = default;"); + writer.WriteLine($"{typeof(DateTimeOffset)} active = default;"); + writer.WriteRawLine("#endif"); + writer.WriteLine($"return typeof({typeof(DateTimeOffset)});"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("DateTimeOffset Timestamp", output); + StringAssert.DoesNotContain("global::System.DateTimeOffset Timestamp", output); + StringAssert.Contains("global::System.DateTimeOffset inactive", output); + StringAssert.Contains("global::System.DateTimeOffset boundary", output, "The directive-bearing occurrence keeps the existing trivia guard"); + StringAssert.Contains("DateTimeOffset active", output); + StringAssert.DoesNotContain("global::System.DateTimeOffset active", output); + static string[] DisabledText(string source) => CSharpSyntaxTree.ParseText(source).GetRoot().DescendantTrivia() + .Where(static trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static trivia => trivia.ToFullString()).ToArray(); + Assert.That(DisabledText(output), Is.EqualTo(DisabledText(code.Content)), "Inactive text must be byte-identical"); + foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER"), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER", "NET9_0_OR_GREATER") }) + AssertOperationBindings(code.Content, output, options); + } + + [Test] + public async Task ConditionalGenericLocalFunctionsConservativelyKeepTheDocumentQualified() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock { public object M() {"); + writer.WriteRawLine("#if NET8_0_OR_GREATER"); + writer.WriteLine($"object F() => typeof({typeof(DateTimeOffset)});"); + writer.WriteRawLine("#else"); + writer.WriteLine($"object F() => typeof({typeof(DateTimeOffset)});"); + writer.WriteRawLine("#endif"); + writer.WriteLine($"return new {typeof(Exception)}();"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("new global::System.Exception()", output); + Assert.AreEqual(2, output.Split("typeof(global::System.DateTimeOffset)").Length - 1); + foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER") }) + AssertOperationBindings(code.Content, output, options); + } + + [Test] + public async Task ConditionalGenericWrapperCannotChangeAnActiveSitesTypeParameterScope() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock { public void M() {"); + writer.WriteRawLine("#if NET6_0_OR_GREATER\nvoid Local() {\n#endif"); + writer.WriteLine($"{typeof(DateTimeOffset)} value = default;"); + writer.WriteRawLine("#if NET6_0_OR_GREATER\n}\n#endif\n} } }"); + }); + var tree = CSharpSyntaxTree.ParseText(code.Content); + var compilation = CSharpCompilation.Create("ConditionalWrapper", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.True(new ProviderConditionalSyntax(compilation).TryGetRoots(tree.GetRoot(), out _), "Existing body/header proof alone admits the wrapper"); + var output = await Reduce(code); + StringAssert.Contains("global::System.DateTimeOffset value", output); + var net6 = new CSharpParseOptions().WithPreprocessorSymbols("NET6_0_OR_GREATER"); + AssertOperationBindings(code.Content, output); + AssertOperationBindings(code.Content, output, net6); + var unsafeShortening = code.Content.Replace("global::System.DateTimeOffset value", "DateTimeOffset value"); + AssertOperationBindings(code.Content, unsafeShortening); + Assert.Throws(() => AssertOperationBindings(code.Content, unsafeShortening, net6)); + } + + [Test] + public async Task ClassConstraintAndBodyHaveDistinctLookupFactsAndDecisions() + { + var target = new CSharpType("Base", "N", false, false, null, System.Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using N; namespace N { public class Base {} } namespace Sample {"); + writer.WriteLine($"public class Clock where T : {target} {{"); + writer.WriteRawLine("public class Base {}"); + writer.WriteLine($"public {target} Value => default;"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::N.Base", output); + StringAssert.Contains("N.Base Value", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task TypeOnlyLookupIgnoresValuesButRespectsLocalFunctionTypeParametersAndRestoresScope() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock { public object M() { int Exception = 0;"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + writer.WriteLine($"object F() => new {typeof(Exception)}();"); + writer.WriteLine($"return new {typeof(Exception)}();"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Exception seed", output); + StringAssert.Contains("F() => new System.Exception()", output); + StringAssert.Contains("return new Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task ProtectedCastCacheHitDoesNotPermitAnUnprotectedCast() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public class Clock { public object M(object value) {"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + writer.WriteLine($"object protectedValue = ({typeof(Exception)})(value);"); + writer.WriteLine($"return ({typeof(Exception)})value;"); + writer.WriteRawLine("} } }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Exception seed", output); + StringAssert.Contains("(Exception)(value)", output); + StringAssert.Contains("(global::System.Exception)value", output); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task AttributeArgumentsCannotUseAnOrdinaryTypeofDecision() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public sealed class HolderAttribute : Attribute { public HolderAttribute(Type type) {} }"); + writer.WriteLine($"[Holder(typeof({typeof(Exception)}))] public class Clock {{"); + writer.WriteLine($"public Type M() => typeof({typeof(Exception)});"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Holder(typeof(global::System.Exception))", output); + StringAssert.Contains("typeof(Exception)", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + public async Task CallerExpressionTextIsPreservedAfterADeclarationDecision(int kind) + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M(object value) {"); + writer.WriteLine($"{typeof(Exception)} seed = default;"); + switch (kind) + { + case 0: writer.WriteLine($"return Capture(new {typeof(Exception)}());"); break; + case 1: writer.WriteLine($"return Capture(typeof({typeof(Exception)}));"); break; + case 2: writer.WriteLine($"return Capture(default({typeof(Exception)}));"); break; + case 3: writer.WriteLine($"return Capture(new {typeof(Exception)}[1]);"); break; + case 4: writer.WriteLine($"return Capture(({typeof(Exception)})(value));"); break; + case 5: writer.WriteLine($"return new Consumer(new {typeof(Exception)}());"); break; + case 6: writer.WriteLine($"return new {typeof(Exception)}().CaptureExtension();"); break; + default: writer.WriteLine($"return new {typeof(Exception)}()?.CaptureExtension();"); break; + } + writer.WriteRawLine("} private static string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; }"); + writer.WriteRawLine("public class Consumer { public Consumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} }"); + writer.WriteRawLine("public static class Extensions { public static string CaptureExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); + }); + var output = await Reduce(code); + StringAssert.DoesNotContain("global::System.Exception seed", output); + StringAssert.Contains("global::System.Exception", output, "Source-observable expression spelling must survive a warm method-region decision"); + AssertOperationBindings(code.Content, output); + } + + [Test] + public async Task ParameterAttributeCannotReuseTheMethodReturnTypesDecision() + { + var code = Recorded(writer => + { + writer.WriteRawLine("using System; namespace Sample { public sealed class HolderAttribute : Attribute { public HolderAttribute(Type value) {} } public class Clock {"); + writer.WriteLine($"public {typeof(Exception)} M([Holder(typeof({typeof(Exception)}))] int value) => new {typeof(Exception)}();"); + writer.WriteRawLine("} }"); + }); + var output = await Reduce(code); + StringAssert.Contains("Exception M(", output); + StringAssert.DoesNotContain("global::System.Exception M(", output); + StringAssert.Contains("Holder(typeof(global::System.Exception))", output); + StringAssert.Contains("new Exception()", output); + AssertOperationBindings(code.Content, output); + } + + [TestCase(false)] + [TestCase(true)] + public void OperationOracleRejectsChangedCompilerSuppliedCallerExpression(bool constructor) + { + const string helpers = "using System; using System.Runtime.CompilerServices; class Consumer { public Consumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } class C { static string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; public object M() => $CALL$; }"; + var expression = constructor ? "new Consumer(new global::System.Exception())" : "Capture(new global::System.Exception())"; + var before = helpers.Replace("$CALL$", expression); + Assert.Throws(() => AssertOperationBindings(before, before.Replace("global::System.Exception", "Exception"))); + } + + [TestCase("new string[1]", "new object[1]")] + [TestCase("typeof(string)", "typeof(object)")] + [TestCase("new Exception(\"value\")", "new Exception(\"value\", null)")] + [TestCase("default(string)", "default(object)")] + public void OperationOracleRejectsChangedTypeOrConstructor(string before, string after) + { + const string template = "using System; public class C { public object M() => $VALUE$; }"; + Assert.Throws(() => AssertOperationBindings(template.Replace("$VALUE$", before), template.Replace("$VALUE$", after))); + } + + private static void AssertOperationBindings(string before, string after, CSharpParseOptions? options = null) + { + static string? MethodIdentity(IMethodSymbol? method) => method == null ? null : + method.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(method.ContainingType) + ":" + + string.Join(",", method.TypeArguments.Select(StructuralTypeNameRuleOracleTests.TypeIdentity)); + static string ArgumentsIdentity(IEnumerable arguments) => string.Join("|", arguments.Select(static argument => + argument.Parameter?.Ordinal + ":" + argument.ArgumentKind + ":" + argument.IsImplicit + ":" + + (argument.Value.ConstantValue.HasValue ? argument.Value.ConstantValue.Value?.GetType().FullName + ":" + argument.Value.ConstantValue.Value : ""))); + string[] Snapshot(string source) + { + var tree = CSharpSyntaxTree.ParseText(source, options); + var compilation = CSharpCompilation.Create("StructuralOperationOracle", new[] { tree }, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var model = compilation.GetSemanticModel(tree); + var result = new List(); + foreach (var node in tree.GetRoot().DescendantNodes()) + { + var value = node switch + { + ObjectCreationExpressionSyntax creation => model.GetOperation(creation) is IObjectCreationOperation created ? MethodIdentity(created.Constructor) + ":" + ArgumentsIdentity(created.Arguments) : null, + ImplicitObjectCreationExpressionSyntax creation => model.GetOperation(creation) is IObjectCreationOperation implicitCreation ? MethodIdentity(implicitCreation.Constructor) + ":" + ArgumentsIdentity(implicitCreation.Arguments) : null, + ConstructorInitializerSyntax initializer => model.GetOperation(initializer) is IInvocationOperation initialized ? MethodIdentity(initialized.TargetMethod) + ":" + ArgumentsIdentity(initialized.Arguments) : null, + TypeOfExpressionSyntax typeOf => StructuralTypeNameRuleOracleTests.TypeIdentity((model.GetOperation(typeOf) as ITypeOfOperation)?.TypeOperand), + DefaultExpressionSyntax zero => StructuralTypeNameRuleOracleTests.TypeIdentity((model.GetOperation(zero) as IDefaultValueOperation)?.Type), + ArrayCreationExpressionSyntax array => StructuralTypeNameRuleOracleTests.TypeIdentity((model.GetOperation(array) as IArrayCreationOperation)?.Type), + CastExpressionSyntax cast => StructuralTypeNameRuleOracleTests.TypeIdentity((model.GetOperation(cast) as IConversionOperation)?.Type) + ":" + MethodIdentity((model.GetOperation(cast) as IConversionOperation)?.OperatorMethod), + CatchDeclarationSyntax caught => StructuralTypeNameRuleOracleTests.TypeIdentity(model.GetTypeInfo(caught.Type).Type), + SimpleBaseTypeSyntax @base => StructuralTypeNameRuleOracleTests.TypeIdentity(model.GetTypeInfo(@base.Type).Type), + TypeConstraintSyntax constraint => StructuralTypeNameRuleOracleTests.TypeIdentity(model.GetTypeInfo(constraint.Type).Type), + VariableDeclaratorSyntax variable => model.GetDeclaredSymbol(variable) is ILocalSymbol local ? StructuralTypeNameRuleOracleTests.TypeIdentity(local.Type) : null, + InvocationExpressionSyntax invocation => model.GetOperation(invocation) is IInvocationOperation operation ? MethodIdentity(operation.TargetMethod) + ":" + ArgumentsIdentity(operation.Arguments) : null, + _ => null + }; + if (value != null) result.Add(node.Kind() + ":" + value); + } + return result.ToArray(); + } + Assert.That(Snapshot(after), Is.EqualTo(Snapshot(before)), "Types, constructors, conversions and enclosing calls must retain binding"); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluator.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluator.cs new file mode 100644 index 00000000000..b31e59b5bf2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluator.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + // Only an adapter: the executable rules now live in the generator assembly. + // Candidate expectations and the recompilation oracle remain test-only. + internal static class StructuralTypeNameRuleEvaluator + { + internal static StructuralTypeNameSelector.Selection Evaluate(IReadOnlyList configurations) + => StructuralTypeNameSelector.Evaluate(configurations.Select(static input => new StructuralTypeNameSelector.Context( + input.Compilation, input.Model, input.Reference, + input.Reference == null ? null : StructuralTypeNameRuleOracleTests.ReferencedSymbol(input.Model, input.Reference) as INamedTypeSymbol)).ToArray()); + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs new file mode 100644 index 00000000000..4678ade5f4d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using NUnit.Framework; +using Scenario = Microsoft.TypeSpec.Generator.Tests.StructuralTypeNameRuleOracleTests.Scenario; +using Violation = Microsoft.TypeSpec.Generator.Tests.StructuralTypeNameRuleOracleTests.Violation; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + [NonParallelizable] + public class StructuralTypeNameRuleEvaluatorTests + { + private static readonly Dictionary ExpectedSelections = new(StringComparer.Ordinal) + { + ["R1_AmbiguousImports"] = "A.Widget", + ["R1_NearerTypeWinsOverImport"] = "System.DateTimeOffset", + ["R1_TypeParameterCapturesImportedType"] = "System.DateTimeOffset", + ["R2_DroppingOwnerSelectsOtherInner"] = "Outer.Inner", + ["R2_IntermediateOwnerAmbiguityMustNotDisappear"] = "A.Outer.Inner", + ["R3_NamespaceSegmentCapturedByType"] = "A.B.Widget", + ["R4_ExistingNamespaceAlias"] = "System.DateTimeOffset", + ["R4_AliasBindsAnotherType"] = "System.DateTimeOffset", + ["R4_InvisibleSiblingAliasIsNotACandidate"] = "System.DateTimeOffset", + ["R4_SameAliasSpellingInDifferentCompilation"] = "System.DateTimeOffset", + ["R5_FirstSegmentCaptured"] = "global::A.B.Widget", + ["R6_DroppingAritySelectsOtherDefinition"] = "Container", + ["R6_ChangingNullabilityArgumentRejected"] = "List", + ["R6_ChangingGenericArgumentRejected"] = "List", + ["R6_ChangingContainingConstructionRejected"] = "Outer.Inner", + ["R7_LocalValueCapturesReceiver"] = "System.DateTimeOffset", + ["R8_EqualNameofConstantIsInsufficient"] = "System.DateTimeOffset", + ["R8_TypeAliasCannotChangeNameofConstant"] = "System.DateTimeOffset", + ["R9_CrefTypeParameterCapture"] = "System.DateTimeOffset", + ["R10_KeywordCannotBeEmittedUnescaped"] = "@event", + ["R10_UnescapedAsyncReceiverRejected"] = "@await", + ["R11_ExternalPartialCollisionAcrossFrameworks"] = "System.DateTimeOffset", + ["R11_NameofConstraintInSecondConfiguration"] = "System.DateTimeOffset", + // This prototype retains instead of mapping heterogeneous type chains. + ["R11_ContainmentShapeChanges"] = "N.Inner", + ["R11_ProjectFlagMustNotBeAssumedAbsent"] = "System.DateTimeOffset" + }; + + public static IEnumerable SelectionCases() + { + foreach (var data in StructuralTypeNameRuleOracleTests.Scenarios()) + { + var scenario = (Scenario)data.Arguments[0]!; + if (!scenario.Id.StartsWith("R", StringComparison.Ordinal)) continue; + var expected = ExpectedSelections.TryGetValue(scenario.Id, out var alternative) ? alternative : scenario.Candidate; + yield return new TestCaseData(scenario, expected).SetName("Select_" + scenario.Id); + } + } + + [Test] + public void ScopeAndMemberFactsAreSharedAcrossCandidateForms() + { + var scenario = new Scenario("SharedFacts", "using S = Other.Wrong; namespace Other.Wrong { public class Anchor {} } namespace A.B { public class Widget {} } class Consumer { class A {} private $TYPE$ Value; }", + "global::A.B.Widget", "global::A.B.Widget", true); + var input = StructuralTypeNameRuleOracleTests.Prepare(scenario, scenario.Original, new CSharpParseOptions()); + var selection = StructuralTypeNameRuleEvaluator.Evaluate(new[] { input }); + Assert.AreEqual("global::A.B.Widget", selection.Name.ToString()); + Assert.AreEqual(1, selection.ScopeSnapshots); + Assert.AreEqual(5, selection.MemberSnapshots, "Five region/container fact views are shared; each requested member name is queried at most once"); + Assert.Greater(selection.CandidatesTried, selection.MemberSnapshots, "Multiple unsuccessful paths must reuse their container facts"); + new StructuralTypeNameRuleOracleTests().CandidateMeetsTheScenarioContract(scenario); + } + + [Test] + public void LookupFactsQueryOnlyRequestedNamesAndCacheAliasesSeparately() + { + var scenario = new Scenario("LazyFacts", "using System; using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DateTimeOffset", true); + var input = StructuralTypeNameRuleOracleTests.Prepare(scenario, scenario.Original, new CSharpParseOptions()); + var facts = new StructuralTypeNameSelector.LookupFacts(input.Model, input.Reference!.SpanStart); + var first = facts.Scope("DateTimeOffset", false); + Assert.AreSame(first, facts.Scope("DateTimeOffset", false)); + Assert.AreEqual(1, first.Length); + Assert.AreEqual(1, facts.RootQueries); + var system = input.Compilation.GlobalNamespace.GetNamespaceMembers().Single(static ns => ns.Name == "System"); + var members = facts.Members(system, "DateTimeOffset", false); + Assert.AreSame(members, facts.Members(system, "DateTimeOffset", false)); + Assert.AreEqual(1, members.Length); + Assert.AreEqual(1, facts.MemberQueries); + Assert.AreEqual("DTO", facts.Aliases().Single().Name); + Assert.AreEqual("DTO", facts.Aliases().Single().Name); + Assert.AreEqual(2, facts.RootQueries, "Alias discovery must not materialize the full visible scope"); + } + + [TestCase("T")] + [TestCase("int")] + public void NonNamedAndPredefinedInputsArePreservedWithoutLookup(string spelling) + { + var scenario = new Scenario("PreserveNonNamed", "class Consumer { private $TYPE$ Value; }", spelling, spelling, true); + var input = StructuralTypeNameRuleOracleTests.Prepare(scenario, spelling, new CSharpParseOptions()); + var selection = StructuralTypeNameRuleEvaluator.Evaluate(new[] { input }); + Assert.AreSame(input.Reference, selection.Name); + Assert.AreEqual(0, selection.ScopeSnapshots); + Assert.AreEqual(0, selection.MemberSnapshots); + Assert.AreEqual(StructuralTypeNameSelector.Retention.UnsupportedInput, selection.Reason); + } + + [TestCaseSource(nameof(SelectionCases))] + public void SelectsExpectedStructuralNameAndPassesIndependentOracle(Scenario scenario, string expected) + { + // The evaluator receives ONLY the originals and scope facts. Neither + // the manually supplied oracle candidate nor expected answer is input. + var configurations = (scenario.Configurations ?? new[] { Array.Empty() }) + .Select(symbols => StructuralTypeNameRuleOracleTests.Prepare(scenario, scenario.Original, + new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose, preprocessorSymbols: symbols))).ToArray(); + var selection = StructuralTypeNameRuleEvaluator.Evaluate(configurations); + var spelling = selection.Name.ToString(); + Assert.AreEqual(expected, spelling); + if (scenario.Id == "R11_ContainmentShapeChanges") + Assert.AreEqual(StructuralTypeNameSelector.Retention.ConfigurationShape, selection.Reason); + Assert.LessOrEqual(selection.ScopeSnapshots, 2 * configurations.Length, "At most one type and one expression scope snapshot per configuration"); + new StructuralTypeNameRuleOracleTests().CandidateMeetsTheScenarioContract(scenario with + { + Candidate = spelling, + ExpectedSafe = true, + RequiredViolation = Violation.None, + ExpectedPerConfiguration = null + }); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs new file mode 100644 index 00000000000..eb04f938ce1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + // Specification tests, not a test-only reducer. Each candidate is explicit. + // Roslyn independently checks whether that candidate preserves the contract. + // These tests do NOT establish that the production reducer selects it, that + // it is the shortest legal spelling, or that selection/emission is efficient. + [NonParallelizable] + public class StructuralTypeNameRuleOracleTests + { + private const string Marker = "$TYPE$"; + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static p => MetadataReference.CreateFromFile(p)).ToArray(); + private const string Imported = "using System; namespace Example { public class Consumer { private $TYPE$ Value; } }"; + + [Flags] + public enum Violation + { + None = 0, + Compilation = 1, + ReferenceIdentity = 2, + EnclosingOperation = 4, + Grammar = 8 + } + + public sealed record Scenario(string Id, string Template, string Original, string Candidate, bool ExpectedSafe, + Violation RequiredViolation = Violation.None, string? AdditionalSource = null, string[][]? Configurations = null, + bool[]? ExpectedPerConfiguration = null); + + public static IEnumerable Scenarios() + { + var cases = new[] + { + new Scenario("R1_CurrentNamespace", "namespace Example { public class Widget {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.Widget", "Widget", true), + new Scenario("R1_EnclosingNamespace", "namespace A { public class Widget {} namespace B { public class Consumer { private $TYPE$ Value; } } }", + "global::A.Widget", "Widget", true), + new Scenario("R1_OrdinaryImport", Imported, "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R1_GlobalImport", Imported.Replace("using System;", "global using System;"), + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R1_AmbiguousImports", "using A; using B; namespace A { public class Widget {} } namespace B { public class Widget {} } class Consumer { private $TYPE$ Value; }", + "global::A.Widget", "Widget", false, Violation.Compilation), + new Scenario("R1_NearerTypeWinsOverImport", "using System; namespace Example { public class DateTimeOffset {} public class Consumer { private $TYPE$ Value; } }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity), + new Scenario("R1_TypeParameterCapturesImportedType", "using System; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity), + new Scenario("Oracle_TypeParametersNeedOwnerContainment", "class Consumer { void F() { void F() { $TYPE$ value = default; } } }", + "T", "U", false, Violation.ReferenceIdentity), + new Scenario("R2_NestedOwnerOutsideOwner", "namespace Example { public class Outer { public class Inner {} } public class Consumer { private $TYPE$ Value; } }", + "global::Example.Outer.Inner", "Outer.Inner", true), + new Scenario("R2_IntermediateOwnerAmbiguityMustNotDisappear", "using A; using B; namespace A { public class Outer { public class Inner {} } } namespace B { public class Outer {} } class Consumer { private $TYPE$ Value; }", + "global::A.Outer.Inner", "Outer.Inner", false, Violation.Compilation), + new Scenario("R2_InsideNestedOwner", "namespace Example { public class Outer { public class Inner {} private $TYPE$ Value; } }", + "global::Example.Outer.Inner", "Inner", true), + new Scenario("R2_InheritedNestedType", "namespace Example { public class Base { protected class Inner {} } public class Consumer : Base { private $TYPE$ Value; } }", + "global::Example.Base.Inner", "Inner", true), + new Scenario("R2_DroppingOwnerSelectsOtherInner", "namespace Example { public class Outer { public class Inner {} } public class Inner {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.Outer.Inner", "Inner", false, Violation.ReferenceIdentity), + new Scenario("R3_PartialNamespacePath", "namespace A { namespace B { public class Widget {} } namespace C { public class Widget {} public class Consumer { private $TYPE$ Value; } } }", + "global::A.B.Widget", "B.Widget", true), + new Scenario("R3_NamespaceSegmentCapturedByType", "namespace A { namespace B { public class Widget {} } namespace C { public class B { public class Widget {} } public class Consumer { private $TYPE$ Value; } } }", + "global::A.B.Widget", "B.Widget", false, Violation.ReferenceIdentity), + new Scenario("R4_ExistingTypeAlias", "using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DTO", true), + new Scenario("R4_ExistingNamespaceAlias", "using S = System; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "S::DateTimeOffset", true), + new Scenario("R4_NamespaceAliasShorterThanNamespacePath", "using D = Very.Long.Space; namespace Very.Long.Space { public class Widget {} } class Consumer { private $TYPE$ Value; }", + "global::Very.Long.Space.Widget", "D::Widget", true), + new Scenario("R4_ExplicitTypeAliasPreservedOnTie", "using System; using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; }", + "DTO", "DTO", true), + new Scenario("R4_ExplicitNamespaceAliasPreservedOnTie", "using S = System; class Consumer { private $TYPE$ Value; }", + "S::DateTimeOffset", "S::DateTimeOffset", true), + new Scenario("R4_GlobalAliasInAnotherTree", "class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DTO", true, AdditionalSource: "global using DTO = System.DateTimeOffset;"), + new Scenario("R4_FileScopedNamespaceAlias", "namespace Example; using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DTO", true), + new Scenario("R4_NestedNamespaceAndSiblingAlias", "namespace Example { namespace Wrong { using DTO = System.TimeSpan; } namespace Right { using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; } } }", + "global::System.DateTimeOffset", "DTO", true), + new Scenario("R4_NearerAliasShadowsOuterAlias", "using DTO = System.TimeSpan; namespace Example { using DTO = System.DateTimeOffset; class Consumer { private $TYPE$ Value; } }", + "global::System.DateTimeOffset", "DTO", true), + new Scenario("R4_InvisibleSiblingAliasIsNotACandidate", "namespace Wrong { using DTO = System.DateTimeOffset; } namespace Right { class Consumer { private $TYPE$ Value; } }", + "global::System.DateTimeOffset", "DTO", false, Violation.Compilation), + new Scenario("R4_SameAliasSpellingInDifferentCompilation", "using DTO = System.TimeSpan; class Consumer { private $TYPE$ Value; }", + "global::System.DateTimeOffset", "DTO", false, Violation.ReferenceIdentity), + new Scenario("R4_AliasBindsAnotherType", "using DateTimeOffset = Example.Other; namespace Example { public class Other {} public class Consumer { private $TYPE$ Value; } }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity), + new Scenario("R5_RemoveGlobalRootOnly", "namespace A { namespace B { public class Widget {} } } namespace Example { public class Consumer { private $TYPE$ Value; } }", + "global::A.B.Widget", "A.B.Widget", true), + new Scenario("R5_FirstSegmentCaptured", "namespace A { namespace B { public class Widget {} } } namespace Example { public class A { public class B { public class Widget {} } } public class Consumer { private $TYPE$ Value; } }", + "global::A.B.Widget", "A.B.Widget", false, Violation.ReferenceIdentity), + new Scenario("R6_GenericArityPreserved", "namespace Example { public class Container {} public class Container {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.Container", "Container", true), + new Scenario("R6_DroppingAritySelectsOtherDefinition", "namespace Example { public class Container {} public class Container {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.Container", "Container", false, Violation.ReferenceIdentity), + new Scenario("R6_ChangingNullabilityArgumentRejected", "#nullable enable\nusing System.Collections.Generic; class Consumer { private $TYPE$ Value; }", + "global::System.Collections.Generic.List", "List", false, Violation.ReferenceIdentity), + new Scenario("R6_ChangingGenericArgumentRejected", "using System.Collections.Generic; class Consumer { private $TYPE$ Value; }", + "global::System.Collections.Generic.List", "List", false, Violation.ReferenceIdentity), + new Scenario("R6_ConstructedNestedOwner", "namespace Example { public class Outer { public class Inner {} } public class Consumer { private $TYPE$ Value; } }", + "global::Example.Outer.Inner", "Outer.Inner", true), + new Scenario("R6_ChangingContainingConstructionRejected", "namespace Example { public class Outer { public class Inner {} } public class Consumer { private $TYPE$ Value; } }", + "global::Example.Outer.Inner", "Outer.Inner", false, Violation.ReferenceIdentity), + new Scenario("R6_LocalTypeArgumentOwnerStableAcrossShortening", "namespace Example { public class Box {} public class Consumer { void F() { void Local() { $TYPE$ value = default; } } } }", + "global::Example.Box", "Box", true), + new Scenario("R6_ArrayWrapperUnchanged", "using System; class Consumer { private $TYPE$[] Value; }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R7_StaticReceiver", "using System; class Consumer { public object M() => $TYPE$.Now; }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R7_LocalValueCapturesReceiver", "using System; class Consumer { public object M() { object DateTimeOffset = null; return $TYPE$.Now; } }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.Compilation), + new Scenario("R7_LocalValueDoesNotBlockTypeOnlyLookup", "using System; class Consumer { public void M() { object DateTimeOffset = null; $TYPE$ value = default; } }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R7_LocalInSiblingMethodDoesNotBlockReceiver", "using System; class Consumer { public object M() => $TYPE$.Now; public void N() { int DateTimeOffset = 0; } }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R7_SelectedOverloadUnchanged", "using System; class Consumer { public string M() => $TYPE$.ToString(42); }", + "global::System.Convert", "Convert", true), + new Scenario("R8_NameofTypePrefix", "using System; class Consumer { public string M() => nameof($TYPE$.Now); }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R8_EqualNameofConstantIsInsufficient", "using System; class Consumer { const string DateTimeOffset = \"value\"; public string M() => nameof($TYPE$); }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity), + new Scenario("R8_TypeAliasCannotChangeNameofConstant", "using DTO = System.DateTimeOffset; class Consumer { public string M() => nameof($TYPE$); }", + "global::System.DateTimeOffset", "DTO", false, Violation.EnclosingOperation), + new Scenario("R8_TypeAliasPrefixPreservesMemberNameof", "using DTO = System.DateTimeOffset; class Consumer { public string M() => nameof($TYPE$.Now); }", + "global::System.DateTimeOffset", "DTO", true), + new Scenario("R9_WholeTypeCref", "using System; class Consumer {\n/// \npublic void M() {} }", + "global::System.DateTimeOffset", "DateTimeOffset", true), + new Scenario("R9_CrefTypeParameterCapture", "using System; class Consumer {\n/// \npublic void M() {} }", + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity), + new Scenario("R10_EscapedIdentifier", "namespace Example { public class @event {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.@event", "@event", true), + new Scenario("R10_KeywordCannotBeEmittedUnescaped", "namespace Example { public class @event {} public class Consumer { private $TYPE$ Value; } }", + "global::Example.@event", "event", false, Violation.Compilation), + new Scenario("R10_AsyncReceiverNeedsEscaping", "using System.Threading.Tasks; namespace Example { public class await { public static int Create() => 1; } public class Consumer { public async Task M() => $TYPE$.Create(); } }", + "global::Example.@await", "@await", true), + new Scenario("R10_UnescapedAsyncReceiverRejected", "using System.Threading.Tasks; namespace Example { public class await { public static int Create() => 1; } public class Consumer { public async Task M() => $TYPE$.Create(); } }", + "global::Example.@await", "await", false, Violation.Compilation), + new Scenario("R10_CastWithProtectedOperand", "namespace Example { public class T { public static explicit operator T(int x) => new T(); } public class Consumer { public T M(int x) => ($TYPE$)(-x); } }", + "global::Example.T", "T", true), + // Oracle control, NOT a proposed qualification reduction: these + // spellings denote the same type but do not preserve parsing. + new Scenario("Oracle_SameTypeDoesNotEstablishSameGrammar", "class Consumer { public int M(int x) => ($TYPE$)-x; }", + "int", "global::System.Int32", false, Violation.Grammar), + new Scenario("R11_ExternalPartialCollisionAcrossFrameworks", Imported.Replace("public class Consumer", "public partial class Consumer"), + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity, + "namespace Example { public partial class Consumer {\n#if NET6_0_OR_GREATER\npublic class DateTimeOffset {}\n#endif\n} }", + new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" } }, new[] { true, false }), + new Scenario("R11_NameofConstraintInSecondConfiguration", "using DTO = System.DateTimeOffset; class Consumer { public object M() =>\n#if TYPE\ntypeof\n#else\nnameof\n#endif\n($TYPE$); }", + "global::System.DateTimeOffset", "DTO", false, Violation.EnclosingOperation, + Configurations: new[] { new[] { "TYPE" }, Array.Empty() }, ExpectedPerConfiguration: new[] { true, false }), + new Scenario("R11_ContainmentShapeChanges", "#if NESTED\nusing static A.Outer; using N = A.Outer;\n#else\nusing B; using N = B;\n#endif\nnamespace A { public class Outer { public class Inner {} } } namespace B { public class Inner {} } class Consumer { private $TYPE$ Value; }", + "N.Inner", "Inner", true, + Configurations: new[] { new[] { "NESTED" }, Array.Empty() }, ExpectedPerConfiguration: new[] { true, true }), + new Scenario("R11_ProjectFlagMustNotBeAssumedAbsent", Imported.Replace("public class Consumer", "public partial class Consumer"), + "global::System.DateTimeOffset", "DateTimeOffset", false, Violation.ReferenceIdentity, + "namespace Example { public partial class Consumer {\n#if DEBUG\npublic class DateTimeOffset {}\n#endif\n} }", + new[] { Array.Empty(), new[] { "DEBUG" } }, new[] { true, false }) + }; + foreach (var scenario in cases) yield return new TestCaseData(scenario).SetName(scenario.Id); + } + + [TestCaseSource(nameof(Scenarios))] + public void CandidateMeetsTheScenarioContract(Scenario scenario) + { + Assert.AreEqual(scenario.Template.IndexOf(Marker, StringComparison.Ordinal), scenario.Template.LastIndexOf(Marker, StringComparison.Ordinal), + "Fixtures must identify exactly one reference"); + var violations = Violation.None; + var details = new List(); + var configurations = scenario.Configurations ?? new[] { Array.Empty() }; + if (scenario.ExpectedPerConfiguration != null) Assert.AreEqual(configurations.Length, scenario.ExpectedPerConfiguration.Length); + for (var configuration = 0; configuration < configurations.Length; configuration++) + { + var symbols = configurations[configuration]; + var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose, preprocessorSymbols: symbols); + var before = Prepare(scenario, scenario.Original, options); + var after = Prepare(scenario, scenario.Candidate, options); + Assert.That(Errors(before.Compilation), Is.Empty, "Original fixture must compile in each specified configuration"); + Assert.NotNull(before.Reference, "Original reference must occupy exactly one syntax node"); + var originalSymbol = ReferencedSymbol(before.Model, before.Reference!); + Assert.That(originalSymbol, Is.InstanceOf(), "Original fixture must identify a known type, not merely text"); + var errors = Errors(after.Compilation); + var current = errors.Length > 0 ? Violation.Compilation : Violation.None; + if (after.Reference == null || !SyntaxFactory.AreEquivalent(Skeleton(before), Skeleton(after), static _ => false)) current |= Violation.Grammar; + if (after.Reference == null || Identity(originalSymbol) != Identity(ReferencedSymbol(after.Model, after.Reference))) current |= Violation.ReferenceIdentity; + if (after.Reference != null && !Operations(before).SequenceEqual(Operations(after))) current |= Violation.EnclosingOperation; + if (scenario.ExpectedPerConfiguration != null) + Assert.AreEqual(scenario.ExpectedPerConfiguration[configuration], current == Violation.None, + "Candidate validity in [" + string.Join(",", symbols) + "]: " + current); + violations |= current; + details.Add($"[{string.Join(",", symbols)}] {current}: {string.Join("; ", errors)}"); + } + Assert.AreEqual(scenario.ExpectedSafe, violations == Violation.None, string.Join(Environment.NewLine, details)); + if (!scenario.ExpectedSafe) + Assert.That((violations & scenario.RequiredViolation) == scenario.RequiredViolation, Is.True, + "Negative control must fail for its intended reason: " + string.Join(Environment.NewLine, details)); + } + + [TestCase("using System.Threading.Tasks; namespace Example { public class await { public static int Create() => 1; } public class Consumer { public async Task M() => $TYPE$.Create(); } }", + "global::Example.await", "CS4003")] + [TestCase("namespace Example { public class T { public static explicit operator T(int x) => new T(); } public class Consumer { public T M(int x) => ($TYPE$)-x; } }", + "global::Example.T", "CS0075")] + public void InvalidPreliminaryOriginalFixturesAreNotAcceptedAsReductionEvidence(string template, string original, string diagnosticId) + { + var scenario = new Scenario("InvalidOriginal", template, original, original, false); + var prepared = Prepare(scenario, original, new CSharpParseOptions()); + Assert.True(prepared.Compilation.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error && d.Id == diagnosticId), + "Keep the rejected starting example as a control instead of claiming a reduction regression"); + } + + [Test] + public void OracleDetectsChangedOverloadEvenWhenReferencedTypeIsIdentical() + { + var scenario = new Scenario("OverloadOracle", "class Consumer { public string M() => $TYPE$.ToString(42); }", + "global::System.Convert", "global::System.Convert", true); + var before = Prepare(scenario, scenario.Original, new CSharpParseOptions()); + var after = Prepare(scenario with { Template = scenario.Template.Replace("42", "42L", StringComparison.Ordinal) }, scenario.Original, new CSharpParseOptions()); + Assert.That(Errors(before.Compilation), Is.Empty); + Assert.That(Errors(after.Compilation), Is.Empty); + Assert.AreEqual(Identity(ReferencedSymbol(before.Model, before.Reference!)), Identity(ReferencedSymbol(after.Model, after.Reference!))); + Assert.That(Operations(before).ToArray(), Is.Not.EqualTo(Operations(after).ToArray()), + "Checking only the referenced type must not hide an overload change"); + } + + internal sealed record Prepared(CSharpCompilation Compilation, SyntaxNode Root, SyntaxNode? Reference, SemanticModel Model); + + internal static Prepared Prepare(Scenario scenario, string spelling, CSharpParseOptions options) + { + // Fixture construction only. No production output or production reducer + // participates in candidate selection or in this compiler oracle. + var start = scenario.Template.IndexOf(Marker, StringComparison.Ordinal); + Assert.That(start, Is.GreaterThanOrEqualTo(0)); + var text = scenario.Template.Replace(Marker, spelling, StringComparison.Ordinal); + var tree = CSharpSyntaxTree.ParseText(text, options, "Scenario.cs"); + var trees = scenario.AdditionalSource == null ? new[] { tree } : new[] { tree, CSharpSyntaxTree.ParseText(scenario.AdditionalSource, options, "Partial.cs") }; + var compilation = CSharpCompilation.Create("StructuralNameScenario", trees, References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var root = tree.GetRoot(); + var span = new TextSpan(start, spelling.Length); + var reference = root.FindNode(span, findInsideTrivia: true, getInnermostNodeForTie: true); + return new Prepared(compilation, root, reference.Span == span ? reference : null, compilation.GetSemanticModel(tree)); + } + + private static string[] Errors(Compilation compilation) => compilation.GetDiagnostics() + .Where(static d => d.Severity == DiagnosticSeverity.Error).Select(static d => d.ToString()).ToArray(); + + internal static ISymbol? ReferencedSymbol(SemanticModel model, SyntaxNode reference) + { + var symbol = model.GetSymbolInfo(reference).Symbol; + return symbol is IAliasSymbol alias ? alias.Target : symbol; + } + + private static SyntaxNode Skeleton(Prepared prepared) + { + if (prepared.Reference == null) return prepared.Root; + SyntaxNode placeholder = prepared.Reference is CrefSyntax + ? SyntaxFactory.TypeCref(SyntaxFactory.IdentifierName("__Reference")) + : SyntaxFactory.IdentifierName("__Reference"); + return prepared.Root.ReplaceNode(prepared.Reference, placeholder.WithTriviaFrom(prepared.Reference)); + } + + private static IEnumerable Operations(Prepared prepared) + { + foreach (var operation in prepared.Reference!.Ancestors().Where(static n => n is MemberAccessExpressionSyntax or InvocationExpressionSyntax or + ObjectCreationExpressionSyntax or CastExpressionSyntax or TypeOfExpressionSyntax or IsPatternExpressionSyntax)) + { + var info = prepared.Model.GetSymbolInfo(operation); + var constant = prepared.Model.GetConstantValue(operation); + yield return operation.Kind() + ":" + Identity(info.Symbol) + ":" + + string.Join("|", info.CandidateSymbols.Select(Identity).OrderBy(static s => s, StringComparer.Ordinal)) + ":" + + TypeIdentity(prepared.Model.GetTypeInfo(operation).Type) + ":constant=" + + (constant.HasValue ? constant.Value?.GetType().FullName + ":" + constant.Value : ""); + } + } + + private static string Identity(ISymbol? symbol) => symbol switch + { + IAliasSymbol alias => Identity(alias.Target), + ITypeSymbol type => TypeIdentity(type), + IMethodSymbol method => "method:" + TypeIdentity(method.ContainingType) + ":" + DeclarationIdentity(method) + + "<" + string.Join(",", method.TypeArguments.Select(TypeIdentity)) + ">(" + + string.Join(",", method.Parameters.Select(static p => p.RefKind + ":" + TypeIdentity(p.Type))) + ")->" + TypeIdentity(method.ReturnType), + IPropertySymbol property => "property:" + TypeIdentity(property.ContainingType) + ":" + property.MetadataName + ":" + TypeIdentity(property.Type), + IFieldSymbol field => "field:" + TypeIdentity(field.ContainingType) + ":" + field.MetadataName + ":" + TypeIdentity(field.Type), + ILocalSymbol local => "local:" + local.Name + ":" + TypeIdentity(local.Type), + null => "", + _ => symbol.Kind + ":" + symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + private static string DeclarationIdentity(ISymbol symbol) + { + if (symbol is not IMethodSymbol { MethodKind: MethodKind.LocalFunction }) + return symbol.Kind + ":" + (symbol.OriginalDefinition.GetDocumentationCommentId() + ?? throw new InvalidOperationException("Oracle needs an explicit declaration identity for " + symbol.Kind)); + + // Documentation IDs can collide for member/local methods with the + // same name/signature. Source offsets also change after shortening. + // A declaration's node path remains stable for name-only changes. + var node = symbol.DeclaringSyntaxReferences.Single().GetSyntax(); + var path = new List(); + var file = node.SyntaxTree.FilePath; + while (node.Parent is { } parent) + { + var siblings = parent.ChildNodes().ToArray(); + var index = Array.FindIndex(siblings, child => child == node); + if (index < 0) throw new InvalidOperationException("Missing declaration in oracle syntax path"); + path.Add(index); + node = parent; + } + path.Reverse(); + return "local:" + file + ":" + string.Join("/", path); + } + + internal static string TypeIdentity(ITypeSymbol? type) => type switch + { + INamedTypeSymbol { TypeKind: TypeKind.Error } error => "", + INamedTypeSymbol named => "named:" + named.TypeKind + ":" + named.NullableAnnotation + ":" + named.ContainingAssembly.Identity + ":" + + named.OriginalDefinition.GetDocumentationCommentId() + ":owner=" + TypeIdentity(named.ContainingType) + + "<" + string.Join(",", named.TypeArguments.Select(TypeIdentity)) + ">", + ITypeParameterSymbol parameter => "parameter:" + parameter.TypeParameterKind + ":" + parameter.Ordinal + ":" + parameter.NullableAnnotation + ":" + + parameter.ContainingAssembly.Identity + ":" + DeclarationIdentity(parameter.ContainingSymbol), + IArrayTypeSymbol array => TypeIdentity(array.ElementType) + "[" + array.Rank + "]:" + array.NullableAnnotation, + IPointerTypeSymbol pointer => TypeIdentity(pointer.PointedAtType) + "*", + null => "", + _ => type.TypeKind + ":" + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Writers/OptionalDefaultEmissionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Writers/OptionalDefaultEmissionTests.cs new file mode 100644 index 00000000000..a6d89bb64a7 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Writers/OptionalDefaultEmissionTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Expressions; +using Microsoft.TypeSpec.Generator.Primitives; +using Microsoft.TypeSpec.Generator.Providers; +using NUnit.Framework; +using static Microsoft.TypeSpec.Generator.Snippets.Snippet; + +namespace Microsoft.TypeSpec.Generator.Tests.Writers +{ + [NonParallelizable] + public class OptionalDefaultEmissionTests + { + [SetUp] + public void SetUp() => MockHelpers.LoadMockGenerator(); + + private static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Select(static path => MetadataReference.CreateFromFile(path)).ToArray(); + + [TestCase(typeof(CancellationToken))] + [TestCase(typeof(DateTimeOffset))] + [TestCase(typeof(Guid))] + [TestCase(typeof(int))] + [TestCase(typeof(DayOfWeek))] + [TestCase(typeof(CancellationToken?))] + [TestCase(typeof(string))] + public void CastDefaultParameterCompilesWithoutSimplification(Type type) + { + using var writer = new CodeWriter(); + writer.AppendRaw("public static class Consumer { public static void M("); + writer.WriteParameter(new ParameterProvider("value", $"The value.", new CSharpType(type), new CastExpression(Default, new CSharpType(type)))); + writer.AppendRaw(") { } }"); + var compilation = CSharpCompilation.Create("RawDefaults", [CSharpSyntaxTree.ParseText(writer.ToString())], References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(static d => d.Severity == DiagnosticSeverity.Error), Is.Empty, writer.ToString()); + } + + [Test] + public void DifferentCastTypeInParameterIsUnchanged() + { + using var writer = new CodeWriter(); + writer.WriteParameter(new ParameterProvider("value", $"The value.", typeof(long), new CastExpression(Default, typeof(int)))); + Assert.AreEqual("long value = ((int)default)", writer.ToString(false)); + } + + [Test] + public void BareDefaultInParameterIsUnchanged() + { + using var writer = new CodeWriter(); + writer.WriteParameter(new ParameterProvider("value", $"The value.", typeof(CancellationToken), Default)); + Assert.AreEqual("global::System.Threading.CancellationToken value = default", writer.ToString(false)); + } + + [Test] + public void CastDefaultOutsideParameterIsUnchanged() + { + using var writer = new CodeWriter(); + new CastExpression(Default, typeof(CancellationToken)).Write(writer); + Assert.AreEqual("((global::System.Threading.CancellationToken)default)", writer.ToString(false)); + } + } +} From a3f0e035f3536c9040a111df958359a0e4038c83 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 29 Oct 2026 13:59:34 +0000 Subject: [PATCH 2/3] [http-client-csharp] Reduce recorded SDK cast type qualifications Add the accepted stable-method SELF interface-cast qualification slice and its diagnostic/import/source regression coverage. Update the published prototype status and residual inventory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../perf/ProviderReducerStatus.md | 22 +- .../StructuralTypeNameReducer.cs | 124 ++++++++++ ...StructuralSdkCastTypeQualificationTests.cs | 234 ++++++++++++++++++ 3 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md index 8b45a13144d..59848e57703 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md @@ -1,19 +1,20 @@ # Provider reducer: current draft status -The accepted prototype checkpoint is **C9**. `TYPESPEC_PROVIDER_REDUCER=structural` enables compiler-backed structural reduction; Roslyn remains the default. The older `true` provider prototype is retained for comparison. Writer grammar/default-emission fixes apply in both modes. This remains a draft, not a default rollout or complete Roslyn-simplifier replacement. +The accepted prototype checkpoint is **C9 plus the SDK-shaped cast/interface qualification slice**. `TYPESPEC_PROVIDER_REDUCER=structural` enables compiler-backed structural reduction; Roslyn remains the default. The older `true` provider prototype is retained for comparison. Writer grammar/default-emission fixes apply in both modes. This remains a draft, not a default rollout or complete Roslyn-simplifier replacement. ## Applied rules - Record original writer type identities/spans and whole-type cref provenance. Share original-compilation facts and integrate reduction with member cleanup in one rewrite traversal and final serialization; uncertain cases retain their spelling rather than fall back to Roslyn. - Shorten supported declaration/type names, namespace prefixes and aliases, including constructed generic heads and nested/array/nullable forms where certified. Conditional documents retain configuration/header/type-scope guards. - Shorten supported static type receivers, including the bounded conditional-static certificate; this does not generally remove a complete member receiver. +- Shorten recorded interface heads and enclosing SELF-model arguments inside retained `((IPersistableModel)this)` call receivers. Require stable owning methods, an independent parameter import-use witness, exact composed-type binding and cast/receiver parser proofs; retain foreign model arguments and varying/source-observable/hidden cases. - Remove certified field/property `this.` receivers with binding/conversion/source-observation guards. General method-call receivers remain outside that rule. - Reduce certified attribute names/qualification and suffixes without rewriting attribute arguments. - Reduce recorded whole-type XML crefs with serialized binding and import/diagnostic certificates, including C9's immediate-namespace permission. Exception provenance and broader imported/ancestor cases are not silently expanded. - Simplify eligible optional-parameter defaults using import-use witnesses; emit valid `default(T)` forms where the writer must preserve a typed optional default. - Remove certified expression parentheses, including conditional/statement and bounded cast-payload cases, while retaining grammar/context, trivia/visibility, caller-source and conversion safeguards. Assignment-expression statements remain legal unparenthesized C# statements. -C3 cast-type qualification, C6 `nameof`, C7 method-call `this`, and C10 SDK-shaped generic inference are **not included as accepted enhancements**. The newer cast investigation is also not included. +Historical C3 cast-type qualification, C6 `nameof`, C7 method-call `this`, and C10 SDK-shaped generic inference remain **excluded**. The accepted SDK-shaped cast slice is a separate narrow certificate, not restoration of C3 or cast removal. ## Saved AppService diff gap @@ -22,16 +23,17 @@ Same saved AppService input and saved Roslyn 5.9 reference, **2,301 emitted file | Checkpoint | Residual events | |---|---:| | Initial structural slice | 165,725 | -| Current accepted C9 | 29,336 | -| Reduction | **136,389 (82.3%)** | +| Accepted C9 (historical) | 29,336 | +| Current accepted C9 + SDK-shaped casts | 25,726 | +| Reduction | **139,999 (84.5%)** | -The earlier combined/legacy provider prototype had 147,326 residual events; that is a different starting rule set, not the initial structural checkpoint. Rejected/deferred experiments are excluded from the current count. C9 alone removes 4,275 cref events across 894 files; its remaining cref count is 4,974. +The earlier combined/legacy provider prototype had 147,326 residual events; that is a different starting rule set, not the initial structural checkpoint. Rejected/deferred experiments are excluded from the current count. C9 alone removes 4,275 cref events across 894 files; its remaining cref count is 4,974. The accepted cast slice removes another **3,610 qualification events in 1,805 casts across 600 files**, leaving every other family unchanged. ## Remaining AppService pattern groups | Potential group | Events | |---|---:| -| Cast/generic-argument type qualifications | 7,050 | +| Cast/generic-argument type qualifications | 3,440 | | Static helper/type receiver qualifications | 6,045 | | XML cref qualifications | 4,974 | | Method-call `this.` receivers | 3,834 | @@ -43,16 +45,16 @@ The earlier combined/legacy provider prototype had 147,326 residual events; that | Explicit generic call arguments | 560 | | Object-creation type qualifications | 496 | | Small keyword/pattern/null/default/declaration remainder | 34 | -| **Total** | **29,336** | +| **Total** | **25,726** | -Important concentrations: 4,798 events are interface/model-argument qualifications in 2,399 `IPersistableModel` casts; static receivers include 2,279 Optional checks, 1,047 ModelReaderWriter uses, 1,047 serialization-context uses and 1,254 JSON fallback calls. Exception crefs account for 2,574 events. Overall, 20,064 events occur in serialization files; 18,871 occur in conditional documents, but only 1,255 are inside branches. These cross-cuts overlap and must not be added together. Counts are investigation opportunities, **not safe-edit estimates** or authorization to revive deferred rules. +The remaining interface/model-argument inventory has 1,188 qualification events in 594 `IPersistableModel` casts. Static receivers still include 2,279 Optional checks, 1,047 ModelReaderWriter uses, 1,047 serialization-context uses and 1,254 JSON fallback calls. Exception crefs account for 2,574 events. These counts are investigation opportunities, **not safe-edit estimates** or authorization to revive deferred rules. ## Validation and limits -The accepted C9 source epoch passed 75 targeted and 869 focused tests, 3,813 Core and 2,509 client tests, plus official unfiltered LOCAL/Spector (956 passed, 10 skipped). The sample discovered zero tests and is not runtime coverage. Source proof covered all 2,301 files in six configurations (27,612 parses), with independent UTF-8/BOM/UTF-16 reconstruction. Semantic output-consumer checks matched 25,650 bindings, complete ordinary/unfiltered compiler/emit diagnostic multisets and 90,444 independently projected locations; 23 controls passed. +The current accepted cast source epoch passed 11 policy regressions, **3,824 Core and 2,509 client tests**, plus official unfiltered LOCAL/Spector (**956 passed, 10 skipped**). The sample discovered zero tests and is not runtime coverage. Source preservation covered all 2,301 files: strict decoding/byte comparison, with the 600 changed files parsed in six configurations and independently reconstructed through UTF-16 deletions with exact UTF-8/BOM/trivia parity; 49 source controls passed. Semantic checks matched **10,830 cast/full-method-operation comparisons**, complete ordinary/unfiltered compiler/emit diagnostic multisets, **752,232 independently projected locations and 619,488 source-syntax records**; 48 controls passed. C9's earlier 25,650 cref-binding comparisons remain historical baseline evidence, not newly rerun cast comparisons. Authentic original reconstruction uses 2,699 documents and 20 references. The explicit output-consumer view has 2,563 documents after symmetrically collapsing 136 original same-name/source/metadata-identical duplicate IDs. Original binding/diagnostic anchors run before that construction. Its existing failed emits are diagnostic evidence, not positive XML/runtime proof. -A successful candidate-only net10 build verified actual GA AppService 1.5.0 API compatibility and byte-identical whole emitted XML against authenticated saved accepted-baseline XML. All 2,677 SDK and 2,186 local/launch files were exactly restored. No fresh baseline build was required. Fresh netstandard2.0/net8.0 validation and comparative profiling remain deferred. The approximately 119-second C9 whole-generation run is a rough single-run observation, not a reducer-only measurement or comparative speed claim. +A successful candidate-only net10 build verified actual GA AppService 1.5.0 API compatibility and byte-identical whole emitted XML against authenticated saved accepted-baseline XML. All 2,677 SDK and 2,186 local/launch files were exactly restored. No fresh baseline build was required. Fresh netstandard2.0/net8.0 validation and comparative profiling remain deferred. The approximately **113-second** current whole-generation run (historical C9: approximately 119 seconds) is a rough single-run observation, not a reducer-only measurement or comparative speed claim. Earlier architecture and measurement histories remain in [ProviderReducerPrototype.md](ProviderReducerPrototype.md) and [StructuralTypeNameReduction.md](StructuralTypeNameReduction.md); their historical scope limits and measurements are not current rollout claims. Large captured SDK inputs, outputs and local validation logs are intentionally not checked in. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs index 9e63552e96c..32917aaaaaf 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs @@ -183,6 +183,14 @@ private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) } try { + if (node is CastExpressionSyntax sdkCast && sdkCast.Expression is ThisExpressionSyntax) + { + // This occurrence owns the complete constructed type. Keep + // ordinary cast children under their existing exclusions, + // and prove the final cast after its single child visit. + var visited = (CastExpressionSyntax)base.Visit(node)!; + return TryReduceSdkCastType(sdkCast, visited) ?? visited; + } if (node is DefaultExpressionSyntax optionalDefault) { // Decide whole-expression erasure before visiting children. @@ -337,6 +345,122 @@ InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyn return true; } + private CastExpressionSyntax? TryReduceSdkCastType(CastExpressionSyntax original, CastExpressionSyntax visited) + { + // Initial SDK-shaped slice: an instance cast used as a retained, + // parenthesized receiver, not arbitrary cast/expression grammar. + if (_insideAttribute || _scope == null || _insideThisExcludedContext || + original.Type is not QualifiedNameSyntax { Right: GenericNameSyntax generic } name || + generic.TypeArgumentList.Arguments.Count != 1 || generic.TypeArgumentList.Arguments[0] is not NameSyntax argument || + original.Parent is not ParenthesizedExpressionSyntax wrapper || wrapper.Expression != original || + wrapper.Parent is not MemberAccessExpressionSyntax member || member.Expression != wrapper || + member.Name is not IdentifierNameSyntax || member.Parent is not InvocationExpressionSyntax invocation || invocation.Expression != member || + original.Ancestors().Any(static n => n is LocalFunctionStatementSyntax or AnonymousFunctionExpressionSyntax) || + original.Ancestors().OfType().FirstOrDefault() is not { } method || + method.Modifiers.Any(SyntaxKind.StaticKeyword) || ConditionalStaticReceiverRules.HasHardSourceContext(original) || + visited.Expression.ToFullString() != original.Expression.ToFullString() || + !_sites.TryGetValue(ReferenceSpan(name), out var headSite)) return null; + if (!_canProveCallSource && !CanProveConditionalStaticReceiver(wrapper, member, headSite)) return null; + if (_tree.GetLineVisibility(headSite.Span.Start) != LineVisibility.Visible || + _tree.GetLineVisibility(headSite.Span.End - 1) != LineVisibility.Visible || + !AllowsConditionalStaticSourceChange(original, headSite.Span)) return null; + _model ??= _owner._compilation.GetSemanticModel(_tree); + if (_model.GetTypeInfo(name).Type is not INamedTypeSymbol { TypeKind: TypeKind.Interface, Arity: 1, ContainingType: null } construction || + construction.TypeArguments[0] is not INamedTypeSymbol { TypeKind: TypeKind.Class, Arity: 0, ContainingType: null } modelType || + !HasUsableTypeGraph(construction) || _owner.Resolve(headSite.Type) is not { } target || + !SymbolEqualityComparer.Default.Equals(target.Symbol, construction.OriginalDefinition) || + DescribeRecordedReference(name, target) is not { } source || + _model.GetOperation(original) is not IConversionOperation conversion || !conversion.Conversion.Exists || + !conversion.Conversion.IsReference || conversion.OperatorMethod != null || + _model.GetSymbolInfo(invocation) is not { CandidateReason: CandidateReason.None, Symbol: IMethodSymbol callee } || + callee.IsStatic || callee.MethodKind != MethodKind.Ordinary || callee.ReducedFrom != null || + !SymbolEqualityComparer.IncludeNullability.Equals(callee.ContainingType, construction) || + !HasCastParameterImportWitness(method, construction.ContainingNamespace)) return null; + + NameSyntax selectedArgument = argument; + // The self-model name is found in its own containing type scope, + // not through an import. A foreign model needs a separate import + // certificate; retain its argument while still allowing HEAD-only + // reduction under the independent parameter witness above. + if (_model.GetDeclaredSymbol(method) is IMethodSymbol ownerMethod && + SymbolEqualityComparer.Default.Equals(ownerMethod.ContainingType, modelType) && + _sites.TryGetValue(ReferenceSpan(argument), out var argumentSite) && + _owner.Resolve(argumentSite.Type) is { } argumentTarget && + SymbolEqualityComparer.IncludeNullability.Equals(argumentTarget.Symbol, modelType) && + DescribeRecordedReference(argument, argumentTarget) is { } argumentSource && + _tree.GetLineVisibility(argumentSite.Span.Start) == LineVisibility.Visible && + _tree.GetLineVisibility(argumentSite.Span.End - 1) == LineVisibility.Visible && + AllowsConditionalStaticSourceChange(original, argumentSite.Span)) + { + var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model, argument, modelType, + KnownDeclarationType: true, Source: argumentSource, ModeOverride: StructuralTypeNameSelector.Mode.Type); + StructuralTypeNameSelector.Choose(new[] { context }, plan => + { + if (plan.Render(argumentSource) is not IdentifierNameSyntax candidate || + candidate.Identifier.ValueText != modelType.Name || ExpressionReceiverSyntaxFacts.ExposesContextualRoot(candidate) || + _model.GetSpeculativeAliasInfo(argument.SpanStart, SyntaxFactory.IdentifierName(candidate.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) != null) return false; + selectedArgument = candidate.WithTriviaFrom(argument); + return true; + }); + } + // Compose the actual candidate, including its preserved/new model + // argument, before whole-type speculation and parser admission. + var parts = (SimpleNameSyntax[])source.Parts.Clone(); + parts[^1] = ((GenericNameSyntax)parts[^1]).WithTypeArgumentList(generic.TypeArgumentList.WithArguments( + SyntaxFactory.SingletonSeparatedList(selectedArgument))); + source = source with { Parts = parts }; + var headContext = new StructuralTypeNameSelector.Context(_owner._compilation, _model, name, construction, + KnownDeclarationType: true, Source: source, PreserveTerminalArguments: true, ModeOverride: StructuralTypeNameSelector.Mode.Type); + CastExpressionSyntax? replacement = null; + StructuralTypeNameSelector.Choose(new[] { headContext }, plan => + { + // Bare generic heads only; namespace/type aliases and broader + // cast operand/parent roles are not part of this certificate. + if (plan.Render(source) is not GenericNameSyntax candidate || candidate.Identifier.ValueText != construction.Name || + ExpressionReceiverSyntaxFacts.ExposesContextualRoot(candidate) || _tree.Options is not CSharpParseOptions options) return false; + var detached = SyntaxFactory.ParseTypeName(candidate.ToString(), options: options); + if (detached.ContainsDiagnostics) return false; + var rebound = _model.GetSpeculativeSymbolInfo(name.SpanStart, detached, SpeculativeBindingOption.BindAsTypeOrNamespace); + if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || + !SymbolEqualityComparer.IncludeNullability.Equals(construction, rebound.Symbol)) return false; + var cast = visited.WithType(detached.WithTriviaFrom(visited.Type)); + var parsedCast = SyntaxFactory.ParseExpression(cast.ToString(), options: options, consumeFullText: true); + if (parsedCast.ContainsDiagnostics || parsedCast is not CastExpressionSyntax || !SyntaxFactory.AreEquivalent(cast, parsedCast)) return false; + var receiver = member.WithExpression(wrapper.WithExpression(cast)); + var parsedReceiver = SyntaxFactory.ParseExpression(receiver.ToString(), options: options, consumeFullText: true); + if (parsedReceiver.ContainsDiagnostics || !SyntaxFactory.AreEquivalent(receiver, parsedReceiver)) return false; + replacement = cast; + return true; + }); + return replacement; + } + + private bool HasCastParameterImportWitness(MethodDeclarationSyntax method, INamespaceSymbol targetNamespace) + { + // A cast cannot newly activate an otherwise-unused import. A bare + // parameter already witnesses it, or this parameter's existing C9 + // type-only rule independently proves its surviving bare spelling. + // This is an occurrence proof, not a cached choice or hypothetical + // rewrite of a source-observed expression. + var model = _model ??= _owner._compilation.GetSemanticModel(_tree); + foreach (var parameter in method.ParameterList.Parameters) + { + if (parameter.Type is not NameSyntax name || + model.GetTypeInfo(name).Type is not INamedTypeSymbol { Arity: 0, ContainingType: null } type || + !SymbolEqualityComparer.Default.Equals(type.ContainingNamespace, targetNamespace)) continue; + if (name is IdentifierNameSyntax bare && bare.Identifier.ValueText == type.Name && model.GetAliasInfo(bare) == null) return true; + if (!_sites.TryGetValue(ReferenceSpan(name), out var site) || !IsTypeOnlyContext(name) || + _owner.Resolve(site.Type) is not { } target || !SymbolEqualityComparer.IncludeNullability.Equals(type, target.Symbol) || + DescribeRecordedReference(name, target) is not { } source) continue; + var context = new StructuralTypeNameSelector.Context(_owner._compilation, model, name, type, + KnownDeclarationType: true, Source: source, ModeOverride: StructuralTypeNameSelector.Mode.Type); + var selected = StructuralTypeNameSelector.Choose(new[] { context }); + if (selected.Plan?.Render(source) is IdentifierNameSyntax identifier && identifier.Identifier.ValueText == type.Name && + model.GetSpeculativeAliasInfo(name.SpanStart, SyntaxFactory.IdentifierName(identifier.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) == null) return true; + } + return false; + } + private ExpressionSyntax? TryReduceOptionalDefault(DefaultExpressionSyntax expression, out bool retainOriginal) { retainOriginal = false; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs new file mode 100644 index 00000000000..7df9f1e96ce --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + // Portable policy regressions reproduce the SDK's cast/member-receiver shape + // and real framework interface/writer metadata. They supplement, not replace, + // the authenticated original 2699-document/20-reference SDK replay. + [NonParallelizable] + public class StructuralSdkCastTypeQualificationTests + { + private string? _previous; + private string _directory = null!; + private const string FullArgument = "global::Azure.ResourceManager.AppService.AiGatewayData"; + + [SetUp] + public void SetUp() + { + _previous = Environment.GetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER"); + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", "structural"); + _directory = Path.Combine(Path.GetTempPath(), "typespec-sdk-cast", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true}"); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable("TYPESPEC_PROVIDER_REDUCER", _previous); + Directory.Delete(_directory, recursive: true); + } + + [TestCase(false)] + [TestCase(true)] + public void SdkCastReceiverShortensCompleteConstructedType(bool conditionalDocument) + { + var code = Build(conditionalDocument); + var text = Reduce(code); + StringAssert.Contains("((IPersistableModel)this).GetFormatFromOptions(options)", text); + } + + [TestCase(false)] + [TestCase(true)] + public void RecordedQualifiedParameterWitnessShortensCast(bool conditionalDocument) + { + var code = Build(conditionalDocument, recordedParameter: true); + StringAssert.Contains("((IPersistableModel)this).GetFormatFromOptions(options)", Reduce(code)); + } + + [Test] + public void ImportedModelArgumentRetainsUnusedImport() + { + var model = new CSharpType("Model", "Models", false, false, null, Array.Empty(), true, false); + var target = new CSharpType("I", "Contracts", false, false, null, new[] { model }, true, false); + using var writer = new CodeWriter(); + writer.AppendRaw("namespace Contracts { public class Options {} public interface I { string Run(Options options); } }\nnamespace Models { public class Model {} }\nnamespace App { public class Host : global::Contracts.I {\npublic string Test(Options options) => (("); + writer.Append($"{target}"); + writer.AppendRaw(")this).Run(options);\npublic string Run(Options options) => \"\";\n} }\n"); + var code = new CodeFile(writer.ToString(), "ImportedArgument.cs", writer.TypeReferences); + StringAssert.Contains("((I)this).Run(options)", Reduce(code)); + } + + [Test] + public void FrameworkVaryingOwningMethodRetainsCast() + { + var code = Build(conditionalDocument: true, varyingOwner: true); + StringAssert.Contains("((global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">)this)", Reduce(code)); + } + + [Test] + public void CallerArgumentExpressionRetainsObservedCast() + { + var code = Build(captured: true); + StringAssert.Contains("((global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">)this)", Reduce(code)); + } + + [Test] + public void HiddenCastRetainsSpelling() + { + var code = Build(hidden: true); + StringAssert.Contains("((global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">)this)", Reduce(code)); + } + + [Test] + public void UnrecordedArgumentCannotLoseItsQualification() + { + var code = Build(); + code = new CodeFile(code.Content, code.Name, code.TypeReferences!.Where(s => s.Type.Name != "AiGatewayData").ToArray()); + StringAssert.Contains("IPersistableModel<" + FullArgument + ">", Reduce(code)); + } + + [Test] + public void DuplicateHeadProvenanceRetainsWholeCast() + { + var code = Build(); + var head = code.TypeReferences!.Single(s => s.Type.Name == "IPersistableModel"); + code = new CodeFile(code.Content, code.Name, code.TypeReferences!.Concat(new[] { head }).ToArray()); + StringAssert.Contains("((global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">)this)", Reduce(code)); + } + + [Test] + public void UnusedFrameworkImportIsNotActivatedByCast() + { + var code = Build(noImportWitness: true); + StringAssert.Contains("((global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">)this)", Reduce(code)); + } + + private static CodeFile Build(bool conditionalDocument = false, bool varyingOwner = false, bool captured = false, bool hidden = false, bool noImportWitness = false, bool recordedParameter = false) + { + var model = new CSharpType("AiGatewayData", "Azure.ResourceManager.AppService", false, false, null, + Array.Empty(), true, false); + var target = new CSharpType(typeof(IPersistableModel<>), model); + using var writer = new CodeWriter(); + writer.AppendRaw("using System;\nnamespace Azure.ResourceManager.AppService {\npublic partial class AiGatewayData : "); + writer.AppendRaw(noImportWitness ? "global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + "> {\n" : "IPersistableModel {\n"); + if (hidden) writer.AppendRaw("#line hidden\n"); + writer.AppendRaw("public string PersistableModelCreateCore("); + if (recordedParameter) writer.Append($"{new CSharpType(typeof(ModelReaderWriterOptions))}"); + else writer.AppendRaw(noImportWitness ? "global::System.ClientModel.Primitives.ModelReaderWriterOptions" : "ModelReaderWriterOptions"); + writer.AppendRaw(" options) {\n"); + if (varyingOwner) writer.AppendRaw("#if NET6_0_OR_GREATER\nint framework = 6;\n#else\nint framework = 0;\n#endif\n"); + writer.AppendRaw(captured ? "return Capture(((" : "return (("); + writer.Append($"{target}"); + writer.AppendRaw(captured ? ")this).GetFormatFromOptions(options));\n}\n" : ")this).GetFormatFromOptions(options);\n}\n"); + if (hidden) writer.AppendRaw("#line default\n"); + if (conditionalDocument && !varyingOwner) + writer.AppendRaw("public int FrameworkVariation() {\n#if NET6_0_OR_GREATER\nreturn 6;\n#else\nreturn 0;\n#endif\n}\n"); + var declarations = "BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => null;\nAiGatewayData IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => this;\nstring IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => \"J\";\n"; + if (noImportWitness) declarations = declarations.Replace("BinaryData", "global::System.BinaryData", StringComparison.Ordinal) + .Replace("IPersistableModel", "global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">", StringComparison.Ordinal) + .Replace("ModelReaderWriterOptions", "global::System.ClientModel.Primitives.ModelReaderWriterOptions", StringComparison.Ordinal); + writer.AppendRaw(declarations); + if (captured) writer.AppendRaw("static string Capture(string value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string source = null) => value;\n"); + writer.AppendRaw("}\n}\n"); + return new CodeFile(writer.ToString(), "AiGatewayData.Serialization.cs", writer.TypeReferences); + } + + private static void PreserveDiagnostics(CodeFile code, string output, Compilation before, Compilation after, SyntaxTree oldTree, SyntaxTree newTree) + { + // Every accepted source change must be a witnessed writer namespace + // deletion. Project diagnostics through those actual UTF16 erasures, + // never through arbitrary offsets or a location-free comparison. + var allowed = code.TypeReferences!.GroupBy(s => s.Span.Start).ToDictionary(g => g.Key, + g => "global::" + g.First().Type.Namespace + "."); + var edits = new List(); + int oldPosition = 0, newPosition = 0; + while (oldPosition < code.Content.Length) + { + if (newPosition < output.Length && code.Content[oldPosition] == output[newPosition]) { oldPosition++; newPosition++; continue; } + Assert.That(allowed.TryGetValue(oldPosition, out var prefix), Is.True, "Only actual writer namespace-prefix deletion is allowed"); + Assert.That(code.Content.AsSpan(oldPosition).StartsWith(prefix.AsSpan(), StringComparison.Ordinal), Is.True); + edits.Add(new TextSpan(oldPosition, prefix!.Length));oldPosition += prefix.Length; + } + Assert.That(newPosition, Is.EqualTo(output.Length)); + int Map(int offset) => offset - edits.Where(e => e.End <= offset).Sum(e => e.Length); + string LocationKey(Location location, bool project) + { + if (!location.IsInSource) return location.Kind.ToString(); + var span = location.SourceSpan;var tree = location.SourceTree!; + if (project && ReferenceEquals(tree, oldTree)) + { + Assert.That(edits.Any(e => span.Start < e.End && span.End > e.Start || span.Length == 0 && span.Start >= e.Start && span.Start < e.End), Is.False, "A diagnostic intersecting erased text cannot be waived"); + span = TextSpan.FromBounds(Map(span.Start), Map(span.End));tree = newTree; + } + string Line(FileLinePositionSpan line) => line.Path + "|" + line.HasMappedPath + "|" + line.IsValid + "|" + line.StartLinePosition.Line + ":" + line.StartLinePosition.Character + "-" + line.EndLinePosition.Line + ":" + line.EndLinePosition.Character; + return location.Kind + "|" + tree.FilePath + "|" + span.Start + ":" + span.End + "|" + Line(tree.GetLineSpan(span)) + "|" + Line(tree.GetMappedLineSpan(span)); + } + string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.Select(d => JsonSerializer.Serialize(new + { + d.Id, severity = d.Severity.ToString(), defaultSeverity = d.DefaultSeverity.ToString(), d.IsSuppressed, d.IsWarningAsError, d.WarningLevel, + message = d.GetMessage(System.Globalization.CultureInfo.InvariantCulture), + descriptor = new { title = d.Descriptor.Title.ToString(), messageFormat = d.Descriptor.MessageFormat.ToString(), description = d.Descriptor.Description.ToString(), d.Descriptor.Category, d.Descriptor.IsEnabledByDefault, d.Descriptor.HelpLinkUri, tags = d.Descriptor.CustomTags.ToArray() }, + properties = d.Properties.OrderBy(p => p.Key, StringComparer.Ordinal).ToArray(), primary = LocationKey(d.Location, project), additional = d.AdditionalLocations.Select(l => LocationKey(l, project)).ToArray() + })).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.That(Bag(after.GetDiagnostics(), false), Is.EqualTo(Bag(before.GetDiagnostics(), true)), "Complete compiler diagnostic multiset, including hidden imports and all location endpoints"); + using var oldPe = new MemoryStream();using var oldXml = new MemoryStream();using var newPe = new MemoryStream();using var newXml = new MemoryStream(); + var oldEmit = before.Emit(oldPe, xmlDocumentationStream: oldXml);var newEmit = after.Emit(newPe, xmlDocumentationStream: newXml); + Assert.That(newEmit.Success, Is.EqualTo(oldEmit.Success)); + Assert.That(Bag(newEmit.Diagnostics, false), Is.EqualTo(Bag(oldEmit.Diagnostics, true)), "Complete emit diagnostic multiset"); + if (oldEmit.Success) Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray())); + } + + private static string Reduce(CodeFile code) + { + using var workspace = new AdhocWorkspace(); + var paths = new[] { typeof(object).Assembly.Location, typeof(IPersistableModel<>).Assembly.Location, + typeof(BinaryData).Assembly.Location, Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), "System.Runtime.dll") }; + var project = workspace.AddProject("SdkCastControl", LanguageNames.CSharp) + .WithParseOptions(new CSharpParseOptions(documentationMode: DocumentationMode.Parse, preprocessorSymbols: new[] { "EXPERIMENTAL" })) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, + metadataReferenceResolver: new WorkspaceMetadataReferenceResolver(), nullableContextOptions: NullableContextOptions.Disable)) + .AddMetadataReferences(paths.Distinct().Select(path => MetadataReference.CreateFromFile(path))); + var document = project.AddDocument(code.Name, SourceText.From(code.Content), new[] { "Generated" }); + var before = document.Project.GetCompilationAsync().GetAwaiter().GetResult()!; + var tree = document.GetSyntaxTreeAsync().GetAwaiter().GetResult()!; + Assert.That(before.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), Is.Empty, "Valid original framework cast fixture"); + var root = tree.GetRoot(); + var rewriter = new StructuralTypeNameReducer(before).CreateRewriter(root, code.TypeReferences!); + var text = rewriter?.Visit(root)?.ToFullString() ?? code.Content; + var candidate = document.WithText(SourceText.From(text)); + var after = candidate.Project.GetCompilationAsync().GetAwaiter().GetResult()!; + Assert.That(after.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + PreserveDiagnostics(code, text, before, after, tree, candidate.GetSyntaxTreeAsync().GetAwaiter().GetResult()!); + if (code.Content.Contains("CreateCore(global::System.ClientModel.Primitives.ModelReaderWriterOptions", StringComparison.Ordinal)) + Assert.That(before.GetDiagnostics().Any(d => d.Id == "CS8019" && d.Severity == DiagnosticSeverity.Hidden), Is.True, "Unused-import regression anchor"); + string Snapshot(Compilation compilation, SyntaxTree syntaxTree) + { + var model = compilation.GetSemanticModel(syntaxTree); + var cast = syntaxTree.GetRoot().DescendantNodes().OfType().Single(c => c.Expression is ThisExpressionSyntax); + var conversion = (IConversionOperation)model.GetOperation(cast)!; + var invocation = (IInvocationOperation)model.GetOperation(cast.Ancestors().OfType().First())!; + return StructuralTypeNameRuleOracleTests.TypeIdentity(conversion.Type!) + "|" + conversion.Conversion.IsReference + "|" + conversion.IsChecked + "|" + + invocation.TargetMethod.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "|" + invocation.TargetMethod.GetDocumentationCommentId(); + } + Assert.That(Snapshot(after, candidate.GetSyntaxTreeAsync().GetAwaiter().GetResult()!), Is.EqualTo(Snapshot(before, tree))); + return text; + } + } +} From 7ac8b2640bcfa42f96e1095fb208de83b911fe71 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Fri, 30 Oct 2026 08:51:05 +0000 Subject: [PATCH 3/3] feat(http-client-csharp): refine structural reducer scope certificates --- .../perf/ProviderReducerStatus.md | 87 +- .../ConditionalStaticReceiverRules.cs | 97 +- .../ExpressionReceiverSyntaxFacts.cs | 12 +- .../PostProcessing/GeneratedCodeWorkspace.cs | 16 +- .../PostProcessing/ParenthesesSyntaxFacts.cs | 6 +- .../ProviderConditionalSyntax.cs | 143 ++- .../src/PostProcessing/ProviderNameReducer.cs | 113 ++- .../PostProcessing/ProviderSyntaxCleanup.cs | 133 ++- .../StructuralAttributeRules.cs | 51 +- .../StructuralCrefImportRules.cs | 101 ++- .../StructuralOptionalReceiverRules.cs | 79 ++ .../StructuralParenthesesRules.cs | 23 +- .../StructuralTypeNameReducer.cs | 839 +++++++++++++++--- .../StructuralTypeNameSelector.cs | 240 ++++- .../Statements/XmlDocExceptionStatement.cs | 13 +- .../src/Writers/CodeWriter.cs | 27 +- .../test/ProviderConditionalCleanupTests.cs | 2 + .../test/ProviderQualifierReductionTests.cs | 24 +- .../test/StructuralAttributeSafetyTests.cs | 18 +- .../test/StructuralAttributeTests.cs | 2 + .../StructuralBranchStaticReceiverTests.cs | 275 ++++++ ...ucturalC5CrefCompositionRegressionTests.cs | 6 +- ...ructuralC5CrefDiagnosticRegressionTests.cs | 17 +- .../StructuralC5CrefDiagnosticSafetyTests.cs | 16 +- ...cturalConditionalParenthesesSafetyTests.cs | 5 +- .../StructuralConditionalParenthesesTests.cs | 2 + ...lConditionalStaticReceiverImplicitTests.cs | 6 +- .../StructuralCrefImportActivationTests.cs | 180 ++++ .../test/StructuralCrefSafetyTests.cs | 23 +- .../StructuralImmediateNamespaceCrefTests.cs | 74 +- .../test/StructuralNamespacePrefixTests.cs | 19 +- .../test/StructuralNamespaceRoleTests.cs | 45 +- .../test/StructuralNamespaceSourceTests.cs | 62 +- .../StructuralOptionalAncestorScopeTests.cs | 105 +++ .../StructuralOptionalDefaultSafetyTests.cs | 18 +- .../test/StructuralOptionalDefaultTests.cs | 2 + .../StructuralOptionalStatementScopeTests.cs | 86 ++ .../test/StructuralParenthesesSafetyTests.cs | 23 +- ...StructuralSdkCastTypeQualificationTests.cs | 58 +- .../StructuralStaticReceiverSafetyTests.cs | 17 +- .../test/StructuralThisReceiverSafetyTests.cs | 12 +- .../test/StructuralThisReceiverTests.cs | 18 +- .../StructuralTypeNameIntegrationTests.cs | 48 +- ...StructuralTypeNameQualifiedContextTests.cs | 12 +- .../StructuralTypeNameRuleEvaluatorTests.cs | 9 +- .../test/StructuralTypeNameRuleOracleTests.cs | 48 +- 46 files changed, 2807 insertions(+), 405 deletions(-) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralOptionalReceiverRules.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralBranchStaticReceiverTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefImportActivationTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalAncestorScopeTests.cs create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalStatementScopeTests.cs diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md index 59848e57703..b6c8dee8111 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/ProviderReducerStatus.md @@ -1,60 +1,61 @@ # Provider reducer: current draft status -The accepted prototype checkpoint is **C9 plus the SDK-shaped cast/interface qualification slice**. `TYPESPEC_PROVIDER_REDUCER=structural` enables compiler-backed structural reduction; Roslyn remains the default. The older `true` provider prototype is retained for comparison. Writer grammar/default-emission fixes apply in both modes. This remains a draft, not a default rollout or complete Roslyn-simplifier replacement. +Opt-in compiler-backed structural reduction through C9, SDK-shaped cast/interface qualification, both Optional.IsDefined refinements, bounded cref import activation/exception provenance, and branch-local static invocations. Enable with `TYPESPEC_PROVIDER_REDUCER=structural`; **Roslyn remains the default**. The older `true` prototype remains for comparison. This is a draft, not a default rollout or complete Roslyn replacement. -## Applied rules +## Architecture and applied rules -- Record original writer type identities/spans and whole-type cref provenance. Share original-compilation facts and integrate reduction with member cleanup in one rewrite traversal and final serialization; uncertain cases retain their spelling rather than fall back to Roslyn. -- Shorten supported declaration/type names, namespace prefixes and aliases, including constructed generic heads and nested/array/nullable forms where certified. Conditional documents retain configuration/header/type-scope guards. -- Shorten supported static type receivers, including the bounded conditional-static certificate; this does not generally remove a complete member receiver. -- Shorten recorded interface heads and enclosing SELF-model arguments inside retained `((IPersistableModel)this)` call receivers. Require stable owning methods, an independent parameter import-use witness, exact composed-type binding and cast/receiver parser proofs; retain foreign model arguments and varying/source-observable/hidden cases. -- Remove certified field/property `this.` receivers with binding/conversion/source-observation guards. General method-call receivers remain outside that rule. -- Reduce certified attribute names/qualification and suffixes without rewriting attribute arguments. -- Reduce recorded whole-type XML crefs with serialized binding and import/diagnostic certificates, including C9's immediate-namespace permission. Exception provenance and broader imported/ancestor cases are not silently expanded. -- Simplify eligible optional-parameter defaults using import-use witnesses; emit valid `default(T)` forms where the writer must preserve a typed optional default. -- Remove certified expression parentheses, including conditional/statement and bounded cast-payload cases, while retaining grammar/context, trivia/visibility, caller-source and conversion safeguards. Assignment-expression statements remain legal unparenthesized C# statements. +Original writer type identities/spans and whole-type-cref provenance feed shared original-compilation facts, one rewrite traversal and final serialization. Unsupported or uncertain cases retain their spelling; there is no Roslyn fallback, per-edit production compilation or occurrence-acceptance cache. -Historical C3 cast-type qualification, C6 `nameof`, C7 method-call `this`, and C10 SDK-shaped generic inference remain **excluded**. The accepted SDK-shaped cast slice is a separate narrow certificate, not restoration of C3 or cast removal. +- Supported declaration/type qualifications, namespace prefixes and aliases, including certified constructed/nested/array/nullable and conditional forms. +- Static type receivers; field/property `this.` receivers with binding/conversion/source guards, not general method-call `this` removal. +- Retained `((IPersistableModel)this)` receivers: shorten recorded interface heads and enclosing SELF-model arguments only, preserving exact composed-type binding, import witnesses and parser/source guards. No cast removal or foreign-model expansion. +- Attribute names/suffixes, eligible optional defaults, and grammar-safe parentheses/statement cleanup. Writer assignment/default-emission repairs also apply in the default pipeline. +- Optional.IsDefined: exact/actual ancestor namespace with no intervening namespace-local import/extern activation; invariant affected statement/binding scope, allowing only certified later nested-block variation. +- Recorded whole-type crefs: existing strict/immediate certificates plus bounded bare top-level nongeneric type import activation. The intentional diagnostic delta is only disappearance of unsuppressed default-Hidden CS8019 when shortening genuinely makes the existing normal import used. Other diagnostics/reporting/source/binding checks remain. Real exception-cref provenance preserves original qualified writer text; generic/nested exception shapes retain the previous path. No code-witness certificate. +- Branch-local static invocations: ordinary block-method ownership, invariant complete method/occurrence in active parses; whole call disabled in inactive parses with matching ownership, visible single-line provenance and no overlapping active expression/argument/local-function source span. Original closure/outer-argument/source/lookup/token/directive safeguards remain. No blanket inactive-branch bypass or source-flag reset. -## Saved AppService diff gap +Paused serialization-context work and the deferred imported-cref code-witness experiment are excluded. Historical C3, C6 `nameof`, C7 method-call `this` and C10 SDK-shaped generic inference remain excluded. -Same saved AppService input and saved Roslyn 5.9 reference, **2,301 emitted files**. Counts are deduplicated syntax-taxonomy events, not Git lines or independent edits. +## Saved AppService reach -| Checkpoint | Residual events | -|---|---:| -| Initial structural slice | 165,725 | -| Accepted C9 (historical) | 29,336 | -| Current accepted C9 + SDK-shaped casts | 25,726 | -| Reduction | **139,999 (84.5%)** | +Same saved input and Roslyn5.9 reference, **2,301 emitted files**. Counts are deduplicated syntax-taxonomy events, not diff lines or safe-edit estimates. -The earlier combined/legacy provider prototype had 147,326 residual events; that is a different starting rule set, not the initial structural checkpoint. Rejected/deferred experiments are excluded from the current count. C9 alone removes 4,275 cref events across 894 files; its remaining cref count is 4,974. The accepted cast slice removes another **3,610 qualification events in 1,805 casts across 600 files**, leaving every other family unchanged. +| Checkpoint | Residual events | +| -------------------------- | --------------: | +| Initial structural slice | 165,725 | +| Historical accepted C9 | 29,336 | +| C9 + SDK-shaped casts | 25,726 | +| Both Optional refinements | 23,747 | +| Cref policy/provenance | 20,718 | +| Bounded branch-static rule | **19,463** | -## Remaining AppService pattern groups +Latest evaluation removed **1,255 static-receiver prefixes /596 files**:627 JsonDocument,627 JsonSerializer and1 BinaryData.FromString. Every other category was unchanged. Earlier Optional refinements removed1,979 events; cref policy/provenance removed3,029 events/202 files. -| Potential group | Events | -|---|---:| -| Cast/generic-argument type qualifications | 3,440 | -| Static helper/type receiver qualifications | 6,045 | -| XML cref qualifications | 4,974 | -| Method-call `this.` receivers | 3,834 | -| Qualified `nameof` operands | 2,399 | -| Complete member/type receivers | 1,678 | -| Attribute qualifications | 817 | -| Factory `(T)default` casts | 784 | -| Expression parentheses | 665 | -| Explicit generic call arguments | 560 | -| Object-creation type qualifications | 496 | -| Small keyword/pattern/null/default/declaration remainder | 34 | -| **Total** | **25,726** | +| Remaining group | Events | +| ----------------------------------------------------------- | ---------: | +| Type-name qualifications (cast/generic and object creation) | 3,940 | +| Method-call `this.` | 3,834 | +| Static receivers | 2,811 | +| Qualified `nameof` | 2,399 | +| XML crefs | 1,945 | +| Complete receivers | 1,678 | +| Attributes | 817 | +| Factory `(T)default` casts | 784 | +| Parentheses | 665 | +| Explicit generic arguments | 560 | +| Small remainder | 30 | +| **Total** | **19,463** | -The remaining interface/model-argument inventory has 1,188 qualification events in 594 `IPersistableModel` casts. Static receivers still include 2,279 Optional checks, 1,047 ModelReaderWriter uses, 1,047 serialization-context uses and 1,254 JSON fallback calls. Exception crefs account for 2,574 events. These counts are investigation opportunities, **not safe-edit estimates** or authorization to revive deferred rules. +## Validation and evidence boundaries -## Validation and limits +The latest pre-main-merge local epoch passed **308 focused Roslyn5.9 tests**, including21 new branch cases/direct checks. Six configurations compare operation trees, calls/conversions/constants and complete compiler/emit diagnostic multisets with all location endpoints. **No explicit CFG comparison is claimed.** Both the original Action-to-Func/CAE witness and inactive-only closure witness retain qualification; forced edits still change real synthesized strings, keeping the regressions meaningful. The existing directive-bearing receiver veto was traced and preserved, not relaxed to force a positive. -The current accepted cast source epoch passed 11 policy regressions, **3,824 Core and 2,509 client tests**, plus official unfiltered LOCAL/Spector (**956 passed, 10 skipped**). The sample discovered zero tests and is not runtime coverage. Source preservation covered all 2,301 files: strict decoding/byte comparison, with the 600 changed files parsed in six configurations and independently reconstructed through UTF-16 deletions with exact UTF-8/BOM/trivia parity; 49 source controls passed. Semantic checks matched **10,830 cast/full-method-operation comparisons**, complete ordinary/unfiltered compiler/emit diagnostic multisets, **752,232 independently projected locations and 619,488 source-syntax records**; 48 controls passed. C9's earlier 25,650 cref-binding comparisons remain historical baseline evidence, not newly rerun cast comparisons. +Authentic original reconstruction uses2,699 documents/20 references. The latest actual ProcessDocument case uses all116 writer records/138 type nodes, removes exactly2JSON prefixes, preserves exact constructed calls/receiver types and complete ordinary/unfiltered document diagnostic bags4→4. Original missing source-generator `.Default` errors are preserved, not repaired or ignored. Probe20.08s including7.78s first ProcessDocument/shared facts—not comparative timing. -Authentic original reconstruction uses 2,699 documents and 20 references. The explicit output-consumer view has 2,563 documents after symmetrically collapsing 136 original same-name/source/metadata-identical duplicate IDs. Original binding/diagnostic anchors run before that construction. Its existing failed emits are diagnostic evidence, not positive XML/runtime proof. +One candidate-only saved-input replay took115.5s, with no baseline generation/build or fresh TypeSpec compilation. All2,301 output hashes and every changed UTF16/BOM-preserving source reconstruction were independently checked; exact2,677-file SDK restoration and unrelated WIP/dependency preservation verified. These are reach/source/restoration evidence, **not whole-SDK semantic/compiler/emit/native/API clearance for this epoch**. -A successful candidate-only net10 build verified actual GA AppService 1.5.0 API compatibility and byte-identical whole emitted XML against authenticated saved accepted-baseline XML. All 2,677 SDK and 2,186 local/launch files were exactly restored. No fresh baseline build was required. Fresh netstandard2.0/net8.0 validation and comparative profiling remain deferred. The approximately **113-second** current whole-generation run (historical C9: approximately 119 seconds) is a rough single-run observation, not a reducer-only measurement or comparative speed claim. +Historical CAST validation remains historical:3,824 Core/2,509 client tests; official unfiltered LOCAL/Spector956 passed/10 skipped;10,830 semantic comparisons, complete compiler/emit bags and native net10 GA AppService1.5.0 API/byte-identical XML. It is not automatically inherited by the newer rules or a merge of main. The stopped serialization-context graph pipeline remains stopped. Fresh netstandard2.0/net8.0 and comparative profiling remain deferred. -Earlier architecture and measurement histories remain in [ProviderReducerPrototype.md](ProviderReducerPrototype.md) and [StructuralTypeNameReduction.md](StructuralTypeNameReduction.md); their historical scope limits and measurements are not current rollout claims. Large captured SDK inputs, outputs and local validation logs are intentionally not checked in. +Post-main-merge checks and their results are reported in the PR description. Broader SDK/native/API/whole-XML validation remains pending; no default enablement is implied. Large local captures, benchmarks and paused work are intentionally not checked in. + +Earlier architecture/history: [ProviderReducerPrototype.md](ProviderReducerPrototype.md), [StructuralTypeNameReduction.md](StructuralTypeNameReduction.md). diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs index d18c4281f6f..ecc6d3faef6 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ConditionalStaticReceiverRules.cs @@ -19,18 +19,30 @@ internal static bool HasHardSourceContext(SyntaxNode receiver) if (ancestor is AttributeSyntax or ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or InitializerExpressionSyntax or InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or QueryExpressionSyntax or PatternSyntax or WhenClauseSyntax or RefExpressionSyntax or AwaitExpressionSyntax) + { return true; + } + if (ancestor is ArgumentSyntax argument && (!argument.RefKindKeyword.IsKind(SyntaxKind.None) || argument.Parent is not ArgumentListSyntax list || list.Parent is not - (InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax))) return true; + (InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax))) + { + return true; + } // Implicit extension GetEnumerator/GetAsyncEnumerator can observe // COLLECTION source via CAE. Their capture does not include body // occurrences. Continue past lambdas to inspect every ancestor. - if (ancestor is CommonForEachStatementSyntax loop && loop.Expression.Span.Contains(receiver.Span)) return true; + if (ancestor is CommonForEachStatementSyntax loop && loop.Expression.Span.Contains(receiver.Span)) + { + return true; + } // Optional-CAE deconstruct patterns are rejected by the compiler; // keep this unneeded context conservative in the first slice. if (ancestor is AssignmentExpressionSyntax assignment && (assignment.Left is TupleExpressionSyntax or DeclarationExpressionSyntax) && - assignment.Right.Span.Contains(receiver.Span)) return true; + assignment.Right.Span.Contains(receiver.Span)) + { + return true; + } } return false; } @@ -60,7 +72,10 @@ static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item internal static bool SameUnit(BaseMethodDeclarationSyntax original, SyntaxNode variant) { if (variant.RawKind != original.RawKind || variant.Span != original.Span || variant.ContainsDiagnostics || - !SyntaxFactory.AreEquivalent(original, variant, static _ => false)) return false; + !SyntaxFactory.AreEquivalent(original, variant, static _ => false)) + { + return false; + } // Physical text alone can reconstruct the same file while changing // active/disabled syntax. Compare the complete active structure and // raw token coordinates, not only top-level headers or FullString. @@ -68,6 +83,74 @@ internal static bool SameUnit(BaseMethodDeclarationSyntax original, SyntaxNode v .SequenceEqual(variant.DescendantTokens().Select(static token => (token.RawKind, token.SpanStart, token.Span.Length, token.Text))); } + // Original call-observation facts are reusable only in invariant ACTIVE + // methods. Disabled text needs a separate source-observation certificate: + // an active lambda/argument can capture that text even with another overload. + internal static bool CanProveBranchLocalMethod(ExpressionSyntax receiver, MemberAccessExpressionSyntax member, + BaseMethodDeclarationSyntax unit, SyntaxNode[] roots, TextSpan prefix) + { + if (unit is not MethodDeclarationSyntax { Body: not null } method || + member.Parent is not InvocationExpressionSyntax call || call.Expression != member || + !method.Body.Span.Contains(call.Span) || HasHardSourceContext(receiver) || + receiver.Ancestors().TakeWhile(node => node != method).Any(node => node is + AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax or ArgumentSyntax or AttributeArgumentSyntax)) + { + return false; + } + + var hasInactiveRoot = false; + foreach (var root in roots) + { + if (root.FindNode(unit.Span, getInnermostNodeForTie: true) is not MethodDeclarationSyntax { Body: not null } variant || + variant.Span != method.Span || variant.ContainsDiagnostics || !variant.Body.Span.Contains(call.Span)) + { + return false; + } + + if (SameOccurrence(receiver, member, root, prefix)) + { + if (!SameUnit(method, variant)) + { + return false; + } + } + else + { + if (!HasUnobservedDisabledCall(call, root, prefix)) + { + return false; + } + + hasInactiveRoot = true; + } + } + return hasInactiveRoot; + } + + internal static bool HasUnobservedDisabledCall(InvocationExpressionSyntax call, SyntaxNode root, TextSpan prefix) + { + if (prefix.Length == 0 || !call.Span.Contains(prefix) || + !root.DescendantTrivia().Any(trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia) && trivia.Span.Contains(call.Span))) + { + return false; + } + + var tree = root.SyntaxTree; + var line = tree.GetLineSpan(prefix); + if (line.StartLinePosition.Line != line.EndLinePosition.Line || + tree.GetLineVisibility(prefix.Start) != LineVisibility.Visible || + tree.GetLineVisibility(prefix.End - 1) != LineVisibility.Visible) + { + return false; + } + // Deliberately stronger than checking selected CAE attributes: reject + // ANY active expression enclosing/intersecting the edit. This includes + // implicit handler/foreach contexts and lambdas introduced only here. + return !root.DescendantNodes().Any(node => + (node is ExpressionSyntax or ArgumentSyntax or AttributeArgumentSyntax or LocalFunctionStatementSyntax) && + node.Span.OverlapsWith(prefix)); + } + internal static bool SameOccurrence(ExpressionSyntax original, MemberAccessExpressionSyntax member, SyntaxNode root, TextSpan prefix) { var candidate = root.FindNode(original.Span, getInnermostNodeForTie: true); @@ -75,7 +158,11 @@ internal static bool SameOccurrence(ExpressionSyntax original, MemberAccessExpre !SyntaxFactory.AreEquivalent(original, candidate, static _ => false) || candidate.Parent is not MemberAccessExpressionSyntax access || access.Expression != candidate || access.RawKind != member.RawKind || access.Span != member.Span || - !SyntaxFactory.AreEquivalent(member, access, static _ => false)) return false; + !SyntaxFactory.AreEquivalent(member, access, static _ => false)) + { + return false; + } + var tree = root.SyntaxTree; var line = tree.GetLineSpan(prefix); return prefix.Length != 0 && line.StartLinePosition.Line == line.EndLinePosition.Line && diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs index 7a24a7e125f..6d0f9c5497e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ExpressionReceiverSyntaxFacts.cs @@ -25,8 +25,16 @@ internal static bool MayExposeCastParse(SyntaxNode receiver) // guard: inspect the full enclosing chain and keep hazardous follow // tokens. This does not remove or rewrite the parentheses. SyntaxNode chain = receiver; - while (chain.Parent is MemberAccessExpressionSyntax parent && parent.Expression == chain) chain = parent; - if (chain.Parent is not ParenthesizedExpressionSyntax parentheses) return false; + while (chain.Parent is MemberAccessExpressionSyntax parent && parent.Expression == chain) + { + chain = parent; + } + + if (chain.Parent is not ParenthesizedExpressionSyntax parentheses) + { + return false; + } + var next = parentheses.GetLastToken().GetNextToken().Kind(); return next is SyntaxKind.TildeToken or SyntaxKind.ExclamationToken or SyntaxKind.OpenParenToken || SyntaxFacts.IsKeywordKind(next) && next is not (SyntaxKind.AsKeyword or SyntaxKind.IsKeyword); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs index f02dd1c227d..07cccdd2c7b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/GeneratedCodeWorkspace.cs @@ -131,8 +131,14 @@ internal static SyntaxTree GetTree(TypeProvider provider) var compilation = await _project.GetCompilationAsync(); if (compilation != null) { - if (_structuralMode) structuralReducer = new StructuralTypeNameReducer(compilation); - else providerReducer = new ProviderNameReducer(compilation); + if (_structuralMode) + { + structuralReducer = new StructuralTypeNameReducer(compilation); + } + else + { + providerReducer = new ProviderNameReducer(compilation); + } } } List> documents = new List>(); @@ -191,7 +197,11 @@ private async Task ProcessDocument(Document document, MemberRemoverRew } if (_structuralMode) { - if (ReferenceEquals(root, originalRoot)) PostProcessingProfile?.Add("StructuralTypeNames.KeepQualified", TimeSpan.Zero, 0); + if (ReferenceEquals(root, originalRoot)) + { + PostProcessingProfile?.Add("StructuralTypeNames.KeepQualified", TimeSpan.Zero, 0); + } + return ReferenceEquals(root, originalRoot) ? document : document.WithSyntaxRoot(root); } if (!CodeModelGenerator.Instance.Configuration.DisableRoslynReduce) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs index b0570ffa5bc..4fead3b266d 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ParenthesesSyntaxFacts.cs @@ -14,7 +14,11 @@ internal static class ParenthesesSyntaxFacts { internal static bool TokensStaySeparate(SyntaxToken left, SyntaxToken right) { - if (left.RawKind == 0 || right.RawKind == 0) return true; + if (left.RawKind == 0 || right.RawKind == 0) + { + return true; + } + var parsed = SyntaxFactory.ParseTokens(left.Text + right.Text).Where(static t => !t.IsKind(SyntaxKind.EndOfFileToken)).ToArray(); return parsed.Length == 2 && parsed[0].RawKind == left.RawKind && parsed[0].Text == left.Text && parsed[1].RawKind == right.RawKind && parsed[1].Text == right.Text; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs index eab4f56ea22..da53500454c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderConditionalSyntax.cs @@ -35,8 +35,16 @@ internal static bool HasConditional(SyntaxNode root) => root.ContainsDirectives internal bool TryGetRoots(SyntaxNode root, out SyntaxNode[] roots) { roots = [root]; - if (!_stableDeclarations.Value) return false; - if (!TreeHasConditional(root)) return true; + if (!_stableDeclarations.Value) + { + return false; + } + + if (!TreeHasConditional(root)) + { + return true; + } + return _roots.TryGetValue(root.SyntaxTree, out roots!); } @@ -58,14 +66,24 @@ private bool ValidateCompilation() foreach (var tree in _compilation.SyntaxTrees) { var root = tree.GetRoot(); - if (!TreeHasConditional(root)) continue; - if (!HasInvariantHeaders(root)) return false; + if (!TreeHasConditional(root)) + { + continue; + } + + if (!HasInvariantHeaders(root)) + { + return false; + } // A fixed-project-flag Shared document may be opaque itself while // proving that it cannot change lookup in other documents. if (TryParseInvariantBodies(root, out var roots, out var hasGenericLocalFunctions)) { _roots.TryAdd(tree, roots); - if (!hasGenericLocalFunctions) _typeOnlyTrees.TryAdd(tree, true); + if (!hasGenericLocalFunctions) + { + _typeOnlyTrees.TryAdd(tree, true); + } } } return true; @@ -73,25 +91,44 @@ private bool ValidateCompilation() private static bool HasInvariantHeaders(SyntaxNode root) { - if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) return false; + if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) + { + return false; + } + var directives = root.DescendantTrivia().Where(static t => t.IsDirective) .Select(static t => t.GetStructure()).OfType().ToArray(); var used = new HashSet(StringComparer.Ordinal); foreach (var directive in directives) { - if (directive is DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) return false; + if (directive is DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) + { + return false; + } + var condition = directive switch { IfDirectiveTriviaSyntax branch => branch.Condition, ElifDirectiveTriviaSyntax branch => branch.Condition, _ => null }; - if (condition == null) continue; + if (condition == null) + { + continue; + } + foreach (var name in condition.DescendantNodesAndSelf().OfType()) { var symbol = name.Identifier.ValueText; - if (symbol.StartsWith("NET", StringComparison.Ordinal) && !HeaderThresholds.Contains(symbol)) return false; - if (HeaderThresholds.Contains(symbol)) used.Add(symbol); + if (symbol.StartsWith("NET", StringComparison.Ordinal) && !HeaderThresholds.Contains(symbol)) + { + return false; + } + + if (HeaderThresholds.Contains(symbol)) + { + used.Add(symbol); + } } } // Only framework thresholds vary. Preserve the exact original project @@ -104,9 +141,16 @@ private static bool HasInvariantHeaders(SyntaxNode root) for (var level = 0; level <= HeaderThresholds.Length; level++) { var active = HeaderThresholds.Take(level).ToArray(); - if (!signatures.Add(Signature(active))) continue; + if (!signatures.Add(Signature(active))) + { + continue; + } + var variant = CSharpSyntaxTree.ParseText(root.SyntaxTree.GetText(), options.WithPreprocessorSymbols(fixedSymbols.Concat(active)), root.SyntaxTree.FilePath).GetRoot(); - if (variant.ContainsDiagnostics || !string.Equals(expected, HeaderShape(variant), StringComparison.Ordinal)) return false; + if (variant.ContainsDiagnostics || !string.Equals(expected, HeaderShape(variant), StringComparison.Ordinal)) + { + return false; + } } return true; } @@ -115,21 +159,40 @@ private static bool TryParseInvariantBodies(SyntaxNode root, out SyntaxNode[] ro { roots = []; hasGenericLocalFunctions = false; - if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) return false; + if (root.ContainsDiagnostics || root.SyntaxTree.Options is not CSharpParseOptions options) + { + return false; + } + var directives = root.DescendantTrivia().Where(static t => t.IsDirective).Select(static t => t.GetStructure()).OfType().ToArray(); var used = new HashSet(StringComparer.Ordinal); foreach (var directive in directives) { if (directive is IfDirectiveTriviaSyntax condition) { - if (condition.Condition is not IdentifierNameSyntax name || !Thresholds.Contains(name.Identifier.ValueText)) return false; + if (condition.Condition is not IdentifierNameSyntax name || !Thresholds.Contains(name.Identifier.ValueText)) + { + return false; + } + used.Add(name.Identifier.ValueText); } - else if (directive is ElifDirectiveTriviaSyntax or DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) return false; + else if (directive is ElifDirectiveTriviaSyntax or DefineDirectiveTriviaSyntax or UndefDirectiveTriviaSyntax) + { + return false; + } + } + if (used.Count == 0) + { + return false; } - if (used.Count == 0) return false; + var fixedSymbols = options.PreprocessorSymbolNames.Where(static s => !Thresholds.Contains(s)).ToArray(); - if (!BodyShape(root, out var originalShape, out hasGenericLocalFunctions)) return false; + if (!BodyShape(root, out var originalShape, out hasGenericLocalFunctions)) + { + return false; + } + var originalSymbols = options.PreprocessorSymbolNames.ToHashSet(StringComparer.Ordinal); var signatures = new HashSet(StringComparer.Ordinal) { @@ -140,13 +203,25 @@ private static bool TryParseInvariantBodies(SyntaxNode root, out SyntaxNode[] ro { var active = Thresholds.Take(level).ToArray(); var signature = string.Join(";", used.Order(StringComparer.Ordinal).Select(s => active.Contains(s) ? "1" : "0")); - if (!signatures.Add(signature)) continue; + if (!signatures.Add(signature)) + { + continue; + } + var symbols = fixedSymbols.Concat(active).ToArray(); var variant = options.PreprocessorSymbolNames.ToHashSet(StringComparer.Ordinal).SetEquals(symbols) ? root : CSharpSyntaxTree.ParseText(root.SyntaxTree.GetText(), options.WithPreprocessorSymbols(symbols), root.SyntaxTree.FilePath).GetRoot(); - if (variant.ContainsDiagnostics || !BodyShape(variant, out var shape, out var variantHasGenericLocalFunctions)) return false; + if (variant.ContainsDiagnostics || !BodyShape(variant, out var shape, out var variantHasGenericLocalFunctions)) + { + return false; + } + hasGenericLocalFunctions |= variantHasGenericLocalFunctions; - if (!string.Equals(shape, originalShape, StringComparison.Ordinal)) return false; + if (!string.Equals(shape, originalShape, StringComparison.Ordinal)) + { + return false; + } + parsed.Add(variant); } roots = parsed.ToArray(); @@ -160,12 +235,19 @@ private static bool BodyShape(SyntaxNode root, out string shape, out bool hasGen foreach (var trivia in root.DescendantTrivia().Where(static t => t.IsKind(SyntaxKind.IfDirectiveTrivia) || t.IsKind(SyntaxKind.ElifDirectiveTrivia) || t.IsKind(SyntaxKind.ElseDirectiveTrivia) || t.IsKind(SyntaxKind.EndIfDirectiveTrivia))) { - if (!bodies.Any(body => body.Contains(trivia.FullSpan))) return false; + if (!bodies.Any(body => body.Contains(trivia.FullSpan))) + { + return false; + } + var token = trivia.Token; var statement = token.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); // Branches must start/end at complete statement boundaries. Never // rejoin fragments of an expression, argument list, or declaration. - if (statement?.SpanStart != token.SpanStart && !(token.Parent is BlockSyntax block && block.CloseBraceToken == token)) return false; + if (statement?.SpanStart != token.SpanStart && !(token.Parent is BlockSyntax block && block.CloseBraceToken == token)) + { + return false; + } } shape = HeaderShape(root); return true; @@ -201,20 +283,31 @@ private static string HeaderShape(SyntaxNode root) { var bodies = Bodies(root); var builder = new StringBuilder(); - foreach (var body in bodies) builder.Append(body.Start).Append(':').Append(body.Length).Append(';'); + foreach (var body in bodies) + { + builder.Append(body.Start).Append(':').Append(body.Length).Append(';'); + } // Directive state can escape a body even when its ordinary tokens do // not: e.g. #nullable changes later declaration semantics. foreach (var directive in root.DescendantTrivia().Where(static t => t.IsDirective) .Select(static t => t.GetStructure()).OfType()) { if (directive is IfDirectiveTriviaSyntax or ElifDirectiveTriviaSyntax or ElseDirectiveTriviaSyntax or EndIfDirectiveTriviaSyntax or - RegionDirectiveTriviaSyntax or EndRegionDirectiveTriviaSyntax) continue; + RegionDirectiveTriviaSyntax or EndRegionDirectiveTriviaSyntax) + { + continue; + } + builder.Append(directive.SpanStart).Append(':').Append(directive.RawKind).Append(':') .Append(directive.IsActive).Append(':').Append(directive.ToFullString()).Append(';'); } foreach (var token in root.DescendantTokens()) { - if (bodies.Any(body => body.Contains(token.Span))) continue; + if (bodies.Any(body => body.Contains(token.Span))) + { + continue; + } + builder.Append(token.SpanStart).Append(':').Append(token.RawKind).Append(':').Append(token.Text.Length).Append(':').Append(token.Text).Append(';'); } return builder.ToString(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs index a4eeddc28c1..985420ee6f9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderNameReducer.cs @@ -65,7 +65,10 @@ private static bool TryMatchReferences(IReadOnlyList roots, IReadOnl { var site = references[index]; if (!roots[0].FullSpan.Contains(site.Span) || !sites.TryAdd(site.Span, index) || - text.ToString(site.Span) != "global::" + site.Type.FullyQualifiedName) return false; + text.ToString(site.Span) != "global::" + site.Type.FullyQualifiedName) + { + return false; + } } foreach (var root in roots) { @@ -73,17 +76,38 @@ private static bool TryMatchReferences(IReadOnlyList roots, IReadOnl foreach (var node in root.DescendantNodes(descendIntoTrivia: documentation)) { var name = ReferenceName(node); - if (name == null || !sites.TryGetValue(TextSpan.FromBounds(node.SpanStart, name.Identifier.Span.End), out var index)) continue; - if ((node is CrefSyntax) != (references[index].Role == TypeReferenceRole.WholeTypeCref)) continue; - if (!seen.Add(index) || name.Arity != references[index].Type.Arguments.Count) return false; - if (matches[index] != null && (matches[index].RawKind != node.RawKind || matches[index].Span != node.Span || matches[index].Parent?.RawKind != node.Parent?.RawKind)) return false; + if (name == null || !sites.TryGetValue(TextSpan.FromBounds(node.SpanStart, name.Identifier.Span.End), out var index)) + { + continue; + } + + if ((node is CrefSyntax) != (references[index].Role == TypeReferenceRole.WholeTypeCref)) + { + continue; + } + + if (!seen.Add(index) || name.Arity != references[index].Type.Arguments.Count) + { + return false; + } + + if (matches[index] != null && (matches[index].RawKind != node.RawKind || matches[index].Span != node.Span || matches[index].Parent?.RawKind != node.Parent?.RawKind)) + { + return false; + } + matches[index] ??= node; } if (seen.Count != references.Count) { var disabled = root.DescendantTrivia().Where(static t => t.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static t => t.Span).ToArray(); for (var i = 0; i < references.Count; i++) - if (!seen.Contains(i) && !disabled.Any(span => span.Contains(references[i].Span))) return false; + { + if (!seen.Contains(i) && !disabled.Any(span => span.Contains(references[i].Span))) + { + return false; + } + } } } return matches.All(static node => node != null); @@ -238,7 +262,10 @@ internal bool TryPlan(SyntaxNode syntaxRoot, IReadOnlyList re QueryContinuationSyntax clause => clause.Identifier, _ => default }; - if (!identifier.IsKind(SyntaxKind.None)) expressionBlockedNames.Add(identifier.ValueText); + if (!identifier.IsKind(SyntaxKind.None)) + { + expressionBlockedNames.Add(identifier.ValueText); + } } var decisions = new Dictionary<(string Namespace, string MetadataName, LookupKind Kind), Reduction>(); var deletions = new List(); @@ -246,21 +273,39 @@ internal bool TryPlan(SyntaxNode syntaxRoot, IReadOnlyList re { var node = matches[index]; var terminal = ReferenceName(node); - if (terminal == null) continue; + if (terminal == null) + { + continue; + } + LookupKind kind; if (references[index].Role == TypeReferenceRole.WholeTypeCref && node is QualifiedCrefSyntax) + { kind = LookupKind.Cref; + } else if (node.Parent is AttributeSyntax attribute && attribute.Name == node) + { kind = LookupKind.Attribute; + } else if (node.Parent is MemberAccessExpressionSyntax receiver && receiver.Expression == node) + { kind = LookupKind.StaticReceiver; + } else if (node is QualifiedNameSyntax name && IsSupportedTypePosition(name)) + { kind = LookupKind.Type; + } else + { continue; + } var type = references[index].Type; - if (type.DeclaringType != null) continue; + if (type.DeclaringType != null) + { + continue; + } + var key = (type.Namespace, type.ClrMetadataName, kind); if (!decisions.TryGetValue(key, out var reduction)) { @@ -270,9 +315,14 @@ internal bool TryPlan(SyntaxNode syntaxRoot, IReadOnlyList re decisions.Add(key, reduction); } if (reduction.ShortenPrefix) + { deletions.Add(TextSpan.FromBounds(node.SpanStart, terminal.SpanStart)); + } + if (reduction.OmitAttributeSuffix) + { deletions.Add(new TextSpan(terminal.Identifier.Span.End - "Attribute".Length, "Attribute".Length)); + } } ProviderSyntaxCleanup.AddEdits(roots, _compilation, namespaceName, usings.Where(static u => u.Alias != null).Select(static u => u.Alias!.Name.Identifier.ValueText), deletions); @@ -348,11 +398,23 @@ private Reduction ReduceAttribute(CSharpType type, HashSet namespaces, H var target = FindType(type.Namespace.Length == 0 ? type.ClrMetadataName : type.Namespace + "." + type.ClrMetadataName); var attribute = FindType("System.Attribute"); if (target == null || target.TypeKind != TypeKind.Class || target.Arity != 0 || target.IsAbstract || attribute == null) + { return default; + } + var derivesFromAttribute = false; for (var current = target.BaseType; current != null; current = current.BaseType) - if (SymbolEqualityComparer.Default.Equals(current, attribute)) derivesFromAttribute = true; - if (!derivesFromAttribute) return default; + { + if (SymbolEqualityComparer.Default.Equals(current, attribute)) + { + derivesFromAttribute = true; + } + } + + if (!derivesFromAttribute) + { + return default; + } var shortName = type.Name.EndsWith("Attribute", StringComparison.Ordinal) ? type.Name[..^"Attribute".Length] : string.Empty; var canOmitSuffix = shortName.Length > 0 && SyntaxFacts.IsValidIdentifier(shortName) && @@ -362,9 +424,14 @@ private Reduction ReduceAttribute(CSharpType type, HashSet namespaces, H // across the entire document by conservatively retaining all of them. SyntaxFacts.GetContextualKeywordKind(shortName) == SyntaxKind.None; if (canOmitSuffix && CanUseAttributeName(target, shortName, namespaces, blockedNames)) + { return new Reduction(true, true); + } + if (CanUseAttributeName(target, type.Name, namespaces, blockedNames)) + { return new Reduction(true); + } // A global-qualified short spelling does not consult local aliases or // imported namespaces. Preserve qualification if only this form is safe. return new Reduction(false, canOmitSuffix && CanUseAttributeName(target, shortName, new[] { type.Namespace }, null)); @@ -373,7 +440,11 @@ private Reduction ReduceAttribute(CSharpType type, HashSet namespaces, H private bool CanUseAttributeName(INamedTypeSymbol target, string spelling, IEnumerable namespaces, HashSet? blockedNames) { var expanded = spelling + "Attribute"; - if (blockedNames != null && (blockedNames.Contains(spelling) || blockedNames.Contains(expanded))) return false; + if (blockedNames != null && (blockedNames.Contains(spelling) || blockedNames.Contains(expanded))) + { + return false; + } + var found = false; foreach (var ns in namespaces) { @@ -381,9 +452,21 @@ private bool CanUseAttributeName(INamedTypeSymbol target, string spelling, IEnum { foreach (var member in FindMembers(ns, candidate)) { - if (member is not INamedTypeSymbol named) return false; - if (named.Arity != 0) continue; - if (!SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, target.OriginalDefinition)) return false; + if (member is not INamedTypeSymbol named) + { + return false; + } + + if (named.Arity != 0) + { + continue; + } + + if (!SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, target.OriginalDefinition)) + { + return false; + } + found = true; } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs index 9fa2cf61f64..c04132ed900 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/ProviderSyntaxCleanup.cs @@ -19,7 +19,8 @@ internal static void AddEdits(IReadOnlyList roots, Compilation compi { var lexical = new HashSet(aliases, StringComparer.Ordinal) { "value", "field" }; foreach (var root in roots) - foreach (var node in root.DescendantNodes()) + { + foreach (var node in root.DescendantNodes()) { var token = node switch { @@ -39,11 +40,17 @@ internal static void AddEdits(IReadOnlyList roots, Compilation compi QueryContinuationSyntax c => c.Identifier, _ => default }; - if (!token.IsKind(SyntaxKind.None)) lexical.Add(token.ValueText); + if (!token.IsKind(SyntaxKind.None)) + { + lexical.Add(token.ValueText); + } + } } + var members = new Dictionary?>(StringComparer.Ordinal); foreach (var root in roots) - foreach (var node in root.DescendantNodes()) + { + foreach (var node in root.DescendantNodes()) { if (node is ParenthesizedExpressionSyntax expression && !expression.Ancestors().Any(static parent => parent is ParenthesizedExpressionSyntax)) { @@ -53,11 +60,19 @@ internal static void AddEdits(IReadOnlyList roots, Compilation compi var removeAll = SafeExpression(payload) && CanRemove(expression, payload); // Retained chains can be cast-disambiguating: ((f))(1). if (!removeAll || !SafeBoundaries(chain[0].OpenParenToken, chain[^1].OpenParenToken, - payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) continue; - foreach (var wrapper in chain) + payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) + { + continue; + } + + foreach (var wrapper in chain) { - if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives || wrapper.OpenParenToken.IsMissing || wrapper.CloseParenToken.IsMissing) continue; - edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); + if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives || wrapper.OpenParenToken.IsMissing || wrapper.CloseParenToken.IsMissing) + { + continue; + } + + edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); } } else if (node is ParenthesizedPatternSyntax pattern && !pattern.Ancestors().Any(static parent => parent is ParenthesizedPatternSyntax or ParenthesizedExpressionSyntax)) @@ -65,26 +80,46 @@ internal static void AddEdits(IReadOnlyList roots, Compilation compi var chain = new List(); PatternSyntax payload = pattern; while (payload is ParenthesizedPatternSyntax wrapper) { chain.Add(wrapper); payload = wrapper.Pattern; } - if (!SafePattern(payload)) continue; - var removeAll = pattern.Parent is IsPatternExpressionSyntax or CasePatternSwitchLabelSyntax or SwitchExpressionArmSyntax or SubpatternSyntax || + if (!SafePattern(payload)) + { + continue; + } + + var removeAll = pattern.Parent is IsPatternExpressionSyntax or CasePatternSwitchLabelSyntax or SwitchExpressionArmSyntax or SubpatternSyntax || pattern.Parent is BinaryPatternSyntax binary && PatternPrecedence(payload) >= PatternPrecedence(binary) || pattern.Parent is UnaryPatternSyntax && PatternPrecedence(payload) > 2; if (!removeAll || !SafeBoundaries(chain[0].OpenParenToken, chain[^1].OpenParenToken, - payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) continue; - foreach (var wrapper in chain) + payload, chain[^1].CloseParenToken, chain[0].CloseParenToken)) + { + continue; + } + + foreach (var wrapper in chain) { - if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives) continue; - edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); + if (wrapper.ContainsDiagnostics || wrapper.ContainsDirectives) + { + continue; + } + + edits.Add(wrapper.OpenParenToken.Span); edits.Add(wrapper.CloseParenToken.Span); } } else if (node is MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: IdentifierNameSyntax name } member) { var identifier = name.Identifier.ValueText; if (identifier == "Object" || lexical.Contains(identifier) || SyntaxFacts.GetContextualKeywordKind(identifier) != SyntaxKind.None || - member.Parent is InvocationExpressionSyntax call && call.Expression == member || member.ContainsDiagnostics || member.ContainsDirectives) continue; - var declaration = member.Ancestors().OfType().FirstOrDefault(); - if (declaration == null) continue; - var names = declaration.AncestorsAndSelf().OfType().Reverse().Select(static d => + member.Parent is InvocationExpressionSyntax call && call.Expression == member || member.ContainsDiagnostics || member.ContainsDirectives) + { + continue; + } + + var declaration = member.Ancestors().OfType().FirstOrDefault(); + if (declaration == null) + { + continue; + } + + var names = declaration.AncestorsAndSelf().OfType().Reverse().Select(static d => d.Identifier.ValueText + (d.TypeParameterList is { } p ? "`" + p.Parameters.Count : string.Empty)); var metadata = (namespaceName.Length == 0 ? string.Empty : namespaceName + ".") + string.Join("+", names); if (!members.TryGetValue(metadata, out var eligible)) @@ -93,36 +128,68 @@ internal static void AddEdits(IReadOnlyList roots, Compilation compi eligible = EligibleMembers(type, compilation); members.Add(metadata, eligible); } - if (eligible?.Contains(identifier) != true) continue; - var prefix = TextSpan.FromBounds(member.SpanStart, name.SpanStart); + if (eligible?.Contains(identifier) != true) + { + continue; + } + + var prefix = TextSpan.FromBounds(member.SpanStart, name.SpanStart); // Do not erase comments or directives attached to this/dot. - if (root.SyntaxTree.GetText().ToString(prefix) != "this.") continue; - edits.Add(prefix); + if (root.SyntaxTree.GetText().ToString(prefix) != "this.") + { + continue; + } + + edits.Add(prefix); } } + } } private static HashSet? EligibleMembers(INamedTypeSymbol? type, Compilation compilation) { if (type == null || type.DeclaringSyntaxReferences.Any(static reference => - reference.GetSyntax().ChildNodes().OfType().Any())) return null; + reference.GetSyntax().ChildNodes().OfType().Any())) + { + return null; + } + var groups = new Dictionary>(StringComparer.Ordinal); for (var current = type; current != null; current = current.BaseType) { - if (current.TypeKind == TypeKind.Error) return null; + if (current.TypeKind == TypeKind.Error) + { + return null; + } + foreach (var member in current.GetMembers()) { - if (!groups.TryGetValue(member.Name, out var list)) groups.Add(member.Name, list = new List()); + if (!groups.TryGetValue(member.Name, out var list)) + { + groups.Add(member.Name, list = new List()); + } + list.Add(member); } } var result = new HashSet(StringComparer.Ordinal); foreach (var (name, values) in groups) { - if (values.Count != 1) continue; + if (values.Count != 1) + { + continue; + } + var member = values[0]; - if (member.IsStatic || !compilation.IsSymbolAccessibleWithin(member, type)) continue; - if (member is IFieldSymbol || member is IPropertySymbol { IsIndexer: false, ExplicitInterfaceImplementations.Length: 0 }) result.Add(name); + if (member.IsStatic || !compilation.IsSymbolAccessibleWithin(member, type)) + { + continue; + } + + if (member is IFieldSymbol || member is IPropertySymbol { IsIndexer: false, ExplicitInterfaceImplementations.Length: 0 }) + { + result.Add(name); + } } return result; } @@ -167,8 +234,16 @@ private static bool CanRemove(ParenthesizedExpressionSyntax wrapper, ExpressionS case ConditionalExpressionSyntax s: return s.Condition == wrapper && Precedence(payload) > 2; case BinaryExpressionSyntax s: var child = Precedence(payload); var parent = Precedence(s); - if (child <= 0 || parent <= 0) return false; - if (child != parent) return child > parent; + if (child <= 0 || parent <= 0) + { + return false; + } + + if (child != parent) + { + return child > parent; + } + return s.IsKind(SyntaxKind.CoalesceExpression) ? s.Right == wrapper : s.Left == wrapper; default: return false; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs index b7d2d9f76a5..72befb281fd 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralAttributeRules.cs @@ -23,15 +23,27 @@ internal static class StructuralAttributeRules AliasQualifiedNameSyntax { Name: IdentifierNameSyntax identifier } => identifier, _ => null }; - if (terminal == null) return null; + if (terminal == null) + { + return null; + } + const string suffix = "Attribute"; var token = terminal.Identifier; if (!token.Text.EndsWith(suffix, StringComparison.Ordinal) || !token.ValueText.EndsWith(suffix, StringComparison.Ordinal) || - token.ValueText.Length == suffix.Length) return null; + token.ValueText.Length == suffix.Length) + { + return null; + } + var value = token.ValueText[..^suffix.Length]; // Constructed identifiers can bind despite invalid serialized keyword // syntax. Keep contextual forms too, including newer-consumer field. - if (SyntaxFacts.GetKeywordKind(value) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(value) != SyntaxKind.None || value == "field") return null; + if (SyntaxFacts.GetKeywordKind(value) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(value) != SyntaxKind.None || value == "field") + { + return null; + } + var shortened = SyntaxFactory.IdentifierName(SyntaxFactory.Identifier(token.LeadingTrivia, SyntaxKind.None, token.Text[..^suffix.Length], value, token.TrailingTrivia)); return name.ReplaceNode(terminal, shortened); @@ -41,9 +53,17 @@ internal static class StructuralAttributeRules { var spelling = name.ToString(); var parsed = SyntaxFactory.ParseCompilationUnit("[" + spelling + "] class __AttributeProof__ {}", options: options); - if (parsed.ContainsDiagnostics) return null; + if (parsed.ContainsDiagnostics) + { + return null; + } + var attributes = parsed.DescendantNodes().OfType().ToArray(); - if (attributes.Length != 1 || attributes[0].Name.ToString() != spelling) return null; + if (attributes.Length != 1 || attributes[0].Name.ToString() != spelling) + { + return null; + } + return attributes[0].Name; } @@ -60,7 +80,11 @@ internal static bool SameContract(IOperation? left, IOperation? right) right.Type?.TypeKind is TypeKind.Error or TypeKind.Dynamic or TypeKind.Pointer or TypeKind.FunctionPointer || left.Kind != right.Kind || !Symbol(left.Type, right.Type) || left.IsImplicit != right.IsImplicit || left.ConstantValue.HasValue != right.ConstantValue.HasValue || - left.ConstantValue.HasValue && !Equals(left.ConstantValue.Value, right.ConstantValue.Value)) return false; + left.ConstantValue.HasValue && !Equals(left.ConstantValue.Value, right.ConstantValue.Value)) + { + return false; + } + var same = (left, right) switch { (IAttributeOperation, IAttributeOperation) => true, @@ -88,10 +112,21 @@ internal static bool SameContract(IOperation? left, IOperation? right) (IParenthesizedOperation, IParenthesizedOperation) => true, _ => false }; - if (!same) return false; + if (!same) + { + return false; + } + var first = left.ChildOperations.GetEnumerator(); var second = right.ChildOperations.GetEnumerator(); - while (first.MoveNext()) if (!second.MoveNext() || !SameContract(first.Current, second.Current)) return false; + while (first.MoveNext()) + { + if (!second.MoveNext() || !SameContract(first.Current, second.Current)) + { + return false; + } + } + return !second.MoveNext(); } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs index 74b3c43485c..6a85006a0ed 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralCrefImportRules.cs @@ -20,21 +20,38 @@ internal static bool SupportsCompilation(Compilation compilation) if (compilation is not CSharpCompilation csharp || csharp.ScriptCompilationInfo != null || !csharp.Options.Usings.IsDefaultOrEmpty || csharp.Options.GeneralDiagnosticOption != ReportDiagnostic.Default || csharp.Options.SpecificDiagnosticOptions.Count != 0) + { return false; + } + foreach (var tree in compilation.SyntaxTrees) { if (tree.Options is not CSharpParseOptions { Kind: SourceCodeKind.Regular } options || options.Features.Keys.Any(static key => key.IndexOf("interceptor", StringComparison.OrdinalIgnoreCase) >= 0) || - tree.GetRoot() is not CompilationUnitSyntax root) return false; + tree.GetRoot() is not CompilationUnitSyntax root) + { + return false; + } // Global imports can change diagnostics in OTHER documents. Aliases // and static imports need an independent usage certificate. Inspect // only import-bearing namespace containers, not method bodies. - if (UnsupportedImports(root.Usings, root.Externs.Count)) return false; + if (UnsupportedImports(root.Usings, root.Externs.Count)) + { + return false; + } + var pending = new Stack(root.Members.OfType()); while (pending.TryPop(out var ns)) { - if (UnsupportedImports(ns.Usings, ns.Externs.Count)) return false; - foreach (var nested in ns.Members.OfType()) pending.Push(nested); + if (UnsupportedImports(ns.Usings, ns.Externs.Count)) + { + return false; + } + + foreach (var nested in ns.Members.OfType()) + { + pending.Push(nested); + } } } return true; @@ -72,14 +89,41 @@ public override bool TryGetGlobalDiagnosticValue(string diagnosticId, Cancellati internal static bool CanEraseTypeWithoutImportUse(TypeSyntax type, SemanticModel model) { - if (type is PredefinedTypeSyntax || model.GetTypeInfo(type).Type is ITypeParameterSymbol) return true; - if (type is ArrayTypeSyntax array) return CanEraseTypeWithoutImportUse(array.ElementType, model); - if (type is NullableTypeSyntax nullable) return CanEraseTypeWithoutImportUse(nullable.ElementType, model); - if (type is TupleTypeSyntax tuple) return tuple.Elements.All(element => CanEraseTypeWithoutImportUse(element.Type, model)); - if (type is not NameSyntax name) return false; + if (type is PredefinedTypeSyntax || model.GetTypeInfo(type).Type is ITypeParameterSymbol) + { + return true; + } + + if (type is ArrayTypeSyntax array) + { + return CanEraseTypeWithoutImportUse(array.ElementType, model); + } + + if (type is NullableTypeSyntax nullable) + { + return CanEraseTypeWithoutImportUse(nullable.ElementType, model); + } + + if (type is TupleTypeSyntax tuple) + { + return tuple.Elements.All(element => CanEraseTypeWithoutImportUse(element.Type, model)); + } + + if (type is not NameSyntax name) + { + return false; + } + NameSyntax root = name; - while (root is QualifiedNameSyntax qualified) root = qualified.Left; - if (root is not AliasQualifiedNameSyntax alias || alias.Alias.Identifier.Text != "global") return false; + while (root is QualifiedNameSyntax qualified) + { + root = qualified.Left; + } + + if (root is not AliasQualifiedNameSyntax alias || alias.Alias.Identifier.Text != "global") + { + return false; + } // A rooted outer name is not enough: an unqualified generic argument // can still consume an import, e.g. global::List. return name.DescendantNodesAndSelf().OfType().All(generic => @@ -88,15 +132,46 @@ internal static bool CanEraseTypeWithoutImportUse(TypeSyntax type, SemanticModel internal static bool SupportsOriginalTreeSourcePolicy(SyntaxTree tree) { - if (tree.Options.DocumentationMode == DocumentationMode.None) return false; + if (tree.Options.DocumentationMode == DocumentationMode.None) + { + return false; + } + var root = tree.GetRoot(); return !root.ContainsDirectives || !root.DescendantTrivia(descendIntoTrivia: true).Any(static trivia => trivia.IsKind(SyntaxKind.PragmaWarningDirectiveTrivia) || trivia.IsKind(SyntaxKind.LineSpanDirectiveTrivia)); } + // Separate permission from the strict certificate: a bare type cref may + // make its existing normal namespace import used. Only Hidden CS8019 + // disappearance is an intentional delta; every other diagnostic vetoes. + internal static bool SupportsHiddenImportActivation(Compilation original, SemanticModel unfiltered) + { + if (!SupportsOriginalTreeSourcePolicy(unfiltered.SyntaxTree)) + { + return false; + } + + var provider = original.Options.SyntaxTreeOptionsProvider; + if (provider != null) + { + static bool Supported(ReportDiagnostic value) => value is ReportDiagnostic.Default or ReportDiagnostic.Hidden; + if (provider.TryGetDiagnosticValue(unfiltered.SyntaxTree, "CS8019", default, out var treeValue) && !Supported(treeValue) || + provider.TryGetGlobalDiagnosticValue("CS8019", default, out var globalValue) && !Supported(globalValue)) + { + return false; + } + } + return unfiltered.GetDiagnostics().All(d => d.Id == "CS8019" && + d.Severity == DiagnosticSeverity.Hidden && d.DefaultSeverity == DiagnosticSeverity.Hidden && !d.IsSuppressed); + } + internal static bool HasSafeOriginalTreeDiagnostics(SemanticModel model) { - if (!SupportsOriginalTreeSourcePolicy(model.SyntaxTree)) return false; + if (!SupportsOriginalTreeSourcePolicy(model.SyntaxTree)) + { + return false; + } // Deliberately request the COMPLETE original tree. A cref-sized span // does not force Roslyn's unused-import accounting. Empty filtered or // unsupported diagnostics are never treated as proof of usage. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralOptionalReceiverRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralOptionalReceiverRules.cs new file mode 100644 index 00000000000..c592a59452f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralOptionalReceiverRules.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.TypeSpec.Generator +{ + // Syntax-only conditional certificate. Binding and source observations are still + // checked at the original occurrence by StructuralTypeNameReducer. + internal static class StructuralOptionalReceiverRules + { + internal static bool SameStatementScope(MethodDeclarationSyntax original, SyntaxNode variant, ExpressionSyntax receiver) + { + if (variant is not MethodDeclarationSyntax next || original.Body == null || next.Body == null || + original.Span != next.Span || next.ContainsDiagnostics) + { + return false; + } + // Deliberately start with the SDK's direct method-body if condition. + // Do not admit lambdas, local functions or nested statement shapes here. + IfStatementSyntax? statement = null; + foreach (var item in original.Body.Statements) + { + if (item is IfStatementSyntax condition && condition.Condition.Span.Contains(receiver.Span)) + { + statement = condition; + } + } + + if (statement == null) + { + return false; + } + + return Same(original, next, statement.Span); + } + + private static bool Same(SyntaxNodeOrToken original, SyntaxNodeOrToken next, TextSpan affected) + { + if (original.RawKind != next.RawKind || original.Span != next.Span || original.IsToken != next.IsToken) + { + return false; + } + + if (original.IsToken) + { + return original.AsToken().Text == next.AsToken().Text; + } + // A later nested block cannot export its local declarations into the + // earlier condition. Its enclosing statement/header remains compared. + // In particular, later declarations in the METHOD BODY are NOT skipped: + // their scope can cover the earlier Optional occurrence too. + if (original.AsNode() is BlockSyntax && original.Span.Start >= affected.End) + { + return true; + } + + var before = original.AsNode()!.ChildNodesAndTokens(); + var after = next.AsNode()!.ChildNodesAndTokens(); + if (before.Count != after.Count) + { + return false; + } + + for (var index = 0; index < before.Count; index++) + { + if (!Same(before[index], after[index], affected)) + { + return false; + } + } + + return true; + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs index 000b932512d..8b7cd93bf20 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralParenthesesRules.cs @@ -28,7 +28,10 @@ ObjectCreationExpressionSyntax or TypeOfExpressionSyntax or SizeOfExpressionSynt internal static bool CanRemove(ParenthesizedExpressionSyntax outer, ExpressionSyntax payload) { - if (!SupportsPayload(payload)) return false; + if (!SupportsPayload(payload)) + { + return false; + } // Always retain cast operands, receivers/targets and uncertain roles. // In particular an alias-qualified cast type may already have shortened. switch (outer.Parent) @@ -55,10 +58,22 @@ internal static bool CanRemove(ParenthesizedExpressionSyntax outer, ExpressionSy node.Parent is ArgumentListSyntax list && list.Parent is InvocationExpressionSyntax or ObjectCreationExpressionSyntax; case BinaryExpressionSyntax node: // Relational comma-list ambiguities need more than precedence. - if (node.IsKind(SyntaxKind.LessThanExpression) || node.IsKind(SyntaxKind.GreaterThanExpression)) return false; + if (node.IsKind(SyntaxKind.LessThanExpression) || node.IsKind(SyntaxKind.GreaterThanExpression)) + { + return false; + } + var child = Precedence(payload); var parent = Precedence(node); - if (child <= 0 || parent <= 0) return false; - if (child != parent) return child > parent; + if (child <= 0 || parent <= 0) + { + return false; + } + + if (child != parent) + { + return child > parent; + } + return node.IsKind(SyntaxKind.CoalesceExpression) ? node.Right == outer : node.Left == outer; default: return false; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs index 32917aaaaaf..f8036a50594 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameReducer.cs @@ -30,6 +30,7 @@ private sealed record Target(INamedTypeSymbol Symbol, string[] Parts); private readonly Lazy _crefImportEnvironment; private readonly Lazy _crefDiagnosticCompilation; private readonly ConcurrentDictionary> _crefOriginalDiagnostics = new(); + private readonly ConcurrentDictionary> _crefImportActivationDiagnostics = new(); private readonly ConcurrentDictionary> _crefSourcePolicies = new(); internal StructuralTypeNameReducer(Compilation compilation) @@ -46,7 +47,10 @@ internal StructuralTypeNameReducer(Compilation compilation) // Conditional documents additionally prove body/header eligibility and // reject generic local-function scope changes in every supported parse. // Only active original syntax is visited; disabled text is untouched. - if (root.ContainsDiagnostics || !_conditional.IsTypeOnlyDocument(root)) return null; + if (root.ContainsDiagnostics || !_conditional.IsTypeOnlyDocument(root)) + { + return null; + } // Provenance is unique across roles, not just within a selected role. var uniqueSites = references.GroupBy(static site => site.Span).Where(static group => group.Count() == 1) .Select(static group => group.Single()).ToArray(); @@ -62,21 +66,38 @@ internal StructuralTypeNameReducer(Compilation compilation) private Target? Resolve(CSharpType type) { for (var current = type; current != null; current = current.DeclaringType) - if (current != type && current.Arguments.Count != 0 || current.IsArray || current.IsFrameworkType && current.FrameworkType.IsGenericParameter) return null; + { + if (current != type && current.Arguments.Count != 0 || current.IsArray || current.IsFrameworkType && current.FrameworkType.IsGenericParameter) + { + return null; + } + } // ClrMetadataName contains the declaring-type chain, not its namespace. // This is a canonical metadata lookup, never emitted-source rewriting. var metadata = type.Namespace.Length == 0 ? type.ClrMetadataName : type.Namespace + "." + type.ClrMetadataName; return _targets.GetOrAdd(metadata, name => { var symbol = _compilation.GetTypeByMetadataName(name); - if (symbol == null || symbol.TypeKind == TypeKind.Error) return null; + if (symbol == null || symbol.TypeKind == TypeKind.Error) + { + return null; + } + var parts = new Stack(); for (var current = symbol; current != null; current = current.ContainingType) { - if (current.Arity != 0 && !SymbolEqualityComparer.Default.Equals(current, symbol)) return null; + if (current.Arity != 0 && !SymbolEqualityComparer.Default.Equals(current, symbol)) + { + return null; + } + parts.Push(current.Name); } - for (var ns = symbol.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) parts.Push(ns.Name); + for (var ns = symbol.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) + { + parts.Push(ns.Name); + } + return new Target(symbol, parts.ToArray()); }); } @@ -130,10 +151,17 @@ private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) public override SyntaxNode? Visit(SyntaxNode? node) { - if (node == null) return null; + if (node == null) + { + return null; + } // Whole-cref proof owns this context. Never send unsupported or // unrecorded member-cref children through ordinary code-name rules. - if (node is CrefSyntax cref) return TryReduceWholeTypeCref(cref) ?? cref; + if (node is CrefSyntax cref) + { + return TryReduceWholeTypeCref(cref) ?? cref; + } + var previous = _scope; var previousAttribute = _insideAttribute; var previousSourceObservableExpression = _insideSourceObservableExpression; @@ -143,26 +171,50 @@ private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) var previousValueDepth = _thisValueContexts.Count; var previousThisExcludedContext = _insideThisExcludedContext; if (node is TypeDeclarationSyntax or BaseMethodDeclarationSyntax or LocalFunctionStatementSyntax or DelegateDeclarationSyntax or - BaseListSyntax or TypeParameterConstraintClauseSyntax or ExplicitInterfaceSpecifierSyntax) _scope = node; - if (node is AttributeSyntax) _insideAttribute = true; + BaseListSyntax or TypeParameterConstraintClauseSyntax or ExplicitInterfaceSpecifierSyntax) + { + _scope = node; + } + + if (node is AttributeSyntax) + { + _insideAttribute = true; + } + if (node is ParameterSyntax or ConstructorInitializerSyntax || node is EqualsValueClauseSyntax initializer && (initializer.Parent is PropertyDeclarationSyntax || initializer.Parent?.Parent?.Parent is BaseFieldDeclarationSyntax) || node is BaseMethodDeclarationSyntax methodDeclaration && methodDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || node is LocalFunctionStatementSyntax localDeclaration && localDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || node is PropertyDeclarationSyntax propertyDeclaration && propertyDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)) + { _insideThisExcludedContext = true; + } + if (node is BinaryExpressionSyntax or AssignmentExpressionSyntax or PrefixUnaryExpressionSyntax or PostfixUnaryExpressionSyntax or CastExpressionSyntax or ConditionalExpressionSyntax or AwaitExpressionSyntax) + { _thisValueContexts.Add((ExpressionSyntax)node); + } + if (node is ParameterSyntax or PatternSyntax or QueryExpressionSyntax or WhenClauseSyntax || node is ArgumentSyntax argumentContext && !argumentContext.RefKindKeyword.IsKind(SyntaxKind.None)) + { _insideParenthesesExcludedContext = true; - if (node is ArgumentListSyntax argumentList) _parenthesesArgumentLists.Add(argumentList); + } + + if (node is ArgumentListSyntax argumentList) + { + _parenthesesArgumentLists.Add(argumentList); + } // Keep hidden-call/conditional contexts conservative. Ordinary call // source is checked lazily once per ORIGINAL call, before name caches. if (node is ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or InitializerExpressionSyntax or - InterpolatedStringExpressionSyntax or CollectionExpressionSyntax) _insideSourceObservableExpression = true; + InterpolatedStringExpressionSyntax or CollectionExpressionSyntax) + { + _insideSourceObservableExpression = true; + } + if (!_insideAttribute && !_insideSourceObservableExpression) { SyntaxNode? call = node as InvocationExpressionSyntax; @@ -170,15 +222,24 @@ private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) { call = argument.Parent is ArgumentListSyntax list && list.Parent is InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax ? list.Parent : null; - if (call == null) _insideSourceObservableExpression = true; + if (call == null) + { + _insideSourceObservableExpression = true; + } } if (call != null) { // A receiver-local's type can vary across framework branches // even when headers/type lookup remain invariant. Original- // configuration call facts alone cannot prove that case. - if (!_canProveCallSource) _insideSourceObservableExpression = true; - else if (_calls.Count == 0 || !ReferenceEquals(_calls[^1], call)) _calls.Add(call); + if (!_canProveCallSource) + { + _insideSourceObservableExpression = true; + } + else if (_calls.Count == 0 || !ReferenceEquals(_calls[^1], call)) + { + _calls.Add(call); + } } } try @@ -218,7 +279,10 @@ private sealed record SourceObservation(bool Unknown, TextSpan[] Captured) return TryReduce(name, site, visited)?.WithTriviaFrom(name) ?? visited; } var rewritten = TryReduce(name, site); - if (rewritten != null) return rewritten.WithTriviaFrom(name); + if (rewritten != null) + { + return rewritten.WithTriviaFrom(name); + } } if (!_insideAttribute && _scope != null && node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAccessExpressionSyntax member && @@ -245,10 +309,21 @@ node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAcces _insideAttribute = previousAttribute; _insideSourceObservableExpression = previousSourceObservableExpression; _insideParenthesesExcludedContext = previousParenthesesExcludedContext; - if (_calls.Count > previousCallDepth) _calls.RemoveRange(previousCallDepth, _calls.Count - previousCallDepth); + if (_calls.Count > previousCallDepth) + { + _calls.RemoveRange(previousCallDepth, _calls.Count - previousCallDepth); + } + if (_parenthesesArgumentLists.Count > previousArgumentDepth) + { _parenthesesArgumentLists.RemoveRange(previousArgumentDepth, _parenthesesArgumentLists.Count - previousArgumentDepth); - if (_thisValueContexts.Count > previousValueDepth) _thisValueContexts.RemoveRange(previousValueDepth, _thisValueContexts.Count - previousValueDepth); + } + + if (_thisValueContexts.Count > previousValueDepth) + { + _thisValueContexts.RemoveRange(previousValueDepth, _thisValueContexts.Count - previousValueDepth); + } + _insideThisExcludedContext = previousThisExcludedContext; } } @@ -256,7 +331,10 @@ node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAcces public override SyntaxNode? VisitParenthesizedExpression(ParenthesizedExpressionSyntax node) { if (_scope == null || _insideAttribute || _insideSourceObservableExpression || _insideParenthesesExcludedContext || - HasRelationalArgumentContext()) return base.VisitParenthesizedExpression(node); + HasRelationalArgumentContext()) + { + return base.VisitParenthesizedExpression(node); + } var chain = new List(); ExpressionSyntax payload = node; @@ -272,12 +350,17 @@ node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAcces var supported = StructuralParenthesesRules.SupportsTrivia(node) && chain.All(static wrapper => !wrapper.OpenParenToken.IsMissing && !wrapper.CloseParenToken.IsMissing) && StructuralParenthesesRules.CanRemove(node, visited); - if (supported && !_canProveCallSource) supported = CanRemoveConditionalParentheses(node, visited); + if (supported && !_canProveCallSource) + { + supported = CanRemoveConditionalParentheses(node, visited); + } + if (supported) { // New cast-payload admission requires visible ORIGINAL // delimiters. Keep pre-existing non-cast policies unchanged. foreach (var wrapper in chain) + { if (visited is CastExpressionSyntax && (_tree.GetLineVisibility(wrapper.OpenParenToken.SpanStart) != LineVisibility.Visible || _tree.GetLineVisibility(wrapper.CloseParenToken.SpanStart) != LineVisibility.Visible) || @@ -286,6 +369,7 @@ node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAcces supported = false; break; } + } } if (supported) { @@ -298,7 +382,11 @@ node is NameSyntax or MemberAccessExpressionSyntax && node.Parent is MemberAcces } GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.Retained", TimeSpan.Zero, 0); ExpressionSyntax retained = visited; - for (var index = chain.Count - 1; index >= 0; index--) retained = chain[index].WithExpression(retained); + for (var index = chain.Count - 1; index >= 0; index--) + { + retained = chain[index].WithExpression(retained); + } + return retained; } @@ -308,8 +396,16 @@ private bool CanRemoveConditionalParentheses(ParenthesizedExpressionSyntax origi // bindings. Conditional call/static/this permissions remain opaque. // CreateRewriter already required IsTypeOnlyDocument, including its // generic-local-function veto; TryGetRoots alone is not that proof. - if (_conditionalParenthesesRoots == null && !_owner._conditional.TryGetRoots(_tree.GetRoot(), out _conditionalParenthesesRoots)) return false; - if (original.Parent == null) return false; + if (_conditionalParenthesesRoots == null && !_owner._conditional.TryGetRoots(_tree.GetRoot(), out _conditionalParenthesesRoots)) + { + return false; + } + + if (original.Parent == null) + { + return false; + } + var role = original.Parent.ChildNodes().TakeWhile(child => child != original).Count(); foreach (var root in _conditionalParenthesesRoots!) { @@ -317,7 +413,10 @@ private bool CanRemoveConditionalParentheses(ParenthesizedExpressionSyntax origi if (root.FindNode(original.Span, getInnermostNodeForTie: true) is not ParenthesizedExpressionSyntax candidate || candidate.Span != original.Span || !SyntaxFactory.AreEquivalent(original, candidate) || candidate.Parent == null || candidate.Parent.RawKind != original.Parent.RawKind || candidate.Parent.Span != original.Parent.Span || - candidate.Parent.ChildNodes().TakeWhile(child => child != candidate).Count() != role) return false; + candidate.Parent.ChildNodes().TakeWhile(child => child != candidate).Count() != role) + { + return false; + } // Empty original call frames are NOT evidence of source safety: // conditional calls intentionally have no observer frames. Check // every variant's entire ancestry, including outside the span. @@ -325,22 +424,37 @@ private bool CanRemoveConditionalParentheses(ParenthesizedExpressionSyntax origi ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax or AttributeSyntax or ConditionalAccessExpressionSyntax or ElementAccessExpressionSyntax or InitializerExpressionSyntax or InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyntax or PatternSyntax or - QueryExpressionSyntax or WhenClauseSyntax)) return false; - if (!StructuralParenthesesRules.SupportsTrivia(candidate) || !StructuralParenthesesRules.CanRemove(candidate, payload)) return false; + QueryExpressionSyntax or WhenClauseSyntax)) + { + return false; + } + + if (!StructuralParenthesesRules.SupportsTrivia(candidate) || !StructuralParenthesesRules.CanRemove(candidate, payload)) + { + return false; + } + var chain = new List(); ExpressionSyntax current = candidate; while (current is ParenthesizedExpressionSyntax wrapper) { if (wrapper.OpenParenToken.IsMissing || wrapper.CloseParenToken.IsMissing || root.SyntaxTree.GetLineVisibility(wrapper.OpenParenToken.SpanStart) != LineVisibility.Visible || - root.SyntaxTree.GetLineVisibility(wrapper.CloseParenToken.SpanStart) != LineVisibility.Visible) return false; + root.SyntaxTree.GetLineVisibility(wrapper.CloseParenToken.SpanStart) != LineVisibility.Visible) + { + return false; + } + chain.Add(wrapper); current = wrapper.Expression; } // Use the variant's wrappers/neighbouring tokens, and the FINAL // visited original payload. Only original-tree output is emitted. var replacement = StructuralParenthesesRules.Remove(chain, payload); - if (!StructuralParenthesesRules.KeepsTokenBoundaries(candidate, replacement)) return false; + if (!StructuralParenthesesRules.KeepsTokenBoundaries(candidate, replacement)) + { + return false; + } } return true; } @@ -359,11 +473,23 @@ InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyn original.Ancestors().OfType().FirstOrDefault() is not { } method || method.Modifiers.Any(SyntaxKind.StaticKeyword) || ConditionalStaticReceiverRules.HasHardSourceContext(original) || visited.Expression.ToFullString() != original.Expression.ToFullString() || - !_sites.TryGetValue(ReferenceSpan(name), out var headSite)) return null; - if (!_canProveCallSource && !CanProveConditionalStaticReceiver(wrapper, member, headSite)) return null; + !_sites.TryGetValue(ReferenceSpan(name), out var headSite)) + { + return null; + } + + if (!_canProveCallSource && !CanProveConditionalStaticReceiver(wrapper, member, headSite)) + { + return null; + } + if (_tree.GetLineVisibility(headSite.Span.Start) != LineVisibility.Visible || _tree.GetLineVisibility(headSite.Span.End - 1) != LineVisibility.Visible || - !AllowsConditionalStaticSourceChange(original, headSite.Span)) return null; + !AllowsConditionalStaticSourceChange(original, headSite.Span)) + { + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); if (_model.GetTypeInfo(name).Type is not INamedTypeSymbol { TypeKind: TypeKind.Interface, Arity: 1, ContainingType: null } construction || construction.TypeArguments[0] is not INamedTypeSymbol { TypeKind: TypeKind.Class, Arity: 0, ContainingType: null } modelType || @@ -375,7 +501,10 @@ InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyn _model.GetSymbolInfo(invocation) is not { CandidateReason: CandidateReason.None, Symbol: IMethodSymbol callee } || callee.IsStatic || callee.MethodKind != MethodKind.Ordinary || callee.ReducedFrom != null || !SymbolEqualityComparer.IncludeNullability.Equals(callee.ContainingType, construction) || - !HasCastParameterImportWitness(method, construction.ContainingNamespace)) return null; + !HasCastParameterImportWitness(method, construction.ContainingNamespace)) + { + return null; + } NameSyntax selectedArgument = argument; // The self-model name is found in its own containing type scope, @@ -398,7 +527,11 @@ InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyn { if (plan.Render(argumentSource) is not IdentifierNameSyntax candidate || candidate.Identifier.ValueText != modelType.Name || ExpressionReceiverSyntaxFacts.ExposesContextualRoot(candidate) || - _model.GetSpeculativeAliasInfo(argument.SpanStart, SyntaxFactory.IdentifierName(candidate.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) != null) return false; + _model.GetSpeculativeAliasInfo(argument.SpanStart, SyntaxFactory.IdentifierName(candidate.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) != null) + { + return false; + } + selectedArgument = candidate.WithTriviaFrom(argument); return true; }); @@ -417,18 +550,38 @@ InterpolatedStringExpressionSyntax or CollectionExpressionSyntax or ParameterSyn // Bare generic heads only; namespace/type aliases and broader // cast operand/parent roles are not part of this certificate. if (plan.Render(source) is not GenericNameSyntax candidate || candidate.Identifier.ValueText != construction.Name || - ExpressionReceiverSyntaxFacts.ExposesContextualRoot(candidate) || _tree.Options is not CSharpParseOptions options) return false; + ExpressionReceiverSyntaxFacts.ExposesContextualRoot(candidate) || _tree.Options is not CSharpParseOptions options) + { + return false; + } + var detached = SyntaxFactory.ParseTypeName(candidate.ToString(), options: options); - if (detached.ContainsDiagnostics) return false; + if (detached.ContainsDiagnostics) + { + return false; + } + var rebound = _model.GetSpeculativeSymbolInfo(name.SpanStart, detached, SpeculativeBindingOption.BindAsTypeOrNamespace); if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || - !SymbolEqualityComparer.IncludeNullability.Equals(construction, rebound.Symbol)) return false; + !SymbolEqualityComparer.IncludeNullability.Equals(construction, rebound.Symbol)) + { + return false; + } + var cast = visited.WithType(detached.WithTriviaFrom(visited.Type)); var parsedCast = SyntaxFactory.ParseExpression(cast.ToString(), options: options, consumeFullText: true); - if (parsedCast.ContainsDiagnostics || parsedCast is not CastExpressionSyntax || !SyntaxFactory.AreEquivalent(cast, parsedCast)) return false; + if (parsedCast.ContainsDiagnostics || parsedCast is not CastExpressionSyntax || !SyntaxFactory.AreEquivalent(cast, parsedCast)) + { + return false; + } + var receiver = member.WithExpression(wrapper.WithExpression(cast)); var parsedReceiver = SyntaxFactory.ParseExpression(receiver.ToString(), options: options, consumeFullText: true); - if (parsedReceiver.ContainsDiagnostics || !SyntaxFactory.AreEquivalent(receiver, parsedReceiver)) return false; + if (parsedReceiver.ContainsDiagnostics || !SyntaxFactory.AreEquivalent(receiver, parsedReceiver)) + { + return false; + } + replacement = cast; return true; }); @@ -447,16 +600,31 @@ private bool HasCastParameterImportWitness(MethodDeclarationSyntax method, IName { if (parameter.Type is not NameSyntax name || model.GetTypeInfo(name).Type is not INamedTypeSymbol { Arity: 0, ContainingType: null } type || - !SymbolEqualityComparer.Default.Equals(type.ContainingNamespace, targetNamespace)) continue; - if (name is IdentifierNameSyntax bare && bare.Identifier.ValueText == type.Name && model.GetAliasInfo(bare) == null) return true; + !SymbolEqualityComparer.Default.Equals(type.ContainingNamespace, targetNamespace)) + { + continue; + } + + if (name is IdentifierNameSyntax bare && bare.Identifier.ValueText == type.Name && model.GetAliasInfo(bare) == null) + { + return true; + } + if (!_sites.TryGetValue(ReferenceSpan(name), out var site) || !IsTypeOnlyContext(name) || _owner.Resolve(site.Type) is not { } target || !SymbolEqualityComparer.IncludeNullability.Equals(type, target.Symbol) || - DescribeRecordedReference(name, target) is not { } source) continue; + DescribeRecordedReference(name, target) is not { } source) + { + continue; + } + var context = new StructuralTypeNameSelector.Context(_owner._compilation, model, name, type, KnownDeclarationType: true, Source: source, ModeOverride: StructuralTypeNameSelector.Mode.Type); var selected = StructuralTypeNameSelector.Choose(new[] { context }); if (selected.Plan?.Render(source) is IdentifierNameSyntax identifier && identifier.Identifier.ValueText == type.Name && - model.GetSpeculativeAliasInfo(name.SpanStart, SyntaxFactory.IdentifierName(identifier.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) == null) return true; + model.GetSpeculativeAliasInfo(name.SpanStart, SyntaxFactory.IdentifierName(identifier.Identifier), SpeculativeBindingOption.BindAsTypeOrNamespace) == null) + { + return true; + } } return false; } @@ -470,29 +638,48 @@ _tree.Options is not CSharpParseOptions options || options.LanguageVersion < Lan clause.Parent is not ParameterSyntax parameter || parameter.Default != clause || parameter.Parent is not ParameterListSyntax list || list.Parent is not (MethodDeclarationSyntax or ConstructorDeclarationSyntax or DelegateDeclarationSyntax or LocalFunctionStatementSyntax) || - expression.ContainsDiagnostics || expression.ContainsDirectives) return null; + expression.ContainsDiagnostics || expression.ContainsDirectives) + { + return null; + } + var lines = _tree.GetLineSpan(expression.Span); if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(expression.SpanStart) != LineVisibility.Visible || expression.DescendantTrivia().Any(trivia => trivia.Span.End > expression.SpanStart && trivia.SpanStart < expression.Span.End && !trivia.IsKind(SyntaxKind.WhitespaceTrivia) && !trivia.IsKind(SyntaxKind.EndOfLineTrivia)) || - !AllowsSourceChange(expression.Span)) return null; + !AllowsSourceChange(expression.Span)) + { + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); if (_model.GetDeclaredSymbol(parameter) is not IParameterSymbol symbol || !symbol.IsOptional || !symbol.HasExplicitDefaultValue || symbol.RefKind != RefKind.None || symbol.IsParams || symbol.IsThis || symbol.ContainingSymbol is not IMethodSymbol method || - method.MethodKind is not (MethodKind.Ordinary or MethodKind.Constructor or MethodKind.DelegateInvoke or MethodKind.LocalFunction)) return null; + method.MethodKind is not (MethodKind.Ordinary or MethodKind.Constructor or MethodKind.DelegateInvoke or MethodKind.LocalFunction)) + { + return null; + } + var operand = _model.GetTypeInfo(expression.Type).Type; // The declaration supplies the target type. This is Roslyn's // exact-declared-type fast path, never its overload-sensitive fallback. // Null metadata values are valid for some struct/generic defaults. if (operand == null || !SymbolEqualityComparer.IncludeNullability.Equals(symbol.Type, operand) || - !_owner._optionalDefaultTypes.GetOrAdd(symbol.Type, SupportsOptionalDefaultType)) return null; + !_owner._optionalDefaultTypes.GetOrAdd(symbol.Type, SupportsOptionalDefaultType)) + { + return null; + } // Neither original diagnostic absence nor a reduction elsewhere // proves a final surviving import use. Erase only import-independent // type syntax, or prove this parameter's identical declaration is an // unchanged witness. This also covers newly withheld handler uses. var canEraseType = StructuralCrefImportRules.CanEraseTypeWithoutImportUse(expression.Type, _model) || HasSurvivingParameterTypeImportWitness(parameter, expression.Type); - if (canEraseType) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralOptionalDefaults.Attempt", TimeSpan.Zero, 0); + if (canEraseType) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralOptionalDefaults.Attempt", TimeSpan.Zero, 0); + } + var replacement = SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, SyntaxFactory.Token(SyntaxKind.DefaultKeyword)).WithTriviaFrom(expression); static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item.FullSpan.Length != 0 && @@ -502,7 +689,11 @@ static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item if (!Separator(previous.TrailingTrivia) && !Separator(replacement.GetLeadingTrivia()) && !ParenthesesSyntaxFacts.TokensStaySeparate(previous, replacement.GetFirstToken()) || !Separator(replacement.GetTrailingTrivia()) && !Separator(next.LeadingTrivia) && - !ParenthesesSyntaxFacts.TokensStaySeparate(replacement.GetLastToken(), next)) return null; + !ParenthesesSyntaxFacts.TokensStaySeparate(replacement.GetLastToken(), next)) + { + return null; + } + if (!canEraseType) { // Every original C5 eligibility check passed. Only this new @@ -518,7 +709,10 @@ static bool Separator(SyntaxTriviaList trivia) => trivia.Any(static item => item private bool HasSurvivingParameterTypeImportWitness(ParameterSyntax parameter, TypeSyntax erasedType) { if (parameter.Type is not { } declaration || !SyntaxFactory.AreEquivalent(declaration, erasedType) || - declaration.ToString() != erasedType.ToString()) return false; + declaration.ToString() != erasedType.ToString()) + { + return false; + } // The exact declared/operand symbols and annotations were already // checked. In this parameter context only recorded code-name sites // can rewrite declaration type tokens; do not assume the original @@ -532,7 +726,11 @@ private static bool SupportsOptionalDefaultType(ITypeSymbol type) var seen = new HashSet(SymbolEqualityComparer.IncludeNullability); bool VisitType(ITypeSymbol current) { - if (!seen.Add(current)) return true; + if (!seen.Add(current)) + { + return true; + } + return current switch { IErrorTypeSymbol or IDynamicTypeSymbol or IPointerTypeSymbol or IFunctionPointerTypeSymbol => false, @@ -550,31 +748,80 @@ bool VisitType(ITypeSymbol current) { var name = attribute.Name; if (!_canProveCallSource || _scope == null || _insideSourceObservableExpression || attribute.ContainsDiagnostics || attribute.ContainsDirectives || - !_sites.TryGetValue(name.Span, out var site) || site.Type.IsArray || site.Type.IsNullable) return null; + !_sites.TryGetValue(name.Span, out var site) || site.Type.IsArray || site.Type.IsNullable) + { + return null; + } + var lines = _tree.GetLineSpan(name.Span); if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(name.SpanStart) != LineVisibility.Visible || - !AllowsSourceChange(name.Span)) return null; + !AllowsSourceChange(name.Span)) + { + return null; + } + var target = _owner.Resolve(site.Type); - if (target == null) return null; - for (var type = target.Symbol; type != null; type = type.ContainingType) if (type.Arity != 0) return null; + if (target == null) + { + return null; + } + + for (var type = target.Symbol; type != null; type = type.ContainingType) + { + if (type.Arity != 0) + { + return null; + } + } + if (!_owner._attributeEligibility.GetOrAdd(target.Symbol, type => { - if (type.TypeKind != TypeKind.Class || type.IsAbstract || !HasUsableTypeGraph(type)) return false; + if (type.TypeKind != TypeKind.Class || type.IsAbstract || !HasUsableTypeGraph(type)) + { + return false; + } + var baseType = _owner._compilation.GetTypeByMetadataName("System.Attribute"); - if (baseType == null) return false; + if (baseType == null) + { + return false; + } + for (var current = type.BaseType; current != null; current = current.BaseType) - if (SymbolEqualityComparer.Default.Equals(current, baseType)) return true; + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + { + return true; + } + } + return false; - })) return null; + })) + { + return null; + } + var source = DescribeRecordedReference(name, target); - if (source == null || source.Parts.Any(static part => part is not IdentifierNameSyntax)) return null; + if (source == null || source.Parts.Any(static part => part is not IdentifierNameSyntax)) + { + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); var original = _model.GetSymbolInfo(attribute); if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || original.Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor, IsStatic: false } constructor || - !SymbolEqualityComparer.IncludeNullability.Equals(constructor.ContainingType, target.Symbol)) return null; + !SymbolEqualityComparer.IncludeNullability.Equals(constructor.ContainingType, target.Symbol)) + { + return null; + } + var operation = _model.GetOperation(attribute); - if (!StructuralAttributeRules.SameContract(operation, operation)) return null; + if (!StructuralAttributeRules.SameContract(operation, operation)) + { + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.Attempt", TimeSpan.Zero, 0); AttributeSyntax? replacement = null; var facts = new StructuralTypeNameSelector.LookupFacts(_model, name.SpanStart); @@ -584,15 +831,31 @@ bool VisitType(ITypeSymbol current) StructuralTypeNameSelector.Choose(new[] { context }, plan => { var qualified = plan.Render(source); - if (qualified == null) return false; + if (qualified == null) + { + return false; + } + bool Accept(NameSyntax candidateName) { - if (candidateName.ToString() == name.ToString()) return false; + if (candidateName.ToString() == name.ToString()) + { + return false; + } + var parsedName = StructuralAttributeRules.RoundTripName(candidateName, (CSharpParseOptions)_tree.Options); - if (parsedName == null) return false; + if (parsedName == null) + { + return false; + } + var candidate = SyntaxFactory.Attribute(parsedName.WithTriviaFrom(name), attribute.ArgumentList).WithTriviaFrom(attribute); GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.SpeculativeCheck", TimeSpan.Zero, 0); - if (!_model.TryGetSpeculativeSemanticModel(attribute.SpanStart, candidate, out var speculative) || speculative == null) return false; + if (!_model.TryGetSpeculativeSemanticModel(attribute.SpanStart, candidate, out var speculative) || speculative == null) + { + return false; + } + var rebound = speculative.GetSymbolInfo(candidate); if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || !SymbolEqualityComparer.IncludeNullability.Equals(constructor, rebound.Symbol) || @@ -607,7 +870,11 @@ bool Accept(NameSyntax candidateName) var abbreviated = StructuralAttributeRules.WithoutSuffix(qualified); return abbreviated != null && Accept(abbreviated) || Accept(qualified); }); - if (replacement != null) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.Accepted", TimeSpan.Zero, 0); + if (replacement != null) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralAttributes.Accepted", TimeSpan.Zero, 0); + } + return replacement; } @@ -616,21 +883,43 @@ bool Accept(NameSyntax candidateName) if (!_canProveCallSource || _scope == null || _insideAttribute || _insideSourceObservableExpression || !_crefSites.TryGetValue(cref.Span, out var site) || site.Type.IsArray || site.Type.IsNullable || cref is not QualifiedCrefSyntax { Member: NameMemberCrefSyntax { Name: IdentifierNameSyntax, Parameters: null } } || - cref.ContainsDiagnostics || cref.ContainsDirectives) return null; + cref.ContainsDiagnostics || cref.ContainsDirectives) + { + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.Attempt", TimeSpan.Zero, 0); var target = _owner.Resolve(site.Type); - if (target == null) return null; - for (var type = target.Symbol; type != null; type = type.ContainingType) if (type.Arity != 0) return null; + if (target == null) + { + return null; + } + + for (var type = target.Symbol; type != null; type = type.ContainingType) + { + if (type.Arity != 0) + { + return null; + } + } + var source = DescribeRecordedReference(cref, target); var lines = _tree.GetLineSpan(cref.Span); if (source == null || source.Parts.Any(static part => part is not IdentifierNameSyntax) || lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(cref.SpanStart) != LineVisibility.Visible || - !AllowsSourceChange(cref.Span)) return null; + !AllowsSourceChange(cref.Span)) + { + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); var original = _model.GetSymbolInfo(cref); if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || original.Symbol is not INamedTypeSymbol construction || !HasUsableTypeGraph(construction) || - !SymbolEqualityComparer.IncludeNullability.Equals(construction, target.Symbol)) return null; + !SymbolEqualityComparer.IncludeNullability.Equals(construction, target.Symbol)) + { + return null; + } // A shorter cref can consume a previously unused import while // preserving its symbol and emitted XML. Use original complete-tree // facts, shared once per tree, before selecting any new spelling. @@ -638,7 +927,12 @@ bool Accept(NameSyntax candidateName) // A top-level type directly in the attachment's immediate // namespace is found before namespace imports. This alternate // permission applies ONLY to its bare-name plan below. - if (!importCertificate && !HasImmediateNamespaceCrefCertificate(cref, construction)) return null; + if (!importCertificate && !HasImmediateNamespaceCrefCertificate(cref, construction) && + !HasCrefImportActivationCertificate(cref, construction)) + { + return null; + } + CrefSyntax? replacement = null; var facts = new StructuralTypeNameSelector.LookupFacts(_model, cref.SpanStart); var context = new StructuralTypeNameSelector.Context(_owner._compilation, _model, cref, construction, @@ -647,14 +941,25 @@ bool Accept(NameSyntax candidateName) StructuralTypeNameSelector.Choose(new[] { context }, plan => { var name = plan.RenderCrefName(source); - if (name == null || name.ToString() == cref.ToString()) return false; + if (name == null || name.ToString() == cref.ToString()) + { + return false; + } + if (!importCertificate && (name is not IdentifierNameSyntax identifier || - identifier.Identifier.ValueText != construction.Name)) return false; + identifier.Identifier.ValueText != construction.Name)) + { + return false; + } // Parse only this prospective XML spelling. A forced TypeCref // AST can select a type even when serialized bare T reparses as // a NameMemberCref and resolves a same-named method instead. var candidate = ParseWholeCref(name); - if (candidate == null) return false; + if (candidate == null) + { + return false; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.SpeculativeCheck", TimeSpan.Zero, 0); var rebound = _model.GetSpeculativeSymbolInfo(cref.SpanStart, candidate, SpeculativeBindingOption.BindAsTypeOrNamespace); if (rebound.CandidateReason != CandidateReason.None || rebound.CandidateSymbols.Length != 0 || @@ -666,27 +971,81 @@ bool Accept(NameSyntax candidateName) replacement = candidate.WithTriviaFrom(cref); return true; }); - if (replacement != null) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.Accepted", TimeSpan.Zero, 0); + if (replacement != null) + { + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralCrefs.Accepted", TimeSpan.Zero, 0); + } + return replacement; } + private bool HasCrefImportActivationCertificate(CrefSyntax cref, INamedTypeSymbol target) + { + // Bare top-level, nongeneric types can activate only their actual + // namespace import. No alias/static/global import, generic argument, + // partial namespace plan or unrelated diagnostic is admitted here. + if (target.ContainingType != null || target.Arity != 0 || target.ContainingNamespace.IsGlobalNamespace || + !_owner._crefImportEnvironment.Value || + !_owner._crefImportActivationDiagnostics.GetOrAdd(_tree, + tree => new Lazy(() => StructuralCrefImportRules.SupportsHiddenImportActivation( + _owner._compilation, _owner._crefDiagnosticCompilation.Value.GetSemanticModel(tree)))).Value) + { + return false; + } + + var trivia = cref.Ancestors().OfType().FirstOrDefault(); + var token = trivia?.ParentTrivia.Token ?? default; + var owner = token.Parent?.AncestorsAndSelf().OfType() + .FirstOrDefault(member => member.GetFirstToken().SpanStart == token.SpanStart); + if (owner == null || _tree.GetRoot() is not CompilationUnitSyntax root) + { + return false; + } + + var imports = root.Usings.Concat(owner.Ancestors().OfType().SelectMany(ns => ns.Usings)); + // Imports bind to the compilation's merged namespace, whereas a + // metadata type can belong to one constituent assembly namespace. + var targetNamespace = _owner._compilation.GetCompilationNamespace(target.ContainingNamespace); + return targetNamespace != null && imports.Any(import => import.Name != null && + SymbolEqualityComparer.Default.Equals(_model.GetSymbolInfo(import.Name).Symbol, targetNamespace)); + } + private bool HasImmediateNamespaceCrefCertificate(CrefSyntax cref, INamedTypeSymbol target) { if (target.ContainingType != null || !_owner._crefImportEnvironment.Value || !_owner._crefSourcePolicies.GetOrAdd(_tree, tree => new Lazy(() => StructuralCrefImportRules.SupportsOriginalTreeSourcePolicy(tree))).Value) + { return false; + } + var documentation = cref.Ancestors().OfType().FirstOrDefault(); - if (documentation == null) return false; + if (documentation == null) + { + return false; + } + var token = documentation.ParentTrivia.Token; - if (!token.LeadingTrivia.Contains(documentation.ParentTrivia)) return false; + if (!token.LeadingTrivia.Contains(documentation.ParentTrivia)) + { + return false; + } + var declaration = token.Parent?.AncestorsAndSelf().OfType().FirstOrDefault(); // In particular, documentation attached to a namespace is not // documentation inside that namespace. Keep uncertain owners out. if (declaration is not TypeDeclarationSyntax and not BaseMethodDeclarationSyntax and - not BasePropertyDeclarationSyntax and not BaseFieldDeclarationSyntax) return false; + not BasePropertyDeclarationSyntax and not BaseFieldDeclarationSyntax) + { + return false; + } + var declared = _model!.GetDeclaredSymbol(declaration); - if (declared == null) return false; + if (declared == null) + { + return false; + } + var namespaceDeclaration = declaration.Ancestors().OfType().FirstOrDefault(); var immediateNamespace = namespaceDeclaration == null ? _owner._compilation.GlobalNamespace : _model.GetDeclaredSymbol(namespaceDeclaration); @@ -704,9 +1063,16 @@ private bool HasCrefImportCertificate() => _owner._crefImportEnvironment.Value & var spelling = name.ToString(); var options = ((CSharpParseOptions)_tree.Options).WithDocumentationMode(DocumentationMode.Diagnose); var parsed = SyntaxFactory.ParseCompilationUnit("/// \nclass __CrefProof__ {}", options: options); - if (parsed.ContainsDiagnostics) return null; + if (parsed.ContainsDiagnostics) + { + return null; + } + var attributes = parsed.DescendantNodes(descendIntoTrivia: true).OfType().ToArray(); - if (attributes.Length != 1 || attributes[0].Cref.ToString() != spelling) return null; + if (attributes.Length != 1 || attributes[0].Cref.ToString() != spelling) + { + return null; + } // Reconstruct the COMPLETE root to detach it from synthetic XML; // token spelling and the actual parser-selected shape are retained. return attributes[0].Cref switch @@ -731,7 +1097,10 @@ private bool HasRelationalArgumentContext() _relationalArguments.Add(list, relational); GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralParentheses.ArgumentListScan", TimeSpan.Zero, 0); } - if (relational) return true; + if (relational) + { + return true; + } } return false; } @@ -751,9 +1120,17 @@ private bool HasRelationalArgumentContext() private NameSyntax? TryReduce(NameSyntax node, TypeReferenceSite site, NameSyntax? visited = null) { var target = _owner.Resolve(site.Type); - if (target == null) return null; + if (target == null) + { + return null; + } + var source = DescribeRecordedReference(node, target); - if (source == null || !AllowsSourceChange(site.Span)) return null; + if (source == null || !AllowsSourceChange(site.Span)) + { + return null; + } + if (!_regions.TryGetValue(_scope!, out var region)) { _model ??= _owner._compilation.GetSemanticModel(_tree); @@ -768,7 +1145,11 @@ private bool HasRelationalArgumentContext() // type parameters and nullability; metadata arguments are not // reconstructed or substituted into the emitted syntax. if (_model!.GetSymbolInfo(node).Symbol is not INamedTypeSymbol construction || !HasUsableTypeGraph(construction) || - !SymbolEqualityComparer.Default.Equals(construction.OriginalDefinition, target.Symbol)) return null; + !SymbolEqualityComparer.Default.Equals(construction.OriginalDefinition, target.Symbol)) + { + return null; + } + var parts = (SimpleNameSyntax[])source.Parts.Clone(); parts[^1] = ((GenericNameSyntax)parts[^1]).WithTypeArgumentList(((GenericNameSyntax)Terminal(visited!)).TypeArgumentList); source = source with { Parts = parts }; @@ -801,7 +1182,9 @@ private bool HasRelationalArgumentContext() var selection = StructuralTypeNameSelector.Evaluate(new[] { context }); if (selection.Reason == StructuralTypeNameSelector.Retention.None && selection.Name is NameSyntax selected && !SyntaxFactory.AreEquivalent(node, selected, static _ => false)) + { replacement = selected.WithoutLeadingTrivia().WithoutTrailingTrivia(); + } } region.Decisions.Add(target.Symbol, replacement); GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralTypeNames.Decision", TimeSpan.Zero, 0); @@ -815,12 +1198,19 @@ private bool HasRelationalArgumentContext() var source = StructuralTypeNameSelector.Describe(node, preserveBoundaryComments: true); if (source.Diagnostics || source.Directives || source.SignificantTrivia || source.Root?.Parent is not AliasQualifiedNameSyntax alias || alias.Alias != source.Root || source.Root.Identifier.Text != SyntaxFacts.GetText(SyntaxKind.GlobalKeyword) || - source.Parts.Length != target.Parts.Length) return null; + source.Parts.Length != target.Parts.Length) + { + return null; + } + for (var index = 0; index < source.Parts.Length; index++) { var part = source.Parts[index]; var arity = part is GenericNameSyntax generic ? generic.TypeArgumentList.Arguments.Count : 0; - if (part.Identifier.ValueText != target.Parts[index] || arity != (index == source.Parts.Length - 1 ? target.Symbol.Arity : 0)) return null; + if (part.Identifier.ValueText != target.Parts[index] || arity != (index == source.Parts.Length - 1 ? target.Symbol.Arity : 0)) + { + return null; + } } return source; } @@ -830,31 +1220,110 @@ private bool CanProveConditionalStaticReceiver(ExpressionSyntax receiver, Member // CreateRewriter has already enforced compilation-wide headers, // type-only document admission and the generic-local-function veto. // This certificate adds value/call invariance, not other permissions. - if (_canProveCallSource || ConditionalStaticReceiverRules.HasHardSourceContext(receiver)) return false; + if (_canProveCallSource || ConditionalStaticReceiverRules.HasHardSourceContext(receiver)) + { + return false; + } + var unit = ConditionalStaticReceiverRules.OuterExecutableUnit(receiver); - if (unit == null || !_owner._conditional.TryGetRoots(_tree.GetRoot(), out var roots)) return false; + if (unit == null || !_owner._conditional.TryGetRoots(_tree.GetRoot(), out var roots)) + { + return false; + } + if (!_conditionalStaticUnits.TryGetValue(unit, out var invariant)) { invariant = roots.All(root => ConditionalStaticReceiverRules.SameUnit(unit, root.FindNode(unit.Span, getInnermostNodeForTie: true))); _conditionalStaticUnits.Add(unit, invariant); } - if (!invariant) return false; + if (!invariant && !CanProveOptionalStatementScope(receiver, member, site, unit, roots)) + { + return ConditionalStaticReceiverRules.CanProveBranchLocalMethod(receiver, member, unit, roots, site.Span); + } // Never cache occurrence acceptance or infer it from a previous // visible/bindable receiver of the same type in this unit. return roots.All(root => ConditionalStaticReceiverRules.SameOccurrence(receiver, member, root, site.Span)); } + private bool CanProveOptionalStatementScope(ExpressionSyntax receiver, MemberAccessExpressionSyntax member, + TypeReferenceSite site, BaseMethodDeclarationSyntax unit, SyntaxNode[] roots) + { + // A bounded source-defined Optional.IsDefined case, not a general + // relaxation for methods whose active conditional bodies vary. + if (unit is not MethodDeclarationSyntax method || member.Name.Identifier.ValueText != "IsDefined" || + member.Parent is not InvocationExpressionSyntax invocation || invocation.Expression != member || + invocation.ArgumentList.Arguments.Count != 1 || + _owner.Resolve(site.Type) is not { Symbol: { Name: "Optional", Arity: 0, IsStatic: true, TypeKind: TypeKind.Class } target } || + target.DeclaringSyntaxReferences.Length == 0) + { + return false; + } + + _model ??= _owner._compilation.GetSemanticModel(_tree); + if (_model.GetDeclaredSymbol(method) is not IMethodSymbol declared) + { + return false; + } + + var scope = declared.ContainingNamespace; + while (scope != null && !SymbolEqualityComparer.Default.Equals(scope, target.ContainingNamespace)) + { + scope = scope.ContainingNamespace; + } + + if (scope == null) + { + return false; // No imported foreign namespace permission. + } + + if (!SymbolEqualityComparer.Default.Equals(declared.ContainingNamespace, target.ContainingNamespace)) + { + // A nearer namespace import can supply the SAME ancestor type + // while losing Hidden CS8019. Preserve import independence; + // direct membership wins before imports at the target namespace. + foreach (var declaration in receiver.Ancestors().OfType()) + { + var namespaceSymbol = _model.GetDeclaredSymbol(declaration); + if (namespaceSymbol == null) + { + return false; + } + + if (SymbolEqualityComparer.Default.Equals(namespaceSymbol, target.ContainingNamespace)) + { + break; + } + + if (declaration.Usings.Count != 0 || declaration.Externs.Count != 0) + { + return false; + } + } + } + return roots.All(root => StructuralOptionalReceiverRules.SameStatementScope(method, + root.FindNode(unit.Span, getInnermostNodeForTie: true), receiver)); + } + private bool AllowsConditionalStaticSourceChange(SyntaxNode receiver, TextSpan prefix) { var lines = _tree.GetLineSpan(prefix); - if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return false; + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) + { + return false; + } // The invariant OUTER unit and global header proof make original // call maps reusable. Walk past lambdas/local functions to every // original ancestor frame; an empty conditional _calls is not proof. foreach (var call in receiver.Ancestors().Where(static ancestor => ancestor is InvocationExpressionSyntax or ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax or ConstructorInitializerSyntax)) - if (HasSourceObservationConflict(call, prefix)) return false; + { + if (HasSourceObservationConflict(call, prefix)) + { + return false; + } + } + return true; } @@ -881,7 +1350,11 @@ private bool HasSourceObservationConflict(SyntaxNode call, TextSpan prefix) GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralStaticReceivers.MetadataRejected", TimeSpan.Zero, 0); return null; } - if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return null; + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) + { + return null; + } + var source = DescribeRecordedReference(receiver, target); if (source == null || !(conditionalSource ? AllowsConditionalStaticSourceChange(receiver, site.Span) : AllowsSourceChange(site.Span))) { @@ -899,16 +1372,30 @@ private bool HasSourceObservationConflict(SyntaxNode call, TextSpan prefix) } var accessed = _model.GetSymbolInfo(member); if (accessed.CandidateReason != CandidateReason.None || accessed.CandidateSymbols.Length != 0 || - accessed.Symbol is not (IMethodSymbol or IFieldSymbol or IPropertySymbol or IEventSymbol) || !accessed.Symbol.IsStatic) return null; - if (accessed.Symbol is IMethodSymbol method && method.MethodKind != MethodKind.Ordinary) return null; + accessed.Symbol is not (IMethodSymbol or IFieldSymbol or IPropertySymbol or IEventSymbol) || !accessed.Symbol.IsStatic) + { + return null; + } + + if (accessed.Symbol is IMethodSymbol method && method.MethodKind != MethodKind.Ordinary) + { + return null; + } + if (member.Parent is InvocationExpressionSyntax invocation && invocation.Expression == member) { var called = _model.GetSymbolInfo(invocation); if (called.CandidateReason != CandidateReason.None || called.CandidateSymbols.Length != 0 || called.Symbol is not IMethodSymbol { IsStatic: true, MethodKind: MethodKind.Ordinary } callee || - !SymbolEqualityComparer.IncludeNullability.Equals(callee, accessed.Symbol)) return null; + !SymbolEqualityComparer.IncludeNullability.Equals(callee, accessed.Symbol)) + { + return null; + } + } + if (ExpressionReceiverSyntaxFacts.MayExposeCastParse(receiver)) + { + return null; } - if (ExpressionReceiverSyntaxFacts.MayExposeCastParse(receiver)) return null; // Expression lookup is position-sensitive even within one method. // Share requests across candidate attempts at THIS occurrence, never @@ -944,29 +1431,58 @@ private bool HasSourceObservationConflict(SyntaxNode call, TextSpan prefix) private IdentifierNameSyntax? TryReduceThisMember(MemberAccessExpressionSyntax member, IdentifierNameSyntax name) { GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.Attempt", TimeSpan.Zero, 0); - if (name.Identifier.IsMissing || name.Identifier.ValueText == "Object" || member.ContainsDiagnostics || member.ContainsDirectives) return null; + if (name.Identifier.IsMissing || name.Identifier.ValueText == "Object" || member.ContainsDiagnostics || member.ContainsDirectives) + { + return null; + } // Parentheses do not change which data member is the invocation // target. Keep this deliberately excluded family uniformly, even // when enough wrappers would make the serialized spelling safe. SyntaxNode invocationTarget = member; - while (invocationTarget.Parent is ParenthesizedExpressionSyntax wrapper) invocationTarget = wrapper; - if (invocationTarget.Parent is InvocationExpressionSyntax invocation && invocation.Expression == invocationTarget) return null; + while (invocationTarget.Parent is ParenthesizedExpressionSyntax wrapper) + { + invocationTarget = wrapper; + } + + if (invocationTarget.Parent is InvocationExpressionSyntax invocation && invocation.Expression == invocationTarget) + { + return null; + } // Keep removed-token ownership deliberately tight. Boundary comments // are restored from the complete original access; internal trivia is // not discarded or moved from this/dot onto an unrelated identifier. if (member.Expression.GetTrailingTrivia().Count != 0 || member.OperatorToken.LeadingTrivia.Count != 0 || - member.OperatorToken.TrailingTrivia.Count != 0 || member.Name.GetLeadingTrivia().Count != 0) return null; + member.OperatorToken.TrailingTrivia.Count != 0 || member.Name.GetLeadingTrivia().Count != 0) + { + return null; + } + var prefix = TextSpan.FromBounds(member.SpanStart, member.Name.SpanStart); var lines = _tree.GetLineSpan(prefix); if (lines.StartLinePosition.Line != lines.EndLinePosition.Line || _tree.GetLineVisibility(member.SpanStart) != LineVisibility.Visible || - !AllowsSourceChange(prefix)) return null; - if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(name) || ExpressionReceiverSyntaxFacts.MayExposeCastParse(member)) return null; + !AllowsSourceChange(prefix)) + { + return null; + } + + if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(name) || ExpressionReceiverSyntaxFacts.MayExposeCastParse(member)) + { + return null; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); var original = _model.GetSymbolInfo(member); if (original.CandidateReason != CandidateReason.None || original.CandidateSymbols.Length != 0 || original.Symbol == null || - !_owner._thisMemberEligibility.GetOrAdd(original.Symbol, IsSupportedThisMember) || HasPrimaryConstructorShadowRisk(original.Symbol)) return null; + !_owner._thisMemberEligibility.GetOrAdd(original.Symbol, IsSupportedThisMember) || HasPrimaryConstructorShadowRisk(original.Symbol)) + { + return null; + } + var memberType = _model.GetTypeInfo(member); - if (UnsafeThisType(memberType.Type) || UnsafeThisType(memberType.ConvertedType) || HasDynamicThisContext()) return null; + if (UnsafeThisType(memberType.Type) || UnsafeThisType(memberType.ConvertedType) || HasDynamicThisContext()) + { + return null; + } // Never pass the attached original member.Name: Roslyn promotes // that node to this.Member via GetStandaloneExpression. Speculate // the actual detached replacement, otherwise shadows look unchanged. @@ -980,7 +1496,11 @@ private bool HasSourceObservationConflict(SyntaxNode call, TextSpan prefix) return null; } if (!ParenthesesSyntaxFacts.TokensStaySeparate(member.GetFirstToken().GetPreviousToken(), replacement.GetFirstToken()) && - !HasRealThisSeparator(member.GetFirstToken().GetPreviousToken().TrailingTrivia) && !HasRealThisSeparator(replacement.GetLeadingTrivia())) return null; + !HasRealThisSeparator(member.GetFirstToken().GetPreviousToken().TrailingTrivia) && !HasRealThisSeparator(replacement.GetLeadingTrivia())) + { + return null; + } + GeneratedCodeWorkspace.PostProcessingProfile?.Add("StructuralThisReceivers.Accepted", TimeSpan.Zero, 0); return replacement; } @@ -991,14 +1511,25 @@ private bool HasPrimaryConstructorShadowRisk(ISymbol member) // inherited member can newly emit CS9179. Speculation discards that // warning (and warnings-as-errors builds would reject the output). var declaration = _scope!.AncestorsAndSelf().OfType().FirstOrDefault(); - if (declaration == null) return true; + if (declaration == null) + { + return true; + } + if (!_thisContainingTypes.TryGetValue(declaration, out var containingType)) { containingType = _model!.GetDeclaredSymbol(declaration) as INamedTypeSymbol; _thisContainingTypes.Add(declaration, containingType); } - if (containingType == null) return true; - if (SymbolEqualityComparer.Default.Equals(containingType.OriginalDefinition, member.ContainingType!.OriginalDefinition)) return false; + if (containingType == null) + { + return true; + } + + if (SymbolEqualityComparer.Default.Equals(containingType.OriginalDefinition, member.ContainingType!.OriginalDefinition)) + { + return false; + } // Only declaration metadata is shared; source scope, the selected // original member and the binding decision remain occurrence-owned. var parameters = _owner._primaryConstructorParameters.GetOrAdd(containingType.OriginalDefinition, static type => @@ -1015,7 +1546,11 @@ private bool HasPrimaryConstructorShadowRisk(ISymbol member) private static bool IsSupportedThisMember(ISymbol member) { - if (member.IsStatic || member.ContainingType == null || !HasUsableTypeGraph(member.ContainingType)) return false; + if (member.IsStatic || member.ContainingType == null || !HasUsableTypeGraph(member.ContainingType)) + { + return false; + } + var type = member switch { IFieldSymbol { IsFixedSizeBuffer: false } field => field.Type, @@ -1049,20 +1584,36 @@ bool Unsafe(ExpressionSyntax expression) }); _dynamicThisContexts.Add(context, dynamic); } - if (dynamic) return true; + if (dynamic) + { + return true; + } } return false; } private bool AllowsSourceChange(TextSpan prefix) { - if (_calls.Count == 0) return true; + if (_calls.Count == 0) + { + return true; + } // A newly admitted edit must not shift implicit CallerLineNumber // values, including values in later calls outside this expression. var lines = _tree.GetLineSpan(prefix); - if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) return false; + if (lines.StartLinePosition.Line != lines.EndLinePosition.Line) + { + return false; + } + foreach (var call in _calls) - if (HasSourceObservationConflict(call, prefix)) return false; + { + if (HasSourceObservationConflict(call, prefix)) + { + return false; + } + } + return true; } @@ -1087,15 +1638,24 @@ private SourceObservation ObserveCall(SyntaxNode call) // _callFacts shares the result, never occurrence acceptance. if (syntaxArguments != null && syntaxArguments.Arguments.Any(static argument => argument.DescendantNodesAndSelf().Any(static node => node is InterpolatedStringExpressionSyntax))) + { return SourceObservation.Retain; + } + _model ??= _owner._compilation.GetSemanticModel(_tree); var binding = _model.GetSymbolInfo(call); if (binding.CandidateReason != CandidateReason.None || binding.Symbol is not IMethodSymbol method || - method.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension or MethodKind.Constructor or MethodKind.DelegateInvoke)) return SourceObservation.Retain; + method.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension or MethodKind.Constructor or MethodKind.DelegateInvoke)) + { + return SourceObservation.Retain; + } + var definition = (method.ReducedFrom ?? method).OriginalDefinition; if (!_owner._capturesCallerSource.GetOrAdd(definition, static target => target.Parameters.Any(static parameter => parameter.GetAttributes().Any(IsCallerExpression)))) + { return SourceObservation.Unobserved; + } // Metadata proves the common no-capture case without building an // operation tree. CAE-bearing calls need the actual parameter map. @@ -1106,26 +1666,48 @@ private SourceObservation ObserveCall(SyntaxNode call) IObjectCreationOperation creation => creation.Arguments.ToArray(), _ => null }; - if (arguments == null) return SourceObservation.Retain; + if (arguments == null) + { + return SourceObservation.Retain; + } + var captured = new List(); foreach (var supplied in arguments) { // Extension receivers can be implicit EXPLICIT arguments. // Only DefaultValue denotes an omitted capture-text argument. - if (supplied.ArgumentKind != ArgumentKind.DefaultValue || supplied.Parameter == null) continue; + if (supplied.ArgumentKind != ArgumentKind.DefaultValue || supplied.Parameter == null) + { + continue; + } + foreach (var attribute in supplied.Parameter.GetAttributes().Where(IsCallerExpression)) { if (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Value is not string name) + { return SourceObservation.Retain; + } + var sources = arguments.Where(argument => argument.Parameter?.Name == name).ToArray(); - if (sources.Length == 0) return SourceObservation.Retain; + if (sources.Length == 0) + { + return SourceObservation.Retain; + } + foreach (var source in sources) { - if (source.ArgumentKind != ArgumentKind.Explicit) return SourceObservation.Retain; + if (source.ArgumentKind != ArgumentKind.Explicit) + { + return SourceObservation.Retain; + } // Classic reduced extensions expose the original static // signature: their 'this' source is argument.Value.Syntax. var syntax = source.Parameter?.IsThis == true ? source.Value.Syntax : source.Syntax; - if (syntax.SyntaxTree != _tree) return SourceObservation.Retain; + if (syntax.SyntaxTree != _tree) + { + return SourceObservation.Retain; + } + captured.Add(syntax.Span); } } @@ -1138,7 +1720,11 @@ private static bool HasUsableTypeGraph(ITypeSymbol type) var visited = new HashSet(SymbolEqualityComparer.IncludeNullability); bool VisitType(ITypeSymbol current) { - if (!visited.Add(current)) return true; + if (!visited.Add(current)) + { + return true; + } + return current switch { IErrorTypeSymbol => false, @@ -1180,8 +1766,15 @@ private static bool IsTypeOnlyContext(NameSyntax name) // more than type lookup (parse/source/configuration proofs). if (type.Parent is CastExpressionSyntax { Expression: not ParenthesizedExpressionSyntax } or TypePatternSyntax or CrefSyntax or CrefParameterSyntax or IncompleteMemberSyntax or RefValueExpressionSyntax or FromClauseSyntax or JoinClauseSyntax || - type.Parent?.IsKind(SyntaxKind.IsExpression) == true) return false; - if (type.Parent is QualifiedNameSyntax qualifiedParent && qualifiedParent.Left == type) return false; + type.Parent?.IsKind(SyntaxKind.IsExpression) == true) + { + return false; + } + + if (type.Parent is QualifiedNameSyntax qualifiedParent && qualifiedParent.Left == type) + { + return false; + } // Classify the original named node, not its complete wrapper: // GetStandaloneExpression promotes array/stackalloc wrappers to // their value expression. Outer normalization above is for policy. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs index 541f399e41b..483f37a37d1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/PostProcessing/StructuralTypeNameSelector.cs @@ -52,11 +52,19 @@ private sealed record Segment(string Name, int TypeIndex, int Arity); _ => throw new InvalidOperationException("Unsupported expression name") }; var name = Render(source); - if (name == null) return null; + if (name == null) + { + return null; + } + var expression = AsExpression(name); // Constructed IdentifierName binding does not prove serialized // contextual-token parsing. Share the same gate with this removal. - if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(expression)) return null; + if (ExpressionReceiverSyntaxFacts.ExposesContextualRoot(expression)) + { + return null; + } + return expression; } private readonly Segment[] _segments; @@ -86,7 +94,11 @@ internal NamePlan(IReadOnlyList parts, Form form, int typeCount) var sourceIndex = source.Parts.Length - _typeCount + segment.TypeIndex; var arguments = segment.TypeIndex >= 0 && sourceIndex >= 0 && sourceIndex < source.Parts.Length && source.Parts[sourceIndex] is GenericNameSyntax generic ? generic.TypeArgumentList : null; - if (index >= _form.Start && segment.Arity > 0 && arguments?.Arguments.Count != segment.Arity) return null; + if (index >= _form.Start && segment.Arity > 0 && arguments?.Arguments.Count != segment.Arity) + { + return null; + } + parts[index] = new Part(segment.Name, segment.TypeIndex, arguments, segment.Arity); } return StructuralTypeNameSelector.Render(parts, _form, source, _typeCount, crefSyntax); @@ -114,27 +126,46 @@ internal View(Context input, INamedTypeSymbol target) Input = input; Target = target; Facts = input.SharedFacts ?? new LookupFacts(input.Model, input.Reference!.SpanStart); var types = new Stack(); - for (var type = target; type != null; type = type.ContainingType) types.Push(type); + for (var type = target; type != null; type = type.ContainingType) + { + types.Push(type); + } + Types = types.ToArray(); LookupMode = input.ModeOverride ?? (input.Reference is CrefSyntax ? Mode.Cref : Mode.Type); if (input.ModeOverride == null && !input.KnownDeclarationType) - foreach (var ancestor in input.Reference!.Ancestors()) + { + foreach (var ancestor in input.Reference!.Ancestors()) { if (ancestor is CastExpressionSyntax cast && cast.Type.Span.Contains(input.Reference.Span) && cast.Expression is not ParenthesizedExpressionSyntax) - UnsupportedCast = true; - if (LookupMode != Mode.Nameof && ancestor is InvocationExpressionSyntax invocation && input.Model.GetOperation(invocation) is INameOfOperation) + { + UnsupportedCast = true; + } + + if (LookupMode != Mode.Nameof && ancestor is InvocationExpressionSyntax invocation && input.Model.GetOperation(invocation) is INameOfOperation) { LookupMode = Mode.Nameof; WholeNameofOperand = invocation.ArgumentList.Arguments.Single().Expression == input.Reference; } - if (ancestor is MemberDeclarationSyntax) break; + if (ancestor is MemberDeclarationSyntax) + { + break; + } + } } + if (input.ModeOverride == null && !input.KnownDeclarationType && LookupMode == Mode.Type && input.Reference!.Parent is MemberAccessExpressionSyntax member && member.Expression == input.Reference) + { LookupMode = Mode.Expression; + } + Source = input.Source ?? Describe(input.Reference!); SourceNames = Source.Parts; TerminalSpelling = SourceNames.LastOrDefault()?.Identifier.ValueText ?? target.Name; - if (!input.KnownDeclarationType && Source.Root != null) PreferredAlias = input.Model.GetAliasInfo(Source.Root)?.Name; + if (!input.KnownDeclarationType && Source.Root != null) + { + PreferredAlias = input.Model.GetAliasInfo(Source.Root)?.Name; + } } internal ISymbol[] Scope(string name, bool expressions) => Facts.Scope(name, expressions); @@ -188,8 +219,15 @@ internal IEnumerable Aliases() // visibility/precedence/target is resolved in this lookup region. var names = _aliasNames.GetValue(_model.Compilation, static compilation => new Lazy(() => CollectAliasNames(compilation))).Value; foreach (var name in names) - foreach (var symbol in Scope(name, false)) - if (symbol is IAliasSymbol alias) yield return alias; + { + foreach (var symbol in Scope(name, false)) + { + if (symbol is IAliasSymbol alias) + { + yield return alias; + } + } + } } } @@ -203,33 +241,54 @@ internal static Selection Choose(IReadOnlyList configurations, Func configurations, bool chooseOnly, Func? acceptPlan = null) { - if (configurations.Count == 0) throw new ArgumentException("At least one scope view is required", nameof(configurations)); + if (configurations.Count == 0) + { + throw new ArgumentException("At least one scope view is required", nameof(configurations)); + } + var original = configurations[0].Reference ?? throw new ArgumentException("Missing original reference"); var views = new List(); foreach (var configuration in configurations) { var target = configuration.Target; if (configuration.Reference == null || target == null || original is PredefinedTypeSyntax || target.IsUnboundGenericType) + { return new Selection(original, 0, 0, 0, Retention.UnsupportedInput); + } + views.Add(new View(configuration, target)); } var shape = views[0].Types; if (views.Any(v => v.Types.Length != shape.Length || v.Types.Where((type, index) => type.Name != shape[index].Name || type.Arity != shape[index].Arity).Any())) + { return Finish(original, views, 0, Retention.ConfigurationShape); + } // Preserve uncertain trivia and cast forms. Context is captured once, // not rediscovered for each candidate. Existing wrappers are untouched. if (views.Any(static v => v.Source.Diagnostics || v.Source.Directives || v.Source.SignificantTrivia)) + { return Finish(original, views, 0, Retention.Trivia); + } + if (views.Any(static v => v.UnsupportedCast)) + { return Finish(original, views, 0, Retention.CastShape); + } + if (views.Any(static v => v.LookupMode == Mode.Cref && v.Types.Any(static t => t.Arity > 0))) + { return Finish(original, views, 0, Retention.GenericCref); + } var first = views[0]; var sourceNames = first.SourceNames; var namespaces = new Stack(); - for (var ns = first.Target.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) namespaces.Push(ns); + for (var ns = first.Target.ContainingNamespace; !ns.IsGlobalNamespace; ns = ns.ContainingNamespace) + { + namespaces.Push(ns); + } + var parts = namespaces.Select(static ns => new Part(ns.Name, -1, null, 0)).ToList(); for (var index = 0; index < first.Types.Length; index++) { @@ -238,15 +297,27 @@ private static Selection EvaluateCore(IReadOnlyList configurations, boo parts.Add(new Part(first.Types[index].Name, index, arguments, first.Types[index].Arity)); } var forms = new List(); - for (var start = 0; start < parts.Count; start++) forms.Add(new Form(RootKind.Ordinary, start, null, false, parts.Count - start)); + for (var start = 0; start < parts.Count; start++) + { + forms.Add(new Form(RootKind.Ordinary, start, null, false, parts.Count - start)); + } + forms.Add(new Form(RootKind.Global, 0, null, false, parts.Count + 1)); foreach (var alias in first.Aliases()) { var namespaceAlias = alias.Target is INamespaceSymbol; - if (alias.Target is not INamespaceOrTypeSymbol) continue; + if (alias.Target is not INamespaceOrTypeSymbol) + { + continue; + } + for (var start = 0; start <= parts.Count; start++) { - if (namespaceAlias && start == parts.Count) continue; + if (namespaceAlias && start == parts.Count) + { + continue; + } + forms.Add(new Form(RootKind.Alias, start, alias.Name, namespaceAlias, parts.Count - start + 1, alias.Name == first.PreferredAlias)); } } @@ -254,18 +325,37 @@ private static Selection EvaluateCore(IReadOnlyList configurations, boo foreach (var form in forms.OrderBy(static f => f.Cost).ThenBy(static f => f.PreferredAlias ? 0 : 1) .ThenBy(static f => f.Root).ThenBy(static f => f.Alias, StringComparer.Ordinal).ThenBy(static f => f.Start)) { - if (parts.Skip(form.Start).Any(static p => p.Arity > 0 && p.Arguments?.Arguments.Count != p.Arity)) continue; + if (parts.Skip(form.Start).Any(static p => p.Arity > 0 && p.Arguments?.Arguments.Count != p.Arity)) + { + continue; + } // Prefix-only integration must not replace the terminal and drop // its opaque argument list via a closed-type alias. - if (form.Start == parts.Count && views.Any(static view => view.Input.PreserveTerminalArguments)) continue; + if (form.Start == parts.Count && views.Any(static view => view.Input.PreserveTerminalArguments)) + { + continue; + } + if (views.Any(view => view.LookupMode == Mode.Nameof && view.WholeNameofOperand && - (form.Start == parts.Count ? form.Alias : parts[^1].Name) != view.TerminalSpelling)) continue; + (form.Start == parts.Count ? form.Alias : parts[^1].Name) != view.TerminalSpelling)) + { + continue; + } + attempts++; if (views.All(view => Resolves(view, parts, form))) { - if (!chooseOnly) return Finish(Render(parts, form), views, attempts); + if (!chooseOnly) + { + return Finish(Render(parts, form), views, attempts); + } + var plan = new NamePlan(parts, form, first.Types.Length); - if (acceptPlan != null && !acceptPlan(plan)) continue; + if (acceptPlan != null && !acceptPlan(plan)) + { + continue; + } + return Finish(original, views, attempts, plan: plan); } } @@ -281,14 +371,24 @@ private static bool Resolves(View view, IReadOnlyList parts, Form form) var expression = view.LookupMode is Mode.Expression or Mode.Nameof; var start = form.Start; List current; - if (form.Root == RootKind.Global) current = new() { view.Input.Compilation.GlobalNamespace }; + if (form.Root == RootKind.Global) + { + current = new() { view.Input.Compilation.GlobalNamespace }; + } else if (form.Root == RootKind.Alias) { var bindings = view.Scope(form.Alias!, form.NamespaceAlias ? false : expression); - if (bindings.Any(static s => s is not INamespaceOrTypeSymbol && s is not IAliasSymbol)) return false; + if (bindings.Any(static s => s is not INamespaceOrTypeSymbol && s is not IAliasSymbol)) + { + return false; + } + current = bindings.OfType().Where(a => a.Name == form.Alias && (a.Target is INamespaceSymbol) == form.NamespaceAlias) .Select(static a => a.Target).OfType().ToList(); - if (current.Count == 0) return false; + if (current.Count == 0) + { + return false; + } } else { @@ -296,7 +396,11 @@ private static bool Resolves(View view, IReadOnlyList parts, Form form) start++; } current = current.Distinct(SymbolEqualityComparer.IncludeNullability).ToList(); - if (current.Count != 1) return false; + if (current.Count != 1) + { + return false; + } + for (; start < parts.Count; start++) { var part = parts[start]; @@ -304,7 +408,10 @@ private static bool Resolves(View view, IReadOnlyList parts, Form form) .Distinct(SymbolEqualityComparer.IncludeNullability).ToList(); // Ambiguity is a failure at this lookup stage. Later suffixes // cannot discard one ambiguous owner and make the path legal. - if (current.Count != 1) return false; + if (current.Count != 1) + { + return false; + } } return SymbolEqualityComparer.IncludeNullability.Equals(current[0], view.Target); } @@ -316,13 +423,20 @@ private static string[] CollectAliasNames(Compilation compilation) // method body. Lazy publication ensures one collection per compilation. foreach (var tree in compilation.SyntaxTrees) { - if (tree.GetRoot() is not CompilationUnitSyntax root) continue; + if (tree.GetRoot() is not CompilationUnitSyntax root) + { + continue; + } + Add(root.Usings); var pending = new Stack(root.Members.OfType()); while (pending.TryPop(out var ns)) { Add(ns.Usings); - foreach (var nested in ns.Members.OfType()) pending.Push(nested); + foreach (var nested in ns.Members.OfType()) + { + pending.Push(nested); + } } } return names.Order(StringComparer.Ordinal).ToArray(); @@ -330,7 +444,12 @@ private static string[] CollectAliasNames(Compilation compilation) void Add(IEnumerable directives) { foreach (var directive in directives) - if (directive.Alias != null) names.Add(directive.Alias.Name.Identifier.ValueText); + { + if (directive.Alias != null) + { + names.Add(directive.Alias.Name.Identifier.ValueText); + } + } } } @@ -343,14 +462,24 @@ private static List Bind(View view, ISymbol[] symbols, P var alias = symbol is IAliasSymbol; if (symbol is IAliasSymbol aliased) { - if (part.Arity != 0) continue; + if (part.Arity != 0) + { + continue; + } + symbol = aliased.Target; } - if (symbol is INamespaceSymbol ns && part.Arity == 0) result.Add(ns); + if (symbol is INamespaceSymbol ns && part.Arity == 0) + { + result.Add(ns); + } else if (symbol is INamedTypeSymbol named && (alias || named.Arity == part.Arity)) { if (!alias && named.Arity > 0 && part.TypeIndex >= 0) + { named = named.ConstructedFrom.Construct(view.Types[part.TypeIndex].TypeArguments.ToArray()); + } + result.Add(named); } else if (symbol is ITypeParameterSymbol && part.Arity == 0) @@ -360,7 +489,9 @@ private static List Bind(View view, ISymbol[] symbols, P } else if (symbol is not INamespaceOrTypeSymbol && !alias && (part.Arity == 0 || symbol is IMethodSymbol method && method.Arity == part.Arity)) + { return new List(); + } } return result; } @@ -370,7 +501,10 @@ private static NameSyntax Render(IReadOnlyList parts, Form form, SourceNam SimpleNameSyntax PartName(Part part) { if (source != null && part.TypeIndex >= 0) + { return source.Parts[source.Parts.Length - typeCount + part.TypeIndex]; + } + return part.Arity == 0 ? SyntaxFactory.IdentifierName(Identifier(part.Name)) : SyntaxFactory.GenericName(Identifier(part.Name), part.Arguments!); } @@ -379,14 +513,28 @@ SimpleNameSyntax PartName(Part part) if (form.Root == RootKind.Alias) { var alias = SyntaxFactory.IdentifierName(Identifier(form.Alias!)); - if (start == parts.Count) return alias; + if (start == parts.Count) + { + return alias; + } + name = form.NamespaceAlias && !crefSyntax ? SyntaxFactory.AliasQualifiedName(alias, PartName(parts[start++])) : SyntaxFactory.QualifiedName(alias, PartName(parts[start++])); } else if (form.Root == RootKind.Global) + { name = SyntaxFactory.AliasQualifiedName(SyntaxFactory.IdentifierName(SyntaxFactory.Token(SyntaxKind.GlobalKeyword)), PartName(parts[start++])); - else name = PartName(parts[start++]); - for (; start < parts.Count; start++) name = SyntaxFactory.QualifiedName(name, PartName(parts[start])); + } + else + { + name = PartName(parts[start++]); + } + + for (; start < parts.Count; start++) + { + name = SyntaxFactory.QualifiedName(name, PartName(parts[start])); + } + return name; } @@ -407,10 +555,18 @@ internal static SourceName Describe(SyntaxNode reference, bool preserveBoundaryC var significant = false; foreach (var trivia in reference.DescendantTrivia()) { - if (trivia.IsKind(SyntaxKind.WhitespaceTrivia) || trivia.IsKind(SyntaxKind.EndOfLineTrivia)) continue; + if (trivia.IsKind(SyntaxKind.WhitespaceTrivia) || trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + { + continue; + } + if (preserveBoundaryComments && (trivia.Span.End <= reference.SpanStart || trivia.SpanStart >= reference.Span.End) && trivia.Kind() is SyntaxKind.SingleLineCommentTrivia or SyntaxKind.MultiLineCommentTrivia or - SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia) continue; + SyntaxKind.SingleLineDocumentationCommentTrivia or SyntaxKind.MultiLineDocumentationCommentTrivia) + { + continue; + } + significant = true; break; } @@ -422,13 +578,21 @@ private static void CollectSourceParts(SyntaxNode node, List p switch (node) { case SimpleNameSyntax simple: - if (parts.Count == 0) rootName = simple as IdentifierNameSyntax; + if (parts.Count == 0) + { + rootName = simple as IdentifierNameSyntax; + } + parts.Add(simple); break; case QualifiedNameSyntax qualified: CollectSourceParts(qualified.Left, parts, ref rootName); parts.Add(qualified.Right); break; case AliasQualifiedNameSyntax alias: - if (parts.Count == 0) rootName = alias.Alias; + if (parts.Count == 0) + { + rootName = alias.Alias; + } + parts.Add(alias.Name); break; case MemberAccessExpressionSyntax member: CollectSourceParts(member.Expression, parts, ref rootName); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/XmlDocExceptionStatement.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/XmlDocExceptionStatement.cs index 4cec153d0cc..ed7deea35b9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/XmlDocExceptionStatement.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Statements/XmlDocExceptionStatement.cs @@ -44,7 +44,18 @@ internal override void Write(CodeWriter writer) { using var scope = new CodeWriter.XmlDocWritingScope(writer); - writer.Append($"/// "); + writer.AppendRaw("/// "); if (Parameters.Count > 0) { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index 7e567cd5338..9713797e86c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -563,6 +563,22 @@ private bool IsAvailable(string s) return true; } + // Write the payload of an existing XML cref attribute without introducing + // a nested tag. Text stays qualified; provenance enables later checks. + internal void AppendTypeInCref(CSharpType type) + { + var previousCref = _writingCrefType; + try + { + _writingCrefType = true; + AppendType(type, false, false); + } + finally + { + _writingCrefType = previousCref; + } + } + private void AppendTypeForCRef(CSharpType type) { if (ProviderReferenceMapAnalyzer.IsRemovedTypeReference(type)) @@ -585,16 +601,7 @@ private void AppendTypeForCRef(CSharpType type) } AppendRaw($""); if (isNullable) diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs index 93879d2e855..31b7629cc2f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderConditionalCleanupTests.cs @@ -122,8 +122,10 @@ public void HeaderInvariantSharedSourceDoesNotDisableOrdinaryPlans(string symbol Assert.True(new ProviderNameReducer(compilation).TryPlan(target.GetRoot(), writer.TypeReferences!, out var plan)); StringAssert.DoesNotContain("global::System.DateTimeOffset", plan!.Apply().ToString()); if (symbol == "DEBUG" || symbol == "HAS_INTERNALS_VISIBLE_CORE") + { Assert.False(new ProviderNameReducer(compilation).TryPlan(extra.GetRoot(), Array.Empty(), out _), "Unknown project-conditional documents remain opaque themselves"); + } } [Test] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs index 7d278158016..79d631bb37c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/ProviderQualifierReductionTests.cs @@ -45,8 +45,14 @@ public void StaticTypePrefixIsShortenedWithoutDroppingItsMember() public void StaticPrefixRespectsParameterShadowing(string parameter) { var output = Reduce(WriteStatic(typeof(DateTimeOffset), $"object {parameter}")); - if (parameter == "DateTimeOffset") StringAssert.Contains("global::System.DateTimeOffset.Now", output); - else StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + if (parameter == "DateTimeOffset") + { + StringAssert.Contains("global::System.DateTimeOffset.Now", output); + } + else + { + StringAssert.DoesNotContain("global::System.DateTimeOffset", output); + } } [TestCase("from DateTimeOffset in new[] { 1 } select ")] @@ -238,7 +244,11 @@ private static CodeFile WriteStatic(CSharpType type, string parameters = "") private static CodeFile WriteAttribute(CSharpType type, string import = "") { using var writer = new CodeWriter(); - if (import.Length > 0) writer.WriteRawLine(import); + if (import.Length > 0) + { + writer.WriteRawLine(import); + } + writer.WriteRawLine("namespace Example {"); writer.WriteLine($"[{type}(\"message\")]"); writer.WriteRawLine("public class Consumer {} }"); @@ -262,11 +272,19 @@ private static string Reduce(CodeFile file, string? additional = null) { observations.Add((reference.Span, Identity(model, reference), false)); if (reference.Parent is AttributeSyntax attribute) + { observations.Add((attribute.Span, Identity(model, attribute), false)); + } + if (reference.Parent is MemberAccessExpressionSyntax member && member.Expression == reference) + { observations.Add((member.Span, Identity(model, member), false)); + } + foreach (var invocation in reference.Ancestors().OfType().Where(static n => n.Expression.ToString() == "nameof")) + { observations.Add((invocation.Span, model.GetConstantValue(invocation).Value!.ToString()!, true)); + } } var output = plan!.Apply().ToString(); var parsed = CSharpSyntaxTree.ParseText(output); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs index 7799968027b..51da3febbaf 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeSafetyTests.cs @@ -146,7 +146,11 @@ public async Task StructuralAttributeVisibilityIsOccurrenceOwned(bool hiddenFirs var source = FlagDefinition + (hiddenFirst ? hidden + visible : visible + hidden); var output = await Reduce(RecordedAttributeSource(source)); StringAssert.Contains("[global::Fixtures.FlagAttribute] public class Hidden", output); - if (hiddenFirst) StringAssert.Contains("[Flag] public class Visible", output); + if (hiddenFirst) + { + StringAssert.Contains("[Flag] public class Visible", output); + } + AssertStructuralAttributes(source, output); } @@ -155,9 +159,17 @@ public async Task StructuralAttributeVisibilityIsOccurrenceOwned(bool hiddenFirs [TestCase("header")] public async Task StructuralAttributeWorkspaceAndHeaderGatesRemain(string mode) { - if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + if (mode == "disabled") + { + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); - if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + + if (mode == "custom") + { + CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + var source = FlagDefinition + "[global::Fixtures.FlagAttribute] public class Clock {}"; var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; var output = await Reduce(RecordedAttributeSource(source), extra); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs index d731e4c6386..1d7cee32b84 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralAttributeTests.cs @@ -199,7 +199,9 @@ string Constant(TypedConstant value) => value.Kind + ":" + Type(value.Type) + ": var original = Snapshot(before); var candidate = Snapshot(after); Assert.That(candidate.Contracts, Is.EqualTo(original.Contracts), "Whole attribute constructors/typed constants/named members must remain identical"); if (formatterRoute) + { Assert.That(SyntaxFactory.AreEquivalent(CSharpSyntaxTree.ParseText(candidate.Outside).GetRoot(), CSharpSyntaxTree.ParseText(original.Outside).GetRoot()), Is.True); + } else { Assert.That(candidate.Outside, Is.EqualTo(original.Outside), "All source including argument expressions outside attribute NAME spans must stay exact"); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralBranchStaticReceiverTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralBranchStaticReceiverTests.cs new file mode 100644 index 00000000000..0c0e33f0ccf --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralBranchStaticReceiverTests.cs @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(0, false, TestName = "BranchStatic_DirectiveBearingReceiverRetained")] + [TestCase(1, true, TestName = "BranchStatic_NestedUsingAndWriteCalls")] + [TestCase(2, true, TestName = "BranchStatic_ReturnedFallbackCall")] + [TestCase(3, false, TestName = "BranchStatic_InactiveOverloadCallerSourceCaptureRetainsOriginal")] + [TestCase(4, false, TestName = "BranchStatic_ClosureAppearsOnlyInInactiveParseRetainsOriginal")] + [TestCase(5, false, TestName = "BranchStatic_LocalFunctionRetained")] + [TestCase(6, false, TestName = "BranchStatic_OuterArgumentRetained")] + [TestCase(7, false, TestName = "BranchStatic_OuterConstructorCaptureRetained")] + [TestCase(8, false, TestName = "BranchStatic_InterpolationRetained")] + [TestCase(9, false, TestName = "BranchStatic_HiddenActiveSourceRetained")] + [TestCase(10, false, TestName = "BranchStatic_MissingProvenanceRetained")] + [TestCase(11, false, TestName = "BranchStatic_DefineDirectiveRetained")] + [TestCase(12, false, TestName = "BranchStatic_ConditionalHeaderRetained")] + [TestCase(13, false, TestName = "BranchStatic_ShadowChangesBetweenActiveParsesRetained")] + [TestCase(14, false, TestName = "BranchStatic_HiddenInactiveSourceRetained")] + [TestCase(15, false, TestName = "BranchStatic_InternalCommentRetained")] + [TestCase(16, true, TestName = "BranchStatic_DirectAssignedFallbackCall")] + public async Task BranchStaticReceiverTrial(int scenario, bool shorten) + { + var code = BranchStaticCode(scenario); + if (scenario is 3 or 4) + { + AssertInactiveBranchCaptureRegression(code.Content, scenario); + } + + string output; + if (scenario == 13) + { + // Preserve an original noncanonical framework state. The original + // active branch has no shadow; another active parse introduces it. + var parse = new CSharpParseOptions(preprocessorSymbols: new[] { "NET8_0_OR_GREATER" }); + var tree = CSharpSyntaxTree.ParseText(code.Content, parse, code.Name); + var compilation = CSharpCompilation.Create("BranchActiveShadow", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var root = tree.GetRoot(); + Assert.That(compilation.GetSemanticModel(tree).GetSymbolInfo(root.DescendantNodes().OfType().Single(c => c.Expression.ToString() == "global::Sample.Utility.Go")).Symbol, Is.Not.Null); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(root, code.TypeReferences!)!; + output = rewriter.Visit(root)!.ToFullString(); + AssertParenthesesOperations(code.Content, output, parseOptions: parse); + } + else + { + output = await Reduce(code); + } + + AssertConditionalParenthesesOperations(code.Content, output); + AssertParenthesesOperations(code.Content, output, parseOptions: new CSharpParseOptions(preprocessorSymbols: new[] { "EXPERIMENTAL", "NET8_0_OR_GREATER" })); + AssertBranchStaticDiagnostics(code.Content, output); + Assert.That(output.Contains("global::Sample.", StringComparison.Ordinal), Is.EqualTo(!shorten)); + } + + [Test] + public void BranchStaticDirectiveBoundaryTrace() + { + var code = BranchStaticCode(0); var tree = CSharpSyntaxTree.ParseText(code.Content); var root = tree.GetRoot(); + var compilation = CSharpCompilation.Create("BranchDirectiveTrace", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var receiver = root.DescendantNodes().OfType().Single(n => n.ToString() == "global::Sample.Utility"); + var member = (MemberAccessExpressionSyntax)receiver.Parent!; + var unit = ConditionalStaticReceiverRules.OuterExecutableUnit(receiver)!; + var conditional = new ProviderConditionalSyntax(compilation); + Assert.That(conditional.TryGetRoots(root, out var roots), Is.True); + Assert.That(ConditionalStaticReceiverRules.CanProveBranchLocalMethod(receiver, member, unit, roots, receiver.Span), Is.True, "Branch source certificate is not the rejecting guard"); + var source = StructuralTypeNameSelector.Describe(receiver, preserveBoundaryComments: true); + TestContext.Progress.WriteLine("Direct-call boundary: branchCertificate=true; ContainsDirectives=" + receiver.ContainsDirectives + "; source.Directives=" + source.Directives); + Assert.That(source.Directives, Is.True, "Existing DescribeRecordedReference rejects directive-bearing receiver trivia before selection"); + Assert.That(receiver.ContainsDirectives, Is.True); + } + + [TestCase(0, true)] + [TestCase(3, false)] + [TestCase(4, false)] + public void BranchStaticDisabledSourceCertificate(int scenario, bool safe) + { + var code = BranchStaticCode(scenario); + var original = CSharpSyntaxTree.ParseText(code.Content).GetRoot(); + var call = original.DescendantNodes().OfType().Single(c => c.Expression.ToString() == "global::Sample.Utility.Go"); + var receiver = ((MemberAccessExpressionSyntax)call.Expression).Expression; + var inactive = CSharpSyntaxTree.ParseText(code.Content, new CSharpParseOptions(preprocessorSymbols: new[] { "NET6_0_OR_GREATER" })).GetRoot(); + Assert.That(inactive.DescendantTrivia().Any(t => t.IsKind(SyntaxKind.DisabledTextTrivia) && t.Span.Contains(call.Span)), Is.True); + if (scenario == 4) + { + Assert.That(receiver.Ancestors().OfType(), Is.Empty, "Closure exists only in the inactive parse, not the original source context"); + } + + Assert.That(ConditionalStaticReceiverRules.HasUnobservedDisabledCall(call, inactive, receiver.Span), Is.EqualTo(safe), "Directly test the inactive-source certificate independently of earlier header/shape vetoes"); + } + + private static CodeFile BranchStaticCode(int scenario) + { + const string actionVsFunc = """ +public string Run() => Capture(() => { +#if NET6_0_OR_GREATER + return 1; +#else + global::Sample.Utility.Go(); +#endif + }); +"""; + const string inactiveOnlyClosure = """ +public void M() { +#if NET6_0_OR_GREATER + Source = Capture(() => { +#endif +#if NET6_0_OR_GREATER + return 1; +#else + global::Sample.Utility.Go(); +#endif +#if NET6_0_OR_GREATER + }); +#endif +} +"""; + string call = scenario switch + { + 1 => "foreach (var value in new[] { 1 }) { using (MemoryStream stream = global::Sample.Utility.Open()) { global::Sample.Utility.Write(stream); } }", + 2 => "return global::Sample.Utility.Abs(1);", + 6 => "Consume(global::Sample.Utility.Abs(1));", + 7 => "new Observer(global::Sample.Utility.Abs(1));", + 8 => "Source = $\"{(global::Sample.Utility.Abs(1))}\";", + 15 => "global::Sample./*keep*/Utility.Go();", + 16 => "Sink = global::Sample.Utility.Abs(1);", + _ => "global::Sample.Utility.Go();" + }; + string method = scenario == 3 ? actionVsFunc : scenario == 4 ? inactiveOnlyClosure : + (scenario == 2 ? "public int M() {\n" : "public void M() {\n") + + (scenario == 5 ? "void Local() {\n" : "") + + (scenario == 13 ? "#if NET8_0_OR_GREATER\n#else\nvar Utility = new Shadow();\n#endif\n" : "") + + (scenario == 9 ? "#line hidden\n" : "") + + "#if NET6_0_OR_GREATER\n" + (scenario == 14 ? "#line hidden\n" : "") + + (scenario == 2 ? "return 2;\n" : "Sink = 1;\n") + "#else\n" + call + "\n#endif\n" + + (scenario is 9 or 14 ? "#line default\n" : "") + + (scenario == 5 ? "}\nLocal();\n" : "") + "}\n"; + var source = (scenario == 11 ? "#define EXTRA\n" : "") + """ +using System; +using System.IO; +using System.Runtime.CompilerServices; +namespace Sample { + internal static class Utility { + public static void Go() {} + public static int Abs(int value) => value; + public static MemoryStream Open() => new MemoryStream(); + public static void Write(Stream stream) {} + } + internal class Shadow { public void Go() {} } + internal class Observer { public Observer(int value, [CallerArgumentExpression("value")] string source = null) {} } + public class Clock { + public int Sink; + public string Source; + static int Consume(int value) => value; + static string Capture(Action action) => "Action overload"; + static string Capture(Func action, [CallerArgumentExpression("action")] string source = null) => source; +""" + "\n" + (scenario == 12 ? "#if NET6_0_OR_GREATER\npublic void ConditionalMember() {}\n#endif\n" : "") + method + "} }"; + var type = new CSharpType("Utility", "Sample", false, false, null, Array.Empty(), false, false); + var sites = Regex.Matches(source, @"global::Sample\.(?:/\*keep\*/)?Utility").Cast().Select(m => new TypeReferenceSite(new TextSpan(m.Index, m.Length), type)).ToArray(); + return new CodeFile(source, "Clock.cs", scenario == 10 ? Array.Empty() : sites); + } + + private static void AssertInactiveBranchCaptureRegression(string source, int scenario) + { + // Preserve the read-only compiler witness: an unsafe forced namespace + // deletion changes NET6's ACTUAL implicit CAE string despite zero + // active Utility calls. With scenario4 the original parse has no + // enclosing closure at all, so inactive-parse inspection is essential. + var forced = source.Replace("global::Sample.Utility.Go()", "Utility.Go()", StringComparison.Ordinal); + foreach (var net6 in new[] { false, true }) + { + string? previousMethod = null, capturedBefore = null, capturedAfter = null; + foreach (var text in new[] { source, forced }) + { + var tree = CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(preprocessorSymbols: net6 ? new[] { "NET6_0_OR_GREATER" } : Array.Empty())); + var compilation = CSharpCompilation.Create("InactiveBranchRegression", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.That(compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), Is.Empty); + var calls = tree.GetRoot().DescendantNodes().OfType().Where(c => c.Expression.ToString() == "Capture").ToArray(); + if (!net6 && scenario == 4) { Assert.That(calls, Is.Empty); continue; } + var call = calls.Single(); var operation = (IInvocationOperation)compilation.GetSemanticModel(tree).GetOperation(call)!; + Assert.That(operation.TargetMethod.Parameters[0].Type.Name, Is.EqualTo(net6 ? "Func" : "Action")); + var signature = operation.TargetMethod.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + if (previousMethod != null) + { + Assert.That(signature, Is.EqualTo(previousMethod)); + } + + previousMethod = signature; + var argument = operation.Arguments.SingleOrDefault(a => a.Parameter?.Name == "source"); + string? captured = argument?.Value.ConstantValue.Value as string; + if (text == source) + { + capturedBefore = captured; + } + else + { + capturedAfter = captured; + } + + using var pe = new MemoryStream(); Assert.That(compilation.Emit(pe).Success, Is.True); + if (net6) + { + Assert.That(tree.GetRoot().DescendantNodes().OfType().Any(c => c.Expression.ToString().EndsWith("Utility.Go", StringComparison.Ordinal)), Is.False); + } + else + { + Assert.That(argument, Is.Null); + } + } + if (net6) + { + Assert.That(capturedBefore, Does.Contain("global::Sample.Utility.Go()")); + Assert.That(capturedAfter, Is.EqualTo(capturedBefore!.Replace("global::Sample.Utility.Go()", "Utility.Go()", StringComparison.Ordinal))); + Assert.That(capturedAfter, Is.Not.EqualTo(capturedBefore)); + } + } + } + + private static void AssertBranchStaticDiagnostics(string before, string after) + { + const string prefix = "global::Sample."; + var edits = new List(); int a = 0, b = 0; + while (a < before.Length || b < after.Length) + { + if (a < before.Length && b < after.Length && before[a] == after[b]) { a++; b++; continue; } + Assert.That(before.AsSpan(a).StartsWith(prefix + "Utility.", StringComparison.Ordinal), Is.True, "Only recorded Utility prefix may change"); + edits.Add(new TextSpan(a, prefix.Length)); a += prefix.Length; + } + foreach (var symbols in new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER" }, + new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER" }, new[] { "NET8_0_OR_GREATER" } }) + { + var parse = new CSharpParseOptions(preprocessorSymbols: symbols.Prepend("EXPERIMENTAL")); + var oldTree = CSharpSyntaxTree.ParseText(before, parse, "BranchStatic.cs"); var nextTree = CSharpSyntaxTree.ParseText(after, parse, oldTree.FilePath); + var old = CSharpCompilation.Create("BranchStaticDiagnostics", new[] { oldTree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var next = old.ReplaceSyntaxTree(oldTree, nextTree); + int Project(int position) { Assert.That(edits.Any(e => e.Start < position && position < e.End), Is.False); return position - edits.Where(e => e.End <= position).Sum(e => e.Length); } + object Location(Location location, bool project) + { + if (!location.IsInSource) + { + return new { kind = location.Kind.ToString() }; + } + + var span = location.SourceSpan; if (project) { Assert.That(edits.Any(e => e.IntersectsWith(span)), Is.False); span = TextSpan.FromBounds(Project(span.Start), Project(span.End)); } + var physical = nextTree.GetLineSpan(span); var mapped = nextTree.GetMappedLineSpan(span); + return new { physical.Path, Start = span.Start, End = span.End, physicalStart = physical.StartLinePosition.ToString(), physicalEnd = physical.EndLinePosition.ToString(), mappedPath = mapped.Path, mappedStart = mapped.StartLinePosition.ToString(), mappedEnd = mapped.EndLinePosition.ToString() }; + } + string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.Select(d => System.Text.Json.JsonSerializer.Serialize(new + { + d.Id, severity = d.Severity.ToString(), defaultSeverity = d.DefaultSeverity.ToString(), d.IsSuppressed, d.IsWarningAsError, d.WarningLevel, message = d.GetMessage(), + descriptor = new { d.Descriptor.Id, title = d.Descriptor.Title.ToString(), message = d.Descriptor.MessageFormat.ToString(), d.Descriptor.Category, d.Descriptor.IsEnabledByDefault, description = d.Descriptor.Description.ToString(), d.Descriptor.HelpLinkUri, tags = d.Descriptor.CustomTags.ToArray() }, + properties = d.Properties.OrderBy(p => p.Key, StringComparer.Ordinal).ToArray(), location = Location(d.Location, project), additional = d.AdditionalLocations.Select(l => Location(l, project)).ToArray() + })).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.That(Bag(next.GetDiagnostics(), false), Is.EqualTo(Bag(old.GetDiagnostics(), true)), "Complete compiler diagnostic multiset, including Hidden/Info and every endpoint"); + using var oldPe = new MemoryStream(); using var nextPe = new MemoryStream(); var oldEmit = old.Emit(oldPe); var nextEmit = next.Emit(nextPe); + Assert.That(nextEmit.Success, Is.EqualTo(oldEmit.Success)); Assert.That(Bag(nextEmit.Diagnostics, false), Is.EqualTo(Bag(oldEmit.Diagnostics, true))); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs index c08096600bd..9959b453271 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefCompositionRegressionTests.cs @@ -22,7 +22,11 @@ public async Task StructuralC5CrefRetentionAndOptionalDefaultKeepOriginalImportU { const string type = "global::System.DateTimeOffset"; var imports = mode == "globalOtherDocument" ? "" : "using System;"; - if (mode == "alias") imports += "using Alias=System.String;"; + if (mode == "alias") + { + imports += "using Alias=System.String;"; + } + var extraMember = mode == "alias" ? "Alias Name=>\"\";" : mode == "warning" ? "int unused;" : ""; var source = imports + "\nclass Clock{" + extraMember + "\n/// \nvoid M(global::System.DateTimeOffset value=default(DateTimeOffset)){} }"; var extra = mode == "globalOtherDocument" ? "global using System;" : null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs index d952a29461a..6a39ac33cce 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticRegressionTests.cs @@ -14,7 +14,7 @@ namespace Microsoft.TypeSpec.Generator.Tests { public partial class StructuralTypeNameIntegrationTests { - [TestCase("using System;")] + // Global imports can affect OTHER documents and remain unsupported. [TestCase("global using System;")] public async Task StructuralC5CrefPreservesHiddenUnusedImportDiagnostic(string directive) { @@ -25,6 +25,21 @@ public async Task StructuralC5CrefPreservesHiddenUnusedImportDiagnostic(string d Assert.That(C5CrefDiagnostics(output), Is.EqualTo(before), "Equal cref symbols and XML do not establish preservation of import diagnostics."); } + [Test] + public async Task StructuralCrefImportActivationChangesOnlyHiddenUnusedImport() + { + // Preserve the original C5 ordinary-import regression input. Its old + // exact-Hidden-equality expectation is now an explicit policy delta: + // this same import becomes genuinely used by the bare type cref. + var code = C5DiagnosticCrefCode("using System;", ""); + var before = C5CrefDiagnostics(code.Content); + Assert.That(before, Is.EqualTo(new[] { "CS8019|Hidden|False|Unnecessary using directive." })); + var output = await Reduce(code); + StringAssert.Contains("cref=\"DateTimeOffset\"", output); + Assert.That(C5CrefDiagnostics(output), Is.Empty); + AssertStructuralCrefs(code.Content, output); + } + [Test] public async Task StructuralC5CrefGenuinelyUsedImportStillSimplifies() { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs index 03424c4165b..07bb49ae6c9 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralC5CrefDiagnosticSafetyTests.cs @@ -35,7 +35,21 @@ public void StructuralC5CrefDocumentationModeAndRealImportUse(DocumentationMode { var code = C5DiagnosticCrefCode("using System;", genuinelyUsed ? "public DateTimeOffset Value=>default;" : ""); var output = ReduceC5CrefDirect(code, new CSharpParseOptions(LanguageVersion.Preview, mode), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - StringAssert.Contains(genuinelyUsed ? "cref=\"DateTimeOffset\"" : "cref=\"global::System.DateTimeOffset\"", output); + // Parse/Diagnose now permit the explicitly approved Hidden-CS8019 + // activation. None still has no parsed cref and must retain it. + var before = C5CrefDiagnostics(code.Content); + Assert.That(before.Any(static d => d.StartsWith("CS8019|Hidden|", StringComparison.Ordinal)), Is.EqualTo(!genuinelyUsed)); + var shouldShorten = mode != DocumentationMode.None; + StringAssert.Contains(shouldShorten ? "cref=\"DateTimeOffset\"" : "cref=\"global::System.DateTimeOffset\"", output); + if (shouldShorten) + { + Assert.That(C5CrefDiagnostics(output), Is.Empty); + AssertStructuralCrefs(code.Content, output); + } + else + { + Assert.That(C5CrefDiagnostics(output), Is.EqualTo(before)); + } } [TestCase(false)] diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs index 19ca001d5b5..8bcdfa58ece 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesSafetyTests.cs @@ -34,7 +34,10 @@ public async Task StructuralConditionalParenthesesHiddenAndVisibleOrderIsOccurre var source = "class Clock { " + (hiddenFirst ? hidden + visible : visible + hidden) + ConditionalParenthesesBranch + "}"; var output = await Reduce(new CodeFile(source, "Clock.cs")); StringAssert.Contains("Hidden(int x, int y) => ((x == y))", output); - if (hiddenFirst) StringAssert.Contains("Visible(int x, int y) => x == y", output); + if (hiddenFirst) + { + StringAssert.Contains("Visible(int x, int y) => x == y", output); + } // BeforeFirstLineDirective is deliberately non-visible for this slice. AssertConditionalParenthesesOperations(source, output); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs index 4fe60f927e3..4414ab10fca 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalParenthesesTests.cs @@ -93,7 +93,9 @@ private static void AssertConditionalParenthesesOperations(string before, string { foreach (var symbols in new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER" } }) + { AssertParenthesesOperations(before, after, parseOptions: new CSharpParseOptions(preprocessorSymbols: symbols.Prepend("EXPERIMENTAL"))); + } } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs index 7b5e8826b20..140b1993524 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralConditionalStaticReceiverImplicitTests.cs @@ -25,8 +25,12 @@ public async Task StructuralConditionalStaticReceiverImplicitCollectionCaptureIs var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); StringAssert.Contains("foreach(var value in global::Factory.Create())", output, "Implicit GetEnumerator CAE observes the collection spelling"); - if (bodyOccurrence) StringAssert.Contains("result=Math.Abs(value-3)", output, + if (bodyOccurrence) + { + StringAssert.Contains("result=Math.Abs(value-3)", output, "The loop body is not part of the observed collection expression"); + } + AssertConditionalParenthesesOperations(source, output); } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefImportActivationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefImportActivationTests.cs new file mode 100644 index 00000000000..9443834c55d --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefImportActivationTests.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using Microsoft.TypeSpec.Generator.Providers; +using Microsoft.TypeSpec.Generator.Statements; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(0, true, TestName = "CrefImportActivation_NormalImportBecomesUsed")] + [TestCase(1, true, TestName = "CrefImportActivation_UnrelatedHiddenImportPreserved")] + [TestCase(2, false, TestName = "CrefImportActivation_GlobalImportRetained")] + [TestCase(3, false, TestName = "CrefImportActivation_AliasImportRetained")] + [TestCase(4, false, TestName = "CrefImportActivation_MissingProvenanceRetained")] + [TestCase(5, false, TestName = "CrefImportActivation_OtherWarningRetained")] + [TestCase(6, false, TestName = "CrefImportActivation_SameNamedMethodCannotStealCref")] + [TestCase(7, true, TestName = "CrefImportActivation_MergedNamespaceUsesCompilationIdentity")] + public async Task CrefImportActivationTrial(int scenario, bool shorten) + { + var directive = scenario == 2 ? "global using System;" : "using System;"; + if (scenario == 1) + { + directive += "\nusing System.Text;"; + } + + if (scenario == 3) + { + directive += "\nusing ExceptionAlias = System.ArgumentNullException;"; + } + + if (scenario == 5) + { + directive += "\n#warning keep this warning"; + } + + var source = directive + "\nnamespace Sample { class Clock {\n/// Value is null. \nvoid M() {}\n" + (scenario == 6 ? "void ArgumentNullException() {}\n" : "") + "} }"; + if (scenario == 7) + { + source += "\nnamespace System { internal class NamespaceContributor {} }"; + } + + var code = RecordedCrefSource(source, typeof(ArgumentNullException)); + if (scenario == 7) + { + var tree = CSharpSyntaxTree.ParseText(code.Content, new CSharpParseOptions(documentationMode: DocumentationMode.Parse)); + var compilation = CSharpCompilation.Create("MergedNamespaceCref", new[] { tree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var imported = compilation.GetSemanticModel(tree).GetSymbolInfo(((CompilationUnitSyntax)tree.GetRoot()).Usings.Single().Name!).Symbol; + var target = compilation.GetTypeByMetadataName("System.ArgumentNullException")!; + Assert.That(SymbolEqualityComparer.Default.Equals(imported, target.ContainingNamespace), Is.False, "A constituent namespace is not the merged compilation namespace"); + Assert.That(SymbolEqualityComparer.Default.Equals(imported, compilation.GetCompilationNamespace(target.ContainingNamespace)), Is.True, "Compiler-backed canonical namespace identity"); + } + if (scenario == 4) + { + code = new CodeFile(code.Content, code.Name); + } + + var output = await Reduce(code); + AssertStructuralCrefs(code.Content, output); + AssertExpectedCrefImportActivation(code.Content, output, shorten); + StringAssert.Contains("cref=\"" + (shorten ? "ArgumentNullException" : "global::System.ArgumentNullException") + "\"", output); + } + + [Test] + public void ExceptionCrefWriterRecordsWholeTypeProvenance() + { + var code = ExceptionImportActivationCode(); + var tree = CSharpSyntaxTree.ParseText(code.Content, new CSharpParseOptions(documentationMode: DocumentationMode.Parse)); + var cref = tree.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Single().Cref; + Assert.That(cref.ToString(), Is.EqualTo("global::System.ArgumentNullException"), "Producer text remains qualified"); + var site = code.TypeReferences!.Single(s => s.Span == cref.Span); + Assert.That(site.Role, Is.EqualTo(TypeReferenceRole.WholeTypeCref)); + Assert.That(site.Type.FrameworkType, Is.EqualTo(typeof(ArgumentNullException))); + } + + [Test] + public async Task ExceptionCrefActualWriterImportActivation() + { + var code = ExceptionImportActivationCode(); var output = await Reduce(code); + AssertStructuralCrefs(code.Content, output); AssertExpectedCrefImportActivation(code.Content, output, true); + StringAssert.Contains("cref=\"ArgumentNullException\"", output); + } + + private static CodeFile ExceptionImportActivationCode() => Recorded(writer => + { + writer.WriteRawLine("namespace Sample { class Clock {"); + new XmlDocExceptionStatement(typeof(ArgumentNullException), Array.Empty()).Write(writer); + writer.WriteRawLine("void M() {} } }"); + }); + + [TestCase(ReportDiagnostic.Warn, false)] + [TestCase(ReportDiagnostic.Error, false)] + [TestCase(ReportDiagnostic.Suppress, false)] + [TestCase(ReportDiagnostic.Warn, true)] + [TestCase(ReportDiagnostic.Error, true)] + [TestCase(ReportDiagnostic.Suppress, true)] + public void CrefImportActivationCustomSeverityRetains(ReportDiagnostic severity, bool treeProvider) + { + var code = RecordedCrefSource("using System;\nclass Clock {\n/// \nvoid M(){} }", typeof(ArgumentNullException)); + var tree = CSharpSyntaxTree.ParseText(code.Content, new CSharpParseOptions(documentationMode: DocumentationMode.Parse)); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, reportSuppressedDiagnostics: true); + options = treeProvider ? options.WithSyntaxTreeOptionsProvider(new CrefActivationSeverityProvider(severity)) : options.WithSpecificDiagnosticOptions(new Dictionary { ["CS8019"] = severity }); + var compilation = CSharpCompilation.Create("CrefImportSeverity", new[] { tree }, References, options); + var rewriter = new StructuralTypeNameReducer(compilation).CreateRewriter(tree.GetRoot(), code.TypeReferences!)!; + StringAssert.Contains("cref=\"global::System.ArgumentNullException\"", rewriter.Visit(tree.GetRoot())!.ToFullString()); + } + + private sealed class CrefActivationSeverityProvider(ReportDiagnostic severity) : SyntaxTreeOptionsProvider + { + public override GeneratedKind IsGenerated(SyntaxTree tree, CancellationToken cancellationToken) => GeneratedKind.Unknown; + public override bool TryGetDiagnosticValue(SyntaxTree tree, string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic value) { value = severity; return diagnosticId == "CS8019"; } + public override bool TryGetGlobalDiagnosticValue(string diagnosticId, CancellationToken cancellationToken, out ReportDiagnostic value) { value = ReportDiagnostic.Default; return false; } + } + + private static void AssertExpectedCrefImportActivation(string before, string after, bool expectActivation) + { + const string prefix = "global::System."; + var edits = new List(); int a = 0, b = 0; + while (a < before.Length || b < after.Length) + { + if (a < before.Length && b < after.Length && before[a] == after[b]) { a++; b++; continue; } + Assert.That(before.AsSpan(a).StartsWith(prefix + "ArgumentNullException", StringComparison.Ordinal), Is.True); + edits.Add(new TextSpan(a, prefix.Length)); a += prefix.Length; + } + var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); + var oldTree = CSharpSyntaxTree.ParseText(before, options, path: "CrefImportActivation.cs"); var nextTree = CSharpSyntaxTree.ParseText(after, options, path: oldTree.FilePath); + var old = CSharpCompilation.Create("CrefImportActivationDiagnostics", new[] { oldTree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var next = old.ReplaceSyntaxTree(oldTree, nextTree); + var targetImport = ((CompilationUnitSyntax)oldTree.GetRoot()).Usings.First(u => u.Name!.ToString() == "System"); + var originalHidden = old.GetDiagnostics().Where(d => d.Id == "CS8019" && d.Severity == DiagnosticSeverity.Hidden && d.Location.SourceSpan == targetImport.Span).ToArray(); + Assert.That(originalHidden.Length, Is.EqualTo(1), "Original normal import is actually unused"); + // An expected removal is allowed only after an ACTUAL namespace deletion; + // baseline tests still reach the spelling assertion while old rules retain. + bool activated = expectActivation && edits.Count != 0; + int Project(int position) { Assert.That(edits.Any(e => e.Start < position && position < e.End), Is.False); return position - edits.Where(e => e.End <= position).Sum(e => e.Length); } + object Location(Location location, bool project) + { + if (!location.IsInSource) + { + return new { kind = location.Kind.ToString() }; + } + + var span = location.SourceSpan; if (project) { Assert.That(edits.Any(e => e.IntersectsWith(span)), Is.False); span = TextSpan.FromBounds(Project(span.Start), Project(span.End)); } + var physical = nextTree.GetLineSpan(span); var mapped = nextTree.GetMappedLineSpan(span); + return new { physical.Path, Start = span.Start, End = span.End, physicalStart = physical.StartLinePosition.ToString(), physicalEnd = physical.EndLinePosition.ToString(), mappedPath = mapped.Path, mappedStart = mapped.StartLinePosition.ToString(), mappedEnd = mapped.EndLinePosition.ToString() }; + } + bool ExpectedRemoval(Diagnostic d) => activated && d.Id == "CS8019" && d.Severity == DiagnosticSeverity.Hidden && d.DefaultSeverity == DiagnosticSeverity.Hidden && !d.IsSuppressed && d.Location.SourceSpan == targetImport.Span; + string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.Where(d => !project || !ExpectedRemoval(d)).Select(d => System.Text.Json.JsonSerializer.Serialize(new + { + d.Id, severity = d.Severity.ToString(), defaultSeverity = d.DefaultSeverity.ToString(), d.IsSuppressed, d.WarningLevel, message = d.GetMessage(), + descriptor = new { d.Descriptor.Id, title = d.Descriptor.Title.ToString(), message = d.Descriptor.MessageFormat.ToString(), d.Descriptor.Category, d.Descriptor.IsEnabledByDefault, description = d.Descriptor.Description.ToString(), d.Descriptor.HelpLinkUri, tags = d.Descriptor.CustomTags.ToArray() }, + properties = d.Properties.OrderBy(p => p.Key, StringComparer.Ordinal).ToArray(), location = Location(d.Location, project), additional = d.AdditionalLocations.Select(l => Location(l, project)).ToArray() + })).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + if (activated) + { + Assert.That(next.GetDiagnostics().Any(d => d.Id == "CS8019" && d.Location.SourceSpan == targetImport.Span), Is.False, "Approved shortening genuinely uses the existing import"); + } + + Assert.That(Bag(next.GetDiagnostics(), false), Is.EqualTo(Bag(old.GetDiagnostics(), true)), "All diagnostic fields and locations preserved except the one explicitly activated Hidden import"); + using var oldPe = new MemoryStream(); using var newPe = new MemoryStream(); using var oldXml = new MemoryStream(); using var newXml = new MemoryStream(); + var oldEmit = old.Emit(oldPe, xmlDocumentationStream: oldXml); var newEmit = next.Emit(newPe, xmlDocumentationStream: newXml); + Assert.That(newEmit.Success, Is.EqualTo(oldEmit.Success)); Assert.That(Bag(newEmit.Diagnostics, false), Is.EqualTo(Bag(oldEmit.Diagnostics, true))); + if (oldEmit.Success) + { + Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray()), "Whole XML/documentation identity preserved"); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs index 40624cf1311..1e5a0d3e4f5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralCrefSafetyTests.cs @@ -102,7 +102,10 @@ public async Task StructuralCrefWholeTypeRoleDoesNotAuthorizeUnsupportedShape(st var options = new CSharpParseOptions(documentationMode: DocumentationMode.Diagnose); Assert.That(CSharpSyntaxTree.ParseText(source, options).GetDiagnostics().Any(static d => d.Id == "CS1584"), Is.True); } - else AssertStructuralCrefs(source, output); + else + { + AssertStructuralCrefs(source, output); + } } [TestCase(false)] @@ -115,7 +118,11 @@ public async Task StructuralCrefVisibilityIsNotCachedAcrossOccurrences(bool hidd var output = await Reduce(RecordedCrefSource(source, typeof(DateTimeOffset))); var crefs = CSharpSyntaxTree.ParseText(output).GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Select(a => a.Cref.ToString()).ToArray(); Assert.That(crefs[hiddenFirst ? 0 : 1], Is.EqualTo("global::System.DateTimeOffset")); - if (hiddenFirst) Assert.That(crefs[1], Is.EqualTo("DateTimeOffset")); + if (hiddenFirst) + { + Assert.That(crefs[1], Is.EqualTo("DateTimeOffset")); + } + AssertStructuralCrefs(source, output); } @@ -149,9 +156,17 @@ public async Task StructuralCrefCapturedLocalFunctionDocumentationUsesEnclosingO [TestCase("header")] public async Task StructuralCrefWorkspaceAndCompilationGatesRemain(string mode) { - if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + if (mode == "disabled") + { + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); - if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + + if (mode == "custom") + { + CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + const string source = "using System;\n/// \npublic class Clock {}"; var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; // Custom rewriters deliberately use the existing formatter route. diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs index 02d39f92e35..9aa4e2233c3 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralImmediateNamespaceCrefTests.cs @@ -74,7 +74,11 @@ public void UnsupportedCertificateDoesNotRelaxOriginalRetention(string kind) kind == "enhanced-line" ? "#line (100,1)-(200,10) 40 \"map.cs\"\n" : "", kind == "conditional" ? "#if EXPERIMENTAL\nclass Alternate {}\n#endif" : "", shadow: kind == "shadow", hidden: kind == "hidden"); - if (kind == "unrecorded") code = new CodeFile(code.Content, code.Name, Array.Empty()); + if (kind == "unrecorded") + { + code = new CodeFile(code.Content, code.Name, Array.Empty()); + } + var result = Reduce(code); StringAssert.Contains("cref=\"global::N.T\"", result.Text); Preserve(code.Content, result); @@ -98,16 +102,47 @@ private static CodeFile Build(string attachment, bool fileScoped, string prefix writer.WriteRawLine("using N;"); writer.WriteRawLine(fileScoped ? "namespace N;" : "namespace N {"); writer.WriteRawLine("public partial class T {}"); - if (attachment == "ancestor") writer.WriteRawLine("namespace Inner {"); - if (attachment == "constructor") writer.WriteRawLine("public partial class T {"); - if (attachment == "method") writer.WriteRawLine("public class C {" + (shadow ? "public void T() {}" : "")); - if (hidden) writer.WriteRawLine("#line hidden"); + if (attachment == "ancestor") + { + writer.WriteRawLine("namespace Inner {"); + } + + if (attachment == "constructor") + { + writer.WriteRawLine("public partial class T {"); + } + + if (attachment == "method") + { + writer.WriteRawLine("public class C {" + (shadow ? "public void T() {}" : "")); + } + + if (hidden) + { + writer.WriteRawLine("#line hidden"); + } + using (new CodeWriter.XmlDocWritingScope(writer)) + { writer.WriteLine($"/// {new CSharpType("T", "N", false, false, null, Array.Empty(), true, false):C}"); + } + writer.WriteRawLine(attachment == "constructor" ? "public T() {}" : attachment == "method" ? "public void M() {}" : "public class C {}"); - if (hidden) writer.WriteRawLine("#line default"); - if (attachment is "constructor" or "method" or "ancestor") writer.WriteRawLine("}"); - if (!fileScoped) writer.WriteRawLine("}"); + if (hidden) + { + writer.WriteRawLine("#line default"); + } + + if (attachment is "constructor" or "method" or "ancestor") + { + writer.WriteRawLine("}"); + } + + if (!fileScoped) + { + writer.WriteRawLine("}"); + } + writer.WriteRawLine(other); return new CodeFile(writer.ToString(), "ImmediateNamespace.cs", writer.TypeReferences); } @@ -123,7 +158,11 @@ private static Result Reduce(CodeFile code, string? other = null) .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); var document = project.AddDocument(code.Name, SourceText.From(code.Content), new[] { "Generated" }); project = document.Project; - if (other != null) project = project.AddDocument("Other.cs", SourceText.From(other)).Project; + if (other != null) + { + project = project.AddDocument("Other.cs", SourceText.From(other)).Project; + } + document = project.GetDocument(document.Id)!; var compilation = (CSharpCompilation)project.GetCompilationAsync().GetAwaiter().GetResult()!; var tree = document.GetSyntaxTreeAsync().GetAwaiter().GetResult()!; @@ -141,11 +180,19 @@ private static void Preserve(string original, Result result) var oldCref = result.Original.GetRoot().DescendantNodes(descendIntoTrivia: true).OfType().Single().Cref; var changed = original != result.Text; var start = oldCref.SpanStart; - if (changed) Assert.That(result.Text, Is.EqualTo(original.Remove(start, prefix.Length)), "Only the witnessed namespace prefix may be deleted"); + if (changed) + { + Assert.That(result.Text, Is.EqualTo(original.Remove(start, prefix.Length)), "Only the witnessed namespace prefix may be deleted"); + } + var removed = changed ? prefix.Length : 0; string LocationKey(Location location, bool project) { - if (!location.IsInSource) return location.Kind.ToString(); + if (!location.IsInSource) + { + return location.Kind.ToString(); + } + var span = location.SourceSpan;var tree = location.SourceTree!; if (project && ReferenceEquals(tree, result.Original)) { @@ -161,7 +208,10 @@ string LocationKey(Location location, bool project) var before = result.Before.Emit(oldPe, xmlDocumentationStream: oldXml);var after = result.After.Emit(newPe, xmlDocumentationStream: newXml); Assert.That(after.Success, Is.EqualTo(before.Success)); Assert.That(Bag(after.Diagnostics, false), Is.EqualTo(Bag(before.Diagnostics, true)), "Complete emit diagnostic multiset"); - if (before.Success) Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray()), "Successful emitted XML must remain identical"); + if (before.Success) + { + Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray()), "Successful emitted XML must remain identical"); + } } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs index fb080477a66..5e1d9748a15 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespacePrefixTests.cs @@ -169,7 +169,11 @@ public async Task NamespacePrefixEvidenceMustBeValidBeforePlanCacheReuse(int kin 2 => sites[1] with { Role = TypeReferenceRole.WholeTypeCref }, _ => sites[1] }; - if (kind == 3) sites.Add(sites[1]); + if (kind == 3) + { + sites.Add(sites[1]); + } + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); StringAssert.Contains("List First", output); StringAssert.DoesNotContain("global::System.Collections.Generic.List First", output); @@ -255,7 +259,11 @@ public async Task NamespacePrefixesRespectConditionalVariantEligibility(bool gen writer.WriteRawLine("namespace Sample { public class Clock {"); writer.WriteLine($"public {list} Value => default;"); writer.WriteRawLine("public object M() {\n#if NET8_0_OR_GREATER"); - if (genericLocalFunction) writer.WriteRawLine("void Local() {} "); + if (genericLocalFunction) + { + writer.WriteRawLine("void Local() {} "); + } + writer.WriteLine($"{list} inactive = default;"); writer.WriteRawLine("#else\nint boundary = 0;"); writer.WriteLine($"{list} active = default;"); @@ -264,7 +272,10 @@ public async Task NamespacePrefixesRespectConditionalVariantEligibility(bool gen writer.WriteRawLine("} } }"); }); var output = await Reduce(code); - if (genericLocalFunction) StringAssert.Contains("global::System.Collections.Generic.List Value", output); + if (genericLocalFunction) + { + StringAssert.Contains("global::System.Collections.Generic.List Value", output); + } else { StringAssert.DoesNotContain("global::System.Collections.Generic.List Value", output); @@ -276,7 +287,9 @@ static string[] Disabled(string source) => CSharpSyntaxTree.ParseText(source).Ge .Where(static trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static trivia => trivia.ToFullString()).ToArray(); Assert.That(Disabled(output), Is.EqualTo(Disabled(code.Content))); foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER"), new CSharpParseOptions().WithPreprocessorSymbols("NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER") }) + { AssertOperationBindings(code.Content, output, options); + } } private static TypeReferenceSite[] PrefixSites(string source, CSharpType type) => CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs index ddc9b26856b..109752c24c5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceRoleTests.cs @@ -189,8 +189,15 @@ public async Task ExplicitInterfaceScopeDoesNotShareMethodTypeParameterFacts(boo { writer.WriteRawLine(returnFirst ? "namespace N { public interface I { global::N.I M(I value); } }" : "namespace N { public interface I { void M(I value); } }"); writer.WriteRawLine("namespace Sample { public class Clock : N.I {"); - if (returnFirst) writer.WriteLine($"{iface} {iface}.M(I value) {{"); - else writer.WriteLine($"void {iface}.M(I value) {{"); + if (returnFirst) + { + writer.WriteLine($"{iface} {iface}.M(I value) {{"); + } + else + { + writer.WriteLine($"void {iface}.M(I value) {{"); + } + writer.WriteLine($"{iface} other = null;"); writer.WriteRawLine(returnFirst ? "return other; } } }" : "} } }"); }); @@ -198,7 +205,10 @@ public async Task ExplicitInterfaceScopeDoesNotShareMethodTypeParameterFacts(boo StringAssert.Contains("I.M(I value)", output); StringAssert.DoesNotContain("global::N.I.M", output); StringAssert.Contains("N.I other", output); - if (returnFirst) StringAssert.Contains("N.I I.M", output); + if (returnFirst) + { + StringAssert.Contains("N.I I.M", output); + } } [Test] @@ -334,7 +344,14 @@ private static async Task ReduceRole(CodeFile code, bool allowUnsafe = f var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); await workspace.AddGeneratedFile(code); string? output = null; - await foreach (var file in workspace.GetGeneratedFilesAsync()) if (file.Name == code.Name) output = file.Text; + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + if (file.Name == code.Name) + { + output = file.Text; + } + } + Assert.NotNull(output); AssertRoleBindings(code.Content, output!, allowUnsafe); return output!; @@ -377,21 +394,41 @@ BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.AsExpression) => bin result.Add(node.Kind() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(symbol)); } if (node is VariableDeclaratorSyntax variable && model.GetDeclaredSymbol(variable) is ILocalSymbol local) + { result.Add("local:" + StructuralTypeNameRuleOracleTests.TypeIdentity(local.Type)); + } + if (node is TypeDeclarationSyntax && model.GetDeclaredSymbol(node) is INamedTypeSymbol declared) + { result.Add("base:" + StructuralTypeNameRuleOracleTests.TypeIdentity(declared.BaseType)); + } + if (node is BinaryExpressionSyntax isExpression && isExpression.IsKind(SyntaxKind.IsExpression)) + { result.Add("is-type:" + StructuralTypeNameRuleOracleTests.TypeIdentity(model.GetTypeInfo(isExpression.Right).Type) + ":" + model.GetSymbolInfo(isExpression.Right).Symbol?.Kind); + } + if (node is IsPatternExpressionSyntax expression && model.GetOperation(expression.Pattern) is IPatternOperation patternOperation) + { result.Add("pattern:" + patternOperation.Kind + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(patternOperation.NarrowedType) + ":" + (patternOperation is IConstantPatternOperation constant ? constant.Value.ConstantValue.ToString() : "")); + } + if (node is PropertyDeclarationSyntax or IndexerDeclarationSyntax && model.GetDeclaredSymbol(node) is IPropertySymbol property) + { result.Add("property-interface:" + string.Join("|", property.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + } + if (node is EventDeclarationSyntax && model.GetDeclaredSymbol(node) is IEventSymbol @event) + { result.Add("event-interface:" + string.Join("|", @event.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + } + if (node is BaseMethodDeclarationSyntax && model.GetDeclaredSymbol(node) is IMethodSymbol method) + { result.Add("method:" + method.RefKind + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(method.ReturnType) + ":" + string.Join("|", method.ExplicitInterfaceImplementations.Select(static implementation => implementation.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(implementation.ContainingType)))); + } } return result.ToArray(); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs index 72d043d1239..e6d45414d18 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralNamespaceSourceTests.cs @@ -80,8 +80,15 @@ public async Task OuterCapturedArgumentStillProtectsNestedOrdinaryCall(bool rece { writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Clock { public object M() {"); writer.WriteLine($"{typeof(Exception)} seed = default;"); - if (receiver) writer.WriteLine($"return Pass(new {typeof(Exception)}()).CapturedExtension();"); - else writer.WriteLine($"return Capture(Pass(new {typeof(Exception)}()));"); + if (receiver) + { + writer.WriteLine($"return Pass(new {typeof(Exception)}()).CapturedExtension();"); + } + else + { + writer.WriteLine($"return Capture(Pass(new {typeof(Exception)}()));"); + } + writer.WriteRawLine("} private object Pass(object value) => value; private string Capture(object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; }"); writer.WriteRawLine("public static class Extensions { public static string CapturedExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); }); @@ -129,8 +136,15 @@ public async Task ReorderedArgumentsUseParameterIdentityRatherThanEvaluationOrde writer.WriteRawLine("} private object Capture(object first, object value, [CallerArgumentExpression(\"" + (captureValue ? "value" : "first") + "\")] string text = \"\") => text; } }"); }); var output = await Reduce(code); - if (captureValue) StringAssert.Contains("new global::System.Exception()", output); - else StringAssert.DoesNotContain("global::System.Exception", output); + if (captureValue) + { + StringAssert.Contains("new global::System.Exception()", output); + } + else + { + StringAssert.DoesNotContain("global::System.Exception", output); + } + AssertOperationBindings(code.Content, output); } @@ -160,8 +174,15 @@ public async Task EnclosingCallProofCoversLambdaSource(bool captured) writer.WriteRawLine("} private object Capture(Func make" + (captured ? ", [CallerArgumentExpression(\"make\")] string text = \"\"" : "") + ") => make; } }"); }); var output = await Reduce(code); - if (captured) StringAssert.Contains("typeof(global::System.Exception)", output); - else StringAssert.DoesNotContain("global::System.Exception", output); + if (captured) + { + StringAssert.Contains("typeof(global::System.Exception)", output); + } + else + { + StringAssert.DoesNotContain("global::System.Exception", output); + } + AssertOperationBindings(code.Content, output); } @@ -218,19 +239,36 @@ public async Task AlternateCallRepresentationsHonorActualCaptureMapping(int kind var code = Recorded(writer => { writer.WriteRawLine("using System.Runtime.CompilerServices; namespace Sample { public class Consumer { public Consumer(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } public class Base { public Base(object value, [CallerArgumentExpression(\"value\")] string text = \"\") {} } public class Clock : Base {"); - if (kind == 2) writer.WriteLine($"public Clock() : base(new {typeof(Exception)}(){suffix}) {{ }}"); + if (kind == 2) + { + writer.WriteLine($"public Clock() : base(new {typeof(Exception)}(){suffix}) {{ }}"); + } else { writer.WriteRawLine("public Clock() : base(null) {} public object M() {"); - if (kind == 0) writer.WriteLine($"Consumer result = new(new {typeof(Exception)}(){suffix}); return result;"); - else writer.WriteLine($"return Extensions.CapturedExtension(new {typeof(Exception)}(){suffix});"); + if (kind == 0) + { + writer.WriteLine($"Consumer result = new(new {typeof(Exception)}(){suffix}); return result;"); + } + else + { + writer.WriteLine($"return Extensions.CapturedExtension(new {typeof(Exception)}(){suffix});"); + } + writer.WriteRawLine("}"); } writer.WriteRawLine("} public static class Extensions { public static string CapturedExtension(this object value, [CallerArgumentExpression(\"value\")] string text = \"\") => text; } }"); }); var output = await Reduce(code); - if (explicitText) StringAssert.DoesNotContain("global::System.Exception", output); - else StringAssert.Contains("new global::System.Exception()", output); + if (explicitText) + { + StringAssert.DoesNotContain("global::System.Exception", output); + } + else + { + StringAssert.Contains("new global::System.Exception()", output); + } + AssertOperationBindings(code.Content, output); } @@ -273,7 +311,9 @@ public async Task ConditionalReceiverBindingCannotReuseOriginalCallProof() var output = await Reduce(code); StringAssert.Contains("new global::System.Exception()", output); foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER") }) + { AssertOperationBindings(code.Content, output, options); + } } } } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalAncestorScopeTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalAncestorScopeTests.cs new file mode 100644 index 00000000000..4e69220f84f --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalAncestorScopeTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + [TestCase(0, true, TestName = "OptionalAncestor_DottedModelsNamespace")] + [TestCase(1, true, TestName = "OptionalAncestor_ExplicitNestedNamespace")] + [TestCase(2, true, TestName = "OptionalAncestor_MultipleAncestorLevels")] + [TestCase(3, false, TestName = "OptionalAncestor_LocalImportMustKeepHiddenDiagnostic")] + [TestCase(4, false, TestName = "OptionalAncestor_SameTargetAliasRetained")] + [TestCase(5, false, TestName = "OptionalAncestor_UnrelatedLocalImportConservativelyRetained")] + [TestCase(6, false, TestName = "OptionalAncestor_ConditionalOuterShadowRetained")] + [TestCase(7, false, TestName = "OptionalAncestor_CallerSourceRetained")] + [TestCase(8, false, TestName = "OptionalAncestor_ForeignNamespaceNotAncestor")] + [TestCase(9, false, TestName = "OptionalAncestor_InterveningOuterNamespaceImport")] + public async Task OptionalAncestorScopeTrial(int scenario, bool shorten) + { + var targetNamespace = scenario == 8 ? "Foreign" : "Sample"; + var target = new CSharpType("Optional", targetNamespace, false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices;"); + writer.WriteRawLine($"namespace {targetNamespace} {{ internal static class Optional {{ public static bool IsDefined(object value) => value != null; }} }}"); + if (scenario == 1) writer.WriteRawLine("namespace Sample { namespace Models {"); + else if (scenario == 9) writer.WriteRawLine("namespace Sample.Models { using Sample; namespace Child {"); + else writer.WriteRawLine("namespace " + (scenario == 2 ? "Sample.Models.Child" : "Sample.Models") + " {"); + if (scenario == 3) writer.WriteRawLine("using Sample;"); + if (scenario == 4) writer.WriteRawLine("using Optional = global::Sample.Optional;"); + if (scenario == 5) writer.WriteRawLine("using System.Text;"); + writer.WriteRawLine("public class Probe { public object Properties { get; set; } public int Sink { get; set; }"); + writer.WriteRawLine("static bool Capture(bool value, [CallerArgumentExpression(\"value\")] string text = null) => value;"); + writer.WriteRawLine("public void Write() {"); + if (scenario == 6) writer.WriteRawLine("#if NET6_0_OR_GREATER\nobject Optional = null;\n#endif"); + if (scenario == 7) writer.WriteLine($"if (Capture({target}.IsDefined(Properties)))"); + else writer.WriteLine($"if ({target}.IsDefined(Properties))"); + writer.WriteRawLine("{ Sink = 1; } if (Properties != null) { foreach (var item in new[] { 1 }) {\n#if NET6_0_OR_GREATER\nSink = item;\n#else\nusing (var document = new System.IO.MemoryStream()) { Sink = (int)document.Length; }\n#endif\n} } } } }"); + if (scenario is 1 or 9) writer.WriteRawLine("}"); + }); + var output = await Reduce(code); + AssertConditionalParenthesesOperations(code.Content, output); + AssertOptionalAncestorDiagnostics(code.Content, output, scenario is 3 or 9); + StringAssert.Contains(shorten ? "if (Optional.IsDefined(Properties))" : $"global::{targetNamespace}.Optional.", output); + } + + private static void AssertOptionalAncestorDiagnostics(string before, string after, bool requireUnusedLocalImport) + { + const string prefix = "global::Sample."; + var edits = new List(); + int a = 0, b = 0; + while (a < before.Length || b < after.Length) + { + if (a < before.Length && b < after.Length && before[a] == after[b]) { a++; b++; continue; } + Assert.That(before.AsSpan(a).StartsWith(prefix + "Optional.IsDefined", StringComparison.Ordinal), Is.True, "Only recorded Optional prefix may change"); + edits.Add(new TextSpan(a, prefix.Length)); a += prefix.Length; + } + foreach (var symbols in new[] { Array.Empty(), new[] { "NET6_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER" }, + new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER" }, new[] { "NET6_0_OR_GREATER", "NET8_0_OR_GREATER", "NET9_0_OR_GREATER", "NET10_0_OR_GREATER" } }) + { + var options = new CSharpParseOptions(preprocessorSymbols: symbols.Prepend("EXPERIMENTAL")); + var oldTree = CSharpSyntaxTree.ParseText(before, options, path: "OptionalAncestor.cs"); + var nextTree = CSharpSyntaxTree.ParseText(after, options, path: oldTree.FilePath); + var old = CSharpCompilation.Create("OptionalAncestorDiagnostics", new[] { oldTree }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var next = old.ReplaceSyntaxTree(oldTree, nextTree); + if (requireUnusedLocalImport) + { + var import = oldTree.GetRoot().DescendantNodes().OfType().Single(u => u.Parent is BaseNamespaceDeclarationSyntax && u.Name!.ToString() == "Sample"); + Assert.That(old.GetDiagnostics().Any(d => d.Id == "CS8019" && d.Severity == DiagnosticSeverity.Hidden && d.Location.SourceSpan == import.Span), Is.True, "Original local namespace import must genuinely be unused"); + } + int Project(int position) { Assert.That(edits.Any(e => e.Start < position && position < e.End), Is.False); return position - edits.Where(e => e.End <= position).Sum(e => e.Length); } + object Location(Location location, bool project) + { + if (!location.IsInSource) return new { kind = location.Kind.ToString() }; + var span = location.SourceSpan; if (project) { Assert.That(edits.Any(e => e.IntersectsWith(span)), Is.False); span = TextSpan.FromBounds(Project(span.Start), Project(span.End)); } + var physical = nextTree.GetLineSpan(span); var mapped = nextTree.GetMappedLineSpan(span); + return new { physical.Path, Start = span.Start, End = span.End, physicalStart = physical.StartLinePosition.ToString(), physicalEnd = physical.EndLinePosition.ToString(), mappedPath = mapped.Path, mappedStart = mapped.StartLinePosition.ToString(), mappedEnd = mapped.EndLinePosition.ToString() }; + } + string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.Select(d => System.Text.Json.JsonSerializer.Serialize(new + { + d.Id, severity = d.Severity.ToString(), defaultSeverity = d.DefaultSeverity.ToString(), d.IsSuppressed, d.WarningLevel, message = d.GetMessage(), + descriptor = new { d.Descriptor.Id, title = d.Descriptor.Title.ToString(), message = d.Descriptor.MessageFormat.ToString(), d.Descriptor.Category, d.Descriptor.IsEnabledByDefault, description = d.Descriptor.Description.ToString(), d.Descriptor.HelpLinkUri, tags = d.Descriptor.CustomTags.ToArray() }, + properties = d.Properties.OrderBy(p => p.Key, StringComparer.Ordinal).ToArray(), location = Location(d.Location, project), additional = d.AdditionalLocations.Select(l => Location(l, project)).ToArray() + })).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.That(Bag(next.GetDiagnostics(), false), Is.EqualTo(Bag(old.GetDiagnostics(), true)), "Complete compiler diagnostic multiset including Hidden/Info and every location endpoint"); + using var oldPe = new MemoryStream(); using var newPe = new MemoryStream(); + var oldEmit = old.Emit(oldPe); var newEmit = next.Emit(newPe); + Assert.That(newEmit.Success, Is.EqualTo(oldEmit.Success)); + Assert.That(Bag(newEmit.Diagnostics, false), Is.EqualTo(Bag(oldEmit.Diagnostics, true)), "Complete emit diagnostic multiset"); + } + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs index 127e2713eda..948f82eb06b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultSafetyTests.cs @@ -137,7 +137,11 @@ public async Task StructuralOptionalDefaultHiddenAndVisibleOrderIsOccurrenceOwne var source = "class Clock {" + (hiddenFirst ? hidden + visible : visible + hidden) + "}"; var output = await Reduce(new CodeFile(source, "Clock.cs")); StringAssert.Contains("Hidden(int value=default(int))", output); - if (hiddenFirst) StringAssert.Contains("Visible(int value=default)", output); + if (hiddenFirst) + { + StringAssert.Contains("Visible(int value=default)", output); + } + AssertOptionalDefaultContracts(source, output); } @@ -146,9 +150,17 @@ public async Task StructuralOptionalDefaultHiddenAndVisibleOrderIsOccurrenceOwne [TestCase("header")] public async Task StructuralOptionalDefaultWorkspaceAndHeaderGatesRemain(string mode) { - if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + if (mode == "disabled") + { + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); - if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + + if (mode == "custom") + { + CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + const string source = "class Clock { public int M(int value=default(int))=>value; }"; var extra = mode == "header" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs index fd2edf9a63a..f842a5fc078 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalDefaultTests.cs @@ -167,7 +167,9 @@ string[] ReflectionDefaults(CSharpCompilation compilation) Assert.That(candidate.Parameters, Is.EqualTo(original.Parameters), "Parameter type/ref/optional/default metadata must remain exact"); Assert.That(candidate.Reflection, Is.EqualTo(original.Reflection), "Emitted reflection defaults must distinguish null, Missing and DBNull"); if (formatterRoute) + { Assert.That(SyntaxFactory.AreEquivalent(CSharpSyntaxTree.ParseText(candidate.Outside, parse).GetRoot(), CSharpSyntaxTree.ParseText(original.Outside, parse).GetRoot()), Is.True); + } else { Assert.That(candidate.Outside, Is.EqualTo(original.Outside), "All source outside parameter default expressions must stay exact"); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalStatementScopeTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalStatementScopeTests.cs new file mode 100644 index 00000000000..f9209692aa1 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralOptionalStatementScopeTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Microsoft.TypeSpec.Generator.Primitives; +using NUnit.Framework; + +namespace Microsoft.TypeSpec.Generator.Tests +{ + public partial class StructuralTypeNameIntegrationTests + { + // SDK-shaped Optional call before the conditional additional-properties block. + // Scenarios retain the original regression separately from scope/source negatives. + [TestCase(0, true, TestName = "OptionalStatementScope_SdkSiblingBranch")] + [TestCase(1, true, TestName = "OptionalStatementScope_NestedLaterShadowDoesNotEscape")] + [TestCase(2, false, TestName = "OptionalStatementScope_OuterLaterShadow")] + [TestCase(3, false, TestName = "OptionalStatementScope_OuterEarlierShadow")] + [TestCase(4, false, TestName = "OptionalStatementScope_AffectedStatementVaries")] + [TestCase(5, false, TestName = "OptionalStatementScope_CallerSourceCapture")] + [TestCase(6, false, TestName = "OptionalStatementScope_UnrecordedReceiver")] + [TestCase(7, false, TestName = "OptionalStatementScope_ForeignNamespaceImport")] + [TestCase(8, false, TestName = "OptionalStatementScope_OtherMethodNotAuthorized")] + public async Task OptionalStatementScopeTrial(int scenario, bool shorten) + { + var ns = scenario == 7 ? "Foreign" : "Sample"; + var target = new CSharpType("Optional", ns, false, false, null, Array.Empty(), true, false); + var code = Recorded(writer => + { + writer.WriteRawLine("using System; using System.Runtime.CompilerServices;"); + if (scenario == 7) + { + writer.WriteRawLine("using Foreign;"); + } + + writer.WriteRawLine($"namespace {ns} {{ internal static class Optional {{ public static bool IsDefined(object value) => value != null; public static bool Other(object value) => value != null; }} }}"); + writer.WriteRawLine("namespace Sample { public class Probe { public object Properties { get; set; } public int Sink { get; set; }"); + writer.WriteRawLine("static bool Capture(bool value, [CallerArgumentExpression(\"value\")] string text = null) => value;"); + writer.WriteRawLine("public void Write() {"); + if (scenario == 3) + { + writer.WriteRawLine("#if NET6_0_OR_GREATER\nobject Optional = null;\n#endif"); + } + + if (scenario == 5) + { + writer.WriteLine($"if (Capture({target}.IsDefined(Properties)))"); + } + else if (scenario == 8) + { + writer.WriteLine($"if ({target}.Other(Properties))"); + } + else + { + writer.WriteLine($"if ({target}.IsDefined(Properties))"); + } + + writer.WriteRawLine(scenario == 4 ? "{\n#if NET6_0_OR_GREATER\nSink = 6;\n#else\nSink = 0;\n#endif\n}" : "{ Sink = 1; }"); + writer.WriteRawLine("if (Properties != null) { foreach (var item in new[] { 1 }) {"); + writer.WriteRawLine("#if NET6_0_OR_GREATER\nSink = item;\n#else\nusing (var document = new System.IO.MemoryStream()) { Sink = (int)document.Length; }\n#endif"); + if (scenario == 1) + { + writer.WriteRawLine("#if NET8_0_OR_GREATER\nobject Optional = null;\n#endif"); + } + + writer.WriteRawLine("} }"); + if (scenario == 2) + { + writer.WriteRawLine("#if NET6_0_OR_GREATER\nobject Optional = null;\n#endif"); + } + + writer.WriteRawLine("} } }"); + }); + if (scenario == 6) + { + code = new CodeFile(code.Content, code.Name); + } + + var output = await Reduce(code); + // Existing operation oracle covers every supported configuration, including + // constructed calls/conversions and captured caller-argument constants. + AssertConditionalParenthesesOperations(code.Content, output); + StringAssert.Contains(shorten ? "if (Optional.IsDefined(Properties))" : $"global::{ns}.Optional.", output); + } + } +} diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs index 0eee88ada4b..9cad6121d4c 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralParenthesesSafetyTests.cs @@ -127,15 +127,30 @@ public async Task StructuralParenthesesEmptyMetadataCannotAuthorizeUnrecordedNam [TestCase("headerProof")] public async Task StructuralParenthesesEmptyMetadataRetainsConfigurationAndCustomGates(string mode) { - if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + if (mode == "disabled") + { + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); - if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + + if (mode == "custom") + { + CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + var conditional = mode == "conditional" ? "int N() {\n#if NET8_0_OR_GREATER\nreturn 8;\n#else\nreturn 0;\n#endif\n}" : ""; var source = "class Clock { public bool M(int x, int y) => ((x == y)); " + conditional + " }"; var extra = mode == "headerProof" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; var output = await Reduce(new CodeFile(source, "Clock.cs"), extra); - if (mode == "conditional") StringAssert.Contains("=> x == y", output); - else StringAssert.Contains("=> ((x == y))", output); + if (mode == "conditional") + { + StringAssert.Contains("=> x == y", output); + } + else + { + StringAssert.Contains("=> ((x == y))", output); + } + AssertParenthesesOperations(source, output); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs index 7df9f1e96ce..e026a244fe7 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralSdkCastTypeQualificationTests.cs @@ -128,24 +128,54 @@ private static CodeFile Build(bool conditionalDocument = false, bool varyingOwne using var writer = new CodeWriter(); writer.AppendRaw("using System;\nnamespace Azure.ResourceManager.AppService {\npublic partial class AiGatewayData : "); writer.AppendRaw(noImportWitness ? "global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + "> {\n" : "IPersistableModel {\n"); - if (hidden) writer.AppendRaw("#line hidden\n"); + if (hidden) + { + writer.AppendRaw("#line hidden\n"); + } + writer.AppendRaw("public string PersistableModelCreateCore("); - if (recordedParameter) writer.Append($"{new CSharpType(typeof(ModelReaderWriterOptions))}"); - else writer.AppendRaw(noImportWitness ? "global::System.ClientModel.Primitives.ModelReaderWriterOptions" : "ModelReaderWriterOptions"); + if (recordedParameter) + { + writer.Append($"{new CSharpType(typeof(ModelReaderWriterOptions))}"); + } + else + { + writer.AppendRaw(noImportWitness ? "global::System.ClientModel.Primitives.ModelReaderWriterOptions" : "ModelReaderWriterOptions"); + } + writer.AppendRaw(" options) {\n"); - if (varyingOwner) writer.AppendRaw("#if NET6_0_OR_GREATER\nint framework = 6;\n#else\nint framework = 0;\n#endif\n"); + if (varyingOwner) + { + writer.AppendRaw("#if NET6_0_OR_GREATER\nint framework = 6;\n#else\nint framework = 0;\n#endif\n"); + } + writer.AppendRaw(captured ? "return Capture(((" : "return (("); writer.Append($"{target}"); writer.AppendRaw(captured ? ")this).GetFormatFromOptions(options));\n}\n" : ")this).GetFormatFromOptions(options);\n}\n"); - if (hidden) writer.AppendRaw("#line default\n"); + if (hidden) + { + writer.AppendRaw("#line default\n"); + } + if (conditionalDocument && !varyingOwner) + { writer.AppendRaw("public int FrameworkVariation() {\n#if NET6_0_OR_GREATER\nreturn 6;\n#else\nreturn 0;\n#endif\n}\n"); + } + var declarations = "BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => null;\nAiGatewayData IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => this;\nstring IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => \"J\";\n"; - if (noImportWitness) declarations = declarations.Replace("BinaryData", "global::System.BinaryData", StringComparison.Ordinal) + if (noImportWitness) + { + declarations = declarations.Replace("BinaryData", "global::System.BinaryData", StringComparison.Ordinal) .Replace("IPersistableModel", "global::System.ClientModel.Primitives.IPersistableModel<" + FullArgument + ">", StringComparison.Ordinal) .Replace("ModelReaderWriterOptions", "global::System.ClientModel.Primitives.ModelReaderWriterOptions", StringComparison.Ordinal); + } + writer.AppendRaw(declarations); - if (captured) writer.AppendRaw("static string Capture(string value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string source = null) => value;\n"); + if (captured) + { + writer.AppendRaw("static string Capture(string value, [System.Runtime.CompilerServices.CallerArgumentExpression(\"value\")] string source = null) => value;\n"); + } + writer.AppendRaw("}\n}\n"); return new CodeFile(writer.ToString(), "AiGatewayData.Serialization.cs", writer.TypeReferences); } @@ -170,7 +200,11 @@ private static void PreserveDiagnostics(CodeFile code, string output, Compilatio int Map(int offset) => offset - edits.Where(e => e.End <= offset).Sum(e => e.Length); string LocationKey(Location location, bool project) { - if (!location.IsInSource) return location.Kind.ToString(); + if (!location.IsInSource) + { + return location.Kind.ToString(); + } + var span = location.SourceSpan;var tree = location.SourceTree!; if (project && ReferenceEquals(tree, oldTree)) { @@ -192,7 +226,10 @@ string[] Bag(IEnumerable diagnostics, bool project) => diagnostics.S var oldEmit = before.Emit(oldPe, xmlDocumentationStream: oldXml);var newEmit = after.Emit(newPe, xmlDocumentationStream: newXml); Assert.That(newEmit.Success, Is.EqualTo(oldEmit.Success)); Assert.That(Bag(newEmit.Diagnostics, false), Is.EqualTo(Bag(oldEmit.Diagnostics, true)), "Complete emit diagnostic multiset"); - if (oldEmit.Success) Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray())); + if (oldEmit.Success) + { + Assert.That(newXml.ToArray(), Is.EqualTo(oldXml.ToArray())); + } } private static string Reduce(CodeFile code) @@ -217,7 +254,10 @@ private static string Reduce(CodeFile code) Assert.That(after.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), Is.Empty); PreserveDiagnostics(code, text, before, after, tree, candidate.GetSyntaxTreeAsync().GetAwaiter().GetResult()!); if (code.Content.Contains("CreateCore(global::System.ClientModel.Primitives.ModelReaderWriterOptions", StringComparison.Ordinal)) + { Assert.That(before.GetDiagnostics().Any(d => d.Id == "CS8019" && d.Severity == DiagnosticSeverity.Hidden), Is.True, "Unused-import regression anchor"); + } + string Snapshot(Compilation compilation, SyntaxTree syntaxTree) { var model = compilation.GetSemanticModel(syntaxTree); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs index 52b166976f3..68290795ff5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralStaticReceiverSafetyTests.cs @@ -134,8 +134,15 @@ public async Task StaticReceiverNameofAndDynamicCallsRemainQualifiedWithoutCondi var code = Recorded(writer => { writer.WriteRawLine("namespace Sample { public class Clock {"); - if (dynamicCall) writer.WriteLine($"public object M(dynamic value) => {typeof(Convert)}.ToString(value);"); - else writer.WriteLine($"public string M() => nameof({typeof(DateTimeOffset)}.Now);"); + if (dynamicCall) + { + writer.WriteLine($"public object M(dynamic value) => {typeof(Convert)}.ToString(value);"); + } + else + { + writer.WriteLine($"public string M() => nameof({typeof(DateTimeOffset)}.Now);"); + } + writer.WriteRawLine("} }"); }); var output = await Reduce(code); @@ -176,7 +183,11 @@ public async Task StaticReceiverContextualIdentifierKeepsSerializedParseAndTermi var type = new CSharpType("await", "Fixtures", false, false, null, Array.Empty(), true, false); var site = new TypeReferenceSite(TextSpan.FromBounds(receiver.SpanStart, receiver.Name.Identifier.Span.End), type); var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { site })); - if (escaped) StringAssert.Contains("@await.Value", output); + if (escaped) + { + StringAssert.Contains("@await.Value", output); + } + AssertStaticMemberBindings(source, output); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs index 3ca660f3c51..329e4c7ca01 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverSafetyTests.cs @@ -152,9 +152,17 @@ public async Task StructuralThisReceiverDelegateInvocationParserBoundariesStayQu [TestCase("headerProof")] public async Task StructuralThisReceiverEmptyMetadataRespectsWorkspaceGates(string mode) { - if (mode == "disabled") MockHelpers.LoadMockGenerator(outputPath: _directory, + if (mode == "disabled") + { + MockHelpers.LoadMockGenerator(outputPath: _directory, configuration: "{\"package-name\":\"Sample\",\"disable-xml-docs\":true,\"disable-roslyn-reduce\":true}"); - if (mode == "custom") CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + + if (mode == "custom") + { + CodeModelGenerator.Instance.AddRewriter(new Probe()); + } + var conditional = mode == "conditional" ? "\n#if NET8_0_OR_GREATER\nint N() => 8;\n#endif\n" : ""; var source = "class Clock { private int Value; public int M() => this.Value; " + conditional + " }"; var extra = mode == "headerProof" ? "#if NET8_0_OR_GREATER\nclass Variant {}\n#else\nclass Variant {}\n#endif" : null; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs index 42e8f446da9..4673d3102e1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralThisReceiverTests.cs @@ -85,7 +85,11 @@ public async Task StructuralThisReceiverRetainsMethodEventNameofDynamicAndInitia { const string source = "class Clock { private int Value; private dynamic Data; private int Other() => 1; public event System.Action Changed; public void E() { this.Changed += () => {}; } public int M() => this.Other(); public object D() => this.Data; public string N() => nameof(this.Value); public object I() => new Clock { Value = this.Value }; }"; var output = await Reduce(new CodeFile(source, "Clock.cs")); - foreach (var retained in new[] { "this.Other()", "this.Changed", "this.Data", "nameof(this.Value)", "Value = this.Value" }) StringAssert.Contains(retained, output); + foreach (var retained in new[] { "this.Other()", "this.Changed", "this.Data", "nameof(this.Value)", "Value = this.Value" }) + { + StringAssert.Contains(retained, output); + } + AssertThisMemberBindings(source, output); } @@ -162,14 +166,22 @@ string[] Snapshot(string source) foreach (var expression in tree.GetRoot().DescendantNodes().OfType()) { var operation = model.GetOperation(expression); - if (operation == null || operation.Syntax.Span != expression.Span) continue; + if (operation == null || operation.Syntax.Span != expression.Span) + { + continue; + } + var member = operation switch { IFieldReferenceOperation field => (ISymbol)field.Field, IPropertyReferenceOperation property => property.Property, _ => null }; - if (member == null || member.IsStatic) continue; + if (member == null || member.IsStatic) + { + continue; + } + var instance = ((IMemberReferenceOperation)operation).Instance; var instanceIdentity = instance is IInstanceReferenceOperation receiver ? receiver.ReferenceKind.ToString() : instance?.Kind.ToString(); values.Add(member.OriginalDefinition.GetDocumentationCommentId() + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(member.ContainingType) + ":" + diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs index e1678d2df67..e39512ed29f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameIntegrationTests.cs @@ -229,7 +229,11 @@ public async Task ConcurrentDocumentsHaveIndependentTraversalAndSemanticState() await workspace.AddGeneratedFile(a); await workspace.AddGeneratedFile(new CodeFile(b.Content, "Other.cs", b.TypeReferences)); var output = new Dictionary(); - await foreach (var file in workspace.GetGeneratedFilesAsync()) output.Add(file.Name, file.Text); + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + output.Add(file.Name, file.Text); + } + StringAssert.Contains("System.DateTimeOffset Timestamp", output["Clock.cs"]); StringAssert.Contains("public DateTimeOffset Timestamp", output["Other.cs"]); AssertBindings(a.Content, output["Clock.cs"]); @@ -297,8 +301,14 @@ public async Task DeclarationGenericArgumentSupportAndContainingSegmentLimitAreE var site = CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes().OfType() .Single(static node => node.ToString() == "global::System.DateTimeOffset"); var output = await Reduce(new CodeFile(source, "Clock.cs", new[] { new TypeReferenceSite(site.Span, typeof(DateTimeOffset)) })); - if (containingSegment) StringAssert.Contains("Outer.Inner", output); - else StringAssert.Contains("List", output); + if (containingSegment) + { + StringAssert.Contains("Outer.Inner", output); + } + else + { + StringAssert.Contains("List", output); + } } [Test] @@ -349,7 +359,11 @@ public async Task LaterUnprovenOccurrenceCannotReuseACleanCachedDecision(int kin new(properties[0].Type.Span, typeof(DateTimeOffset)), new(properties[1].Type.Span, typeof(DateTimeOffset), kind == 1 ? TypeReferenceRole.WholeTypeCref : TypeReferenceRole.Code) }; - if (kind == 2) sites.Add(sites[1]); + if (kind == 2) + { + sites.Add(sites[1]); + } + var output = await Reduce(new CodeFile(source, "Clock.cs", sites)); StringAssert.Contains("DateTimeOffset First", output); StringAssert.Contains(second + " Second", output); @@ -376,11 +390,16 @@ public async Task RecordedReferencesInExcludedContextsRemainUnchangedAfterADecla var output = await Reduce(code); StringAssert.Contains("Exception Value", output); foreach (var supported in new[] { "Clock : Exception", "new Exception()", "typeof(Exception)", "Use()" }) + { StringAssert.Contains(supported, output); + } + AssertOperationBindings(code.Content, output); StringAssert.Contains("Same() => Exception.ReferenceEquals(null, null)", output); foreach (var preserved in new[] { "Holder(typeof(global::System.Exception))", "(global::System.Exception)value", "nameof(global::System.Exception)" }) + { StringAssert.Contains(preserved, output); + } } private static int Count(string profile, string name) => int.Parse(profile.Split('\n').Single(line => line.StartsWith(name + ", ", StringComparison.Ordinal)).Split(',')[1]); @@ -404,9 +423,20 @@ private static async Task Reduce(CodeFile code, string? extra = null) GeneratedCodeWorkspace.Initialize(); var workspace = await GeneratedCodeWorkspace.Create(isCustomCodeProject: false); await workspace.AddGeneratedFile(code); - if (extra != null) await workspace.AddGeneratedFile(new CodeFile(extra, "Additional.cs")); + if (extra != null) + { + await workspace.AddGeneratedFile(new CodeFile(extra, "Additional.cs")); + } + string? result = null; - await foreach (var file in workspace.GetGeneratedFilesAsync()) if (file.Name == code.Name) result = file.Text; + await foreach (var file in workspace.GetGeneratedFilesAsync()) + { + if (file.Name == code.Name) + { + result = file.Text; + } + } + Assert.NotNull(result); AssertBindings(code.Content, result!, extra); return result!; @@ -440,7 +470,11 @@ string[] Snapshot(string source) IParameterSymbol parameter => parameter.Type, _ => null }; - if (type == null) continue; + if (type == null) + { + continue; + } + result.Add(result.Count + ":" + symbol!.Kind + ":" + symbol.Name + ":" + StructuralTypeNameRuleOracleTests.TypeIdentity(type)); } return result.ToArray(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs index 1fcb3891c65..774a754d09f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameQualifiedContextTests.cs @@ -38,7 +38,10 @@ public async Task TypeOnlyExpressionContextsUseStructuralNamesWithoutChangingOpe Assert.That(model.GetSymbolInfo(creationType).Symbol, Is.InstanceOf(), "Creation names bind canonically through SymbolInfo; TypeInfo can be empty"); StringAssert.DoesNotContain("global::System.Exception", output); foreach (var syntax in new[] { "new Exception()", "typeof(Exception)", "default(Exception)", "new Exception[1]", "(Exception)(value)", "catch (Exception error)" }) + { StringAssert.Contains(syntax, output); + } + AssertOperationBindings(code.Content, output); } @@ -105,7 +108,9 @@ static string[] DisabledText(string source) => CSharpSyntaxTree.ParseText(source .Where(static trivia => trivia.IsKind(SyntaxKind.DisabledTextTrivia)).Select(static trivia => trivia.ToFullString()).ToArray(); Assert.That(DisabledText(output), Is.EqualTo(DisabledText(code.Content)), "Inactive text must be byte-identical"); foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER"), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER", "NET9_0_OR_GREATER") }) + { AssertOperationBindings(code.Content, output, options); + } } [Test] @@ -126,7 +131,9 @@ public async Task ConditionalGenericLocalFunctionsConservativelyKeepTheDocumentQ StringAssert.Contains("new global::System.Exception()", output); Assert.AreEqual(2, output.Split("typeof(global::System.DateTimeOffset)").Length - 1); foreach (var options in new[] { new CSharpParseOptions(), new CSharpParseOptions().WithPreprocessorSymbols("NET8_0_OR_GREATER") }) + { AssertOperationBindings(code.Content, output, options); + } } [Test] @@ -329,7 +336,10 @@ string[] Snapshot(string source) InvocationExpressionSyntax invocation => model.GetOperation(invocation) is IInvocationOperation operation ? MethodIdentity(operation.TargetMethod) + ":" + ArgumentsIdentity(operation.Arguments) : null, _ => null }; - if (value != null) result.Add(node.Kind() + ":" + value); + if (value != null) + { + result.Add(node.Kind() + ":" + value); + } } return result.ToArray(); } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs index 4678ade5f4d..c854e08a1e5 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleEvaluatorTests.cs @@ -50,7 +50,11 @@ public static IEnumerable SelectionCases() foreach (var data in StructuralTypeNameRuleOracleTests.Scenarios()) { var scenario = (Scenario)data.Arguments[0]!; - if (!scenario.Id.StartsWith("R", StringComparison.Ordinal)) continue; + if (!scenario.Id.StartsWith("R", StringComparison.Ordinal)) + { + continue; + } + var expected = ExpectedSelections.TryGetValue(scenario.Id, out var alternative) ? alternative : scenario.Candidate; yield return new TestCaseData(scenario, expected).SetName("Select_" + scenario.Id); } @@ -116,7 +120,10 @@ public void SelectsExpectedStructuralNameAndPassesIndependentOracle(Scenario sce var spelling = selection.Name.ToString(); Assert.AreEqual(expected, spelling); if (scenario.Id == "R11_ContainmentShapeChanges") + { Assert.AreEqual(StructuralTypeNameSelector.Retention.ConfigurationShape, selection.Reason); + } + Assert.LessOrEqual(selection.ScopeSnapshots, 2 * configurations.Length, "At most one type and one expression scope snapshot per configuration"); new StructuralTypeNameRuleOracleTests().CandidateMeetsTheScenarioContract(scenario with { diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs index eb04f938ce1..3e2ca2ad63a 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/StructuralTypeNameRuleOracleTests.cs @@ -167,7 +167,10 @@ public static IEnumerable Scenarios() "namespace Example { public partial class Consumer {\n#if DEBUG\npublic class DateTimeOffset {}\n#endif\n} }", new[] { Array.Empty(), new[] { "DEBUG" } }, new[] { true, false }) }; - foreach (var scenario in cases) yield return new TestCaseData(scenario).SetName(scenario.Id); + foreach (var scenario in cases) + { + yield return new TestCaseData(scenario).SetName(scenario.Id); + } } [TestCaseSource(nameof(Scenarios))] @@ -178,7 +181,11 @@ public void CandidateMeetsTheScenarioContract(Scenario scenario) var violations = Violation.None; var details = new List(); var configurations = scenario.Configurations ?? new[] { Array.Empty() }; - if (scenario.ExpectedPerConfiguration != null) Assert.AreEqual(configurations.Length, scenario.ExpectedPerConfiguration.Length); + if (scenario.ExpectedPerConfiguration != null) + { + Assert.AreEqual(configurations.Length, scenario.ExpectedPerConfiguration.Length); + } + for (var configuration = 0; configuration < configurations.Length; configuration++) { var symbols = configurations[configuration]; @@ -191,19 +198,36 @@ public void CandidateMeetsTheScenarioContract(Scenario scenario) Assert.That(originalSymbol, Is.InstanceOf(), "Original fixture must identify a known type, not merely text"); var errors = Errors(after.Compilation); var current = errors.Length > 0 ? Violation.Compilation : Violation.None; - if (after.Reference == null || !SyntaxFactory.AreEquivalent(Skeleton(before), Skeleton(after), static _ => false)) current |= Violation.Grammar; - if (after.Reference == null || Identity(originalSymbol) != Identity(ReferencedSymbol(after.Model, after.Reference))) current |= Violation.ReferenceIdentity; - if (after.Reference != null && !Operations(before).SequenceEqual(Operations(after))) current |= Violation.EnclosingOperation; + if (after.Reference == null || !SyntaxFactory.AreEquivalent(Skeleton(before), Skeleton(after), static _ => false)) + { + current |= Violation.Grammar; + } + + if (after.Reference == null || Identity(originalSymbol) != Identity(ReferencedSymbol(after.Model, after.Reference))) + { + current |= Violation.ReferenceIdentity; + } + + if (after.Reference != null && !Operations(before).SequenceEqual(Operations(after))) + { + current |= Violation.EnclosingOperation; + } + if (scenario.ExpectedPerConfiguration != null) + { Assert.AreEqual(scenario.ExpectedPerConfiguration[configuration], current == Violation.None, "Candidate validity in [" + string.Join(",", symbols) + "]: " + current); + } + violations |= current; details.Add($"[{string.Join(",", symbols)}] {current}: {string.Join("; ", errors)}"); } Assert.AreEqual(scenario.ExpectedSafe, violations == Violation.None, string.Join(Environment.NewLine, details)); if (!scenario.ExpectedSafe) + { Assert.That((violations & scenario.RequiredViolation) == scenario.RequiredViolation, Is.True, "Negative control must fail for its intended reason: " + string.Join(Environment.NewLine, details)); + } } [TestCase("using System.Threading.Tasks; namespace Example { public class await { public static int Create() => 1; } public class Consumer { public async Task M() => $TYPE$.Create(); } }", @@ -262,7 +286,11 @@ private static string[] Errors(Compilation compilation) => compilation.GetDiagno private static SyntaxNode Skeleton(Prepared prepared) { - if (prepared.Reference == null) return prepared.Root; + if (prepared.Reference == null) + { + return prepared.Root; + } + SyntaxNode placeholder = prepared.Reference is CrefSyntax ? SyntaxFactory.TypeCref(SyntaxFactory.IdentifierName("__Reference")) : SyntaxFactory.IdentifierName("__Reference"); @@ -300,8 +328,10 @@ private static IEnumerable Operations(Prepared prepared) private static string DeclarationIdentity(ISymbol symbol) { if (symbol is not IMethodSymbol { MethodKind: MethodKind.LocalFunction }) + { return symbol.Kind + ":" + (symbol.OriginalDefinition.GetDocumentationCommentId() ?? throw new InvalidOperationException("Oracle needs an explicit declaration identity for " + symbol.Kind)); + } // Documentation IDs can collide for member/local methods with the // same name/signature. Source offsets also change after shortening. @@ -313,7 +343,11 @@ private static string DeclarationIdentity(ISymbol symbol) { var siblings = parent.ChildNodes().ToArray(); var index = Array.FindIndex(siblings, child => child == node); - if (index < 0) throw new InvalidOperationException("Missing declaration in oracle syntax path"); + if (index < 0) + { + throw new InvalidOperationException("Missing declaration in oracle syntax path"); + } + path.Add(index); node = parent; }