From b41220ae8918df055f8f3db8e7086daa24bc304b Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Wed, 3 Jun 2026 09:34:04 +0000 Subject: [PATCH 01/12] Optimize qualified name simplification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) 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 d90488b4c85..27916176c7d 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 @@ -14,6 +14,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Simplification; +using Microsoft.CodeAnalysis.Text; using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Providers; using Microsoft.TypeSpec.Generator.Utilities; @@ -148,7 +149,24 @@ private async Task ProcessDocument(Document document, MemberRemoverRew } document = document.WithSyntaxRoot(root); - document = await Simplifier.ReduceAsync(document); + document = await ReduceQualifiedNamesAsync(document); + root = await document.GetSyntaxRootAsync(); + if (root == null) + { + return document; + } + + var simplifierSpans = GetSimplifierSpans(root); + if (simplifierSpans.Count > 0) + { + root = root.WithAdditionalAnnotations(Simplifier.Annotation); + document = document.WithSyntaxRoot(root); + document = await Simplifier.ReduceAsync(document, simplifierSpans); + } + else if (ContainsSimplifierAnnotations(root)) + { + document = await Simplifier.ReduceAsync(document); + } // Reformat if any custom rewriters have been applied if (CodeModelGenerator.Instance.Rewriters.Count > 0) @@ -158,6 +176,128 @@ private async Task ProcessDocument(Document document, MemberRemoverRew return document; } + private static async Task ReduceQualifiedNamesAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + var semanticModel = await document.GetSemanticModelAsync(); + if (root == null || semanticModel == null) + { + return document; + } + + var safeNodes = new HashSet(); + foreach (var name in root.DescendantNodes().OfType()) + { + if (name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax || + name.Parent is QualifiedNameSyntax || + IsInUnsupportedQualifiedNameContext(name)) + { + continue; + } + + var originalSymbol = semanticModel.GetSymbolInfo(name).Symbol; + if (originalSymbol == null) + { + continue; + } + + var replacement = GetRightmostName(name).WithTriviaFrom(name); + if (SpeculativelyBindsToSameSymbol(semanticModel, name, replacement, originalSymbol)) + { + safeNodes.Add(name); + } + } + + if (safeNodes.Count == 0) + { + return document; + } + + var rewrittenRoot = root.ReplaceNodes( + safeNodes, + static (_, rewritten) => GetRightmostName(rewritten).WithTriviaFrom(rewritten)); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static bool SpeculativelyBindsToSameSymbol( + SemanticModel semanticModel, + NameSyntax originalName, + SimpleNameSyntax replacement, + ISymbol originalSymbol) + { + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + originalName.SpanStart, + replacement, + SpeculativeBindingOption.BindAsTypeOrNamespace).Symbol; + if (speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + return true; + } + + if (originalName.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression == originalName) + { + speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + originalName.SpanStart, + replacement, + SpeculativeBindingOption.BindAsExpression).Symbol; + return speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol); + } + + return false; + } + + private static SimpleNameSyntax GetRightmostName(NameSyntax name) => name switch + { + QualifiedNameSyntax qualifiedName => qualifiedName.Right, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name, + SimpleNameSyntax simpleName => simpleName, + _ => throw new InvalidOperationException($"Unexpected name syntax: {name.Kind()}") + }; + + private static bool IsInUnsupportedQualifiedNameContext(NameSyntax name) => + name.Ancestors().Any(static ancestor => + ancestor is UsingDirectiveSyntax || + ancestor is CrefSyntax); + + private static bool ContainsSimplifierAnnotations(SyntaxNode root) => + root.HasAnnotation(Simplifier.Annotation) || + root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => + nodeOrToken.HasAnnotation(Simplifier.Annotation)); + + private static IReadOnlyList GetSimplifierSpans(SyntaxNode root) + { + List spans = new(); + foreach (var member in root.DescendantNodes().OfType()) + { + if (ContainsReducibleSyntax(member)) + { + spans.Add(member.FullSpan); + } + } + + spans.AddRange(root + .DescendantNodesAndTokens(descendIntoTrivia: true) + .Where(static nodeOrToken => nodeOrToken.HasAnnotation(Simplifier.Annotation)) + .Select(static nodeOrToken => nodeOrToken.FullSpan)); + + return spans; + } + + private static bool ContainsReducibleSyntax(SyntaxNode root) => + root.DescendantNodes( + descendIntoChildren: node => node == root || node is not MemberDeclarationSyntax, + descendIntoTrivia: true).Any(static node => + node is ThisExpressionSyntax || + node is ParenthesizedExpressionSyntax || + node is CrefSyntax || + node is QualifiedNameSyntax || + node is MemberAccessExpressionSyntax || + node is AssignmentExpressionSyntax { RawKind: (int)SyntaxKind.SimpleAssignmentExpression } || + node is AliasQualifiedNameSyntax { Alias.Identifier.ValueText: "global" }); + public static bool IsGeneratedDocument(Document document) => document.Folders.Contains(GeneratedFolder); public static bool IsCustomDocument(Document document) => !IsGeneratedDocument(document); public static bool IsGeneratedTestDocument(Document document) => document.Folders.Contains(GeneratedTestFolder); From 4870164cb0b8b11ece0789961d0e1484f4d5d522 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 00:55:26 +0000 Subject: [PATCH 02/12] Handle System member qualified name reduction --- .../PostProcessing/GeneratedCodeWorkspace.cs | 180 +++++++++++++++++- .../test/GeneratedCodeWorkspaceTests.cs | 67 +++++++ 2 files changed, 242 insertions(+), 5 deletions(-) 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 27916176c7d..4e1f77d4131 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 @@ -185,7 +185,7 @@ private static async Task ReduceQualifiedNamesAsync(Document document) return document; } - var safeNodes = new HashSet(); + var safeNameReplacements = new Dictionary(); foreach (var name in root.DescendantNodes().OfType()) { if (name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax || @@ -204,18 +204,45 @@ name.Parent is QualifiedNameSyntax || var replacement = GetRightmostName(name).WithTriviaFrom(name); if (SpeculativelyBindsToSameSymbol(semanticModel, name, replacement, originalSymbol)) { - safeNodes.Add(name); + safeNameReplacements.Add(name, replacement); } } - if (safeNodes.Count == 0) + foreach (var attribute in root.DescendantNodes().OfType()) + { + if (TryGetAttributeNameReplacement(semanticModel, attribute, out var replacement)) + { + safeNameReplacements[attribute.Name] = replacement; + } + } + + var safeMemberAccessReplacements = new Dictionary(); + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + if (IsInUnsupportedQualifiedNameContext(memberAccess)) + { + continue; + } + + if (TryGetMemberAccessReplacement(semanticModel, memberAccess, out var replacement)) + { + safeMemberAccessReplacements.Add(memberAccess, replacement); + } + } + + if (safeNameReplacements.Count == 0 && safeMemberAccessReplacements.Count == 0) { return document; } var rewrittenRoot = root.ReplaceNodes( - safeNodes, - static (_, rewritten) => GetRightmostName(rewritten).WithTriviaFrom(rewritten)); + safeNameReplacements.Keys.Concat(safeMemberAccessReplacements.Keys), + (original, rewritten) => original switch + { + NameSyntax name => safeNameReplacements[name].WithTriviaFrom(rewritten), + MemberAccessExpressionSyntax memberAccess => safeMemberAccessReplacements[memberAccess].WithTriviaFrom(rewritten), + _ => rewritten + }); return document.WithSyntaxRoot(rewrittenRoot); } @@ -257,11 +284,154 @@ private static bool SpeculativelyBindsToSameSymbol( _ => throw new InvalidOperationException($"Unexpected name syntax: {name.Kind()}") }; + private static bool TryGetAttributeNameReplacement( + SemanticModel semanticModel, + AttributeSyntax attribute, + out NameSyntax replacement) + { + replacement = attribute.Name; + if (attribute.Name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax) + { + return false; + } + + var originalSymbol = semanticModel.GetSymbolInfo(attribute).Symbol; + if (originalSymbol is not IMethodSymbol { ContainingType: { } originalAttributeType }) + { + return false; + } + + var rightmostName = GetRightmostName(attribute.Name); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + attribute.Name.SpanStart, + rightmostName, + SpeculativeBindingOption.BindAsTypeOrNamespace).Symbol; + if (!SymbolEqualityComparer.Default.Equals(originalAttributeType, speculativeSymbol)) + { + return false; + } + + replacement = TrimAttributeSuffix(rightmostName).WithTriviaFrom(attribute.Name); + return true; + } + + private static SimpleNameSyntax TrimAttributeSuffix(SimpleNameSyntax name) + { + const string AttributeSuffix = "Attribute"; + var identifier = name.Identifier; + var text = identifier.ValueText; + if (!text.EndsWith(AttributeSuffix, StringComparison.Ordinal) || text.Length == AttributeSuffix.Length) + { + return name; + } + + return SyntaxFactory.IdentifierName( + SyntaxFactory.Identifier( + identifier.LeadingTrivia, + text.Substring(0, text.Length - AttributeSuffix.Length), + identifier.TrailingTrivia)); + } + + private static bool TryGetMemberAccessReplacement( + SemanticModel semanticModel, + MemberAccessExpressionSyntax memberAccess, + out ExpressionSyntax replacement) + { + replacement = memberAccess; + var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; + if (originalSymbol == null || + !TryGetMemberAccessParts(memberAccess, out var parts) || + parts.Count < 2 || + parts[0].Identifier.ValueText != "System") + { + return false; + } + + for (int i = parts.Count - 1; i > 0; i--) + { + var candidate = BuildMemberAccess(parts, i).WithTriviaFrom(memberAccess); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + memberAccess.SpanStart, + candidate, + SpeculativeBindingOption.BindAsExpression).Symbol; + if (speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + replacement = candidate; + return true; + } + } + + return false; + } + + private static bool TryGetMemberAccessParts(ExpressionSyntax expression, out IReadOnlyList parts) + { + var builder = new List(); + if (AddMemberAccessParts(expression, builder)) + { + parts = builder; + return true; + } + + parts = []; + return false; + } + + private static bool AddMemberAccessParts(SyntaxNode expression, List parts) + { + switch (expression) + { + case SimpleNameSyntax name: + parts.Add(name); + return true; + case MemberAccessExpressionSyntax memberAccess: + if (!AddMemberAccessParts(memberAccess.Expression, parts)) + { + return false; + } + + parts.Add(memberAccess.Name); + return true; + case QualifiedNameSyntax qualifiedName: + if (!AddMemberAccessParts(qualifiedName.Left, parts)) + { + return false; + } + + parts.Add(qualifiedName.Right); + return true; + case AliasQualifiedNameSyntax { Alias.Identifier.ValueText: "global" } aliasQualifiedName: + parts.Add(aliasQualifiedName.Name); + return true; + default: + return false; + } + } + + private static ExpressionSyntax BuildMemberAccess(IReadOnlyList parts, int startIndex) + { + ExpressionSyntax expression = parts[startIndex]; + for (int i = startIndex + 1; i < parts.Count; i++) + { + expression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + expression, + parts[i]); + } + + return expression; + } + private static bool IsInUnsupportedQualifiedNameContext(NameSyntax name) => name.Ancestors().Any(static ancestor => ancestor is UsingDirectiveSyntax || ancestor is CrefSyntax); + private static bool IsInUnsupportedQualifiedNameContext(ExpressionSyntax expression) => + expression.Ancestors().Any(static ancestor => + ancestor is CrefSyntax); + private static bool ContainsSimplifierAnnotations(SyntaxNode root) => root.HasAnnotation(Simplifier.Annotation) || root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 0a75d8c9360..8ca35ece393 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Build.Construction; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.TypeSpec.Generator.Primitives; using Microsoft.TypeSpec.Generator.Tests.Common; using NUnit.Framework; using System; @@ -97,6 +98,72 @@ await MockHelpers.LoadMockGeneratorAsync( Assert.NotNull(fooMethod, "Foo method should be found in the SimpleType"); } + [Test] + public async Task GetGeneratedFilesAsync_SimplifiesFrameworkNamesWhenTypeHasSystemMember() + { + MockHelpers.LoadMockGenerator( + outputPath: _projectDir, + configuration: "{\"package-name\": \"TestNamespace\"}", + additionalMetadataReferences: + [ + MetadataReference.CreateFromFile(typeof(EditorBrowsableAttribute).Assembly.Location) + ]); + + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(false); + await workspace.AddGeneratedFile(new CodeFile( + """ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ComponentModel; + +namespace TestNamespace +{ + public readonly partial struct TestRole : IEquatable + { + private readonly string _value; + private const string SystemValue = "system"; + + public TestRole(string value) + { + _value = value; + } + + public static TestRole System { get; } = new TestRole(SystemValue); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override bool Equals(object obj) => obj is TestRole other && Equals(other); + + public bool Equals(TestRole other) => string.Equals(_value, other._value, global::System.StringComparison.InvariantCultureIgnoreCase); + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Never)] + public override int GetHashCode() => _value != null ? global::System.StringComparer.InvariantCultureIgnoreCase.GetHashCode(_value) : 0; + } +} +""", + "TestRole.cs")); + + string? generatedText = null; + await foreach (var generatedFile in workspace.GetGeneratedFilesAsync()) + { + generatedText = generatedFile.Text; + } + + Assert.That(generatedText, Is.Not.Null); + Assert.That(generatedText, Does.Contain("[EditorBrowsable(EditorBrowsableState.Never)]")); + Assert.That(generatedText, Does.Contain("StringComparison.InvariantCultureIgnoreCase")); + Assert.That(generatedText, Does.Contain("StringComparer.InvariantCultureIgnoreCase")); + Assert.That(generatedText, Does.Not.Contain("System.ComponentModel.EditorBrowsableAttribute")); + Assert.That(generatedText, Does.Not.Contain("System.StringComparison")); + Assert.That(generatedText, Does.Not.Contain("System.StringComparer")); + } + [Test] public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() { From 59eb312177422a20cb0021d140ba8e9bb768826c Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 01:10:44 +0000 Subject: [PATCH 03/12] Avoid ambiguous System member pre-reduction --- .../PostProcessing/GeneratedCodeWorkspace.cs | 183 +----------------- 1 file changed, 9 insertions(+), 174 deletions(-) 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 4e1f77d4131..702fb337a64 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 @@ -185,11 +185,12 @@ private static async Task ReduceQualifiedNamesAsync(Document document) return document; } - var safeNameReplacements = new Dictionary(); + var safeNodes = new HashSet(); foreach (var name in root.DescendantNodes().OfType()) { if (name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax || name.Parent is QualifiedNameSyntax || + IsPartOfMemberAccessChain(name) || IsInUnsupportedQualifiedNameContext(name)) { continue; @@ -204,45 +205,18 @@ name.Parent is QualifiedNameSyntax || var replacement = GetRightmostName(name).WithTriviaFrom(name); if (SpeculativelyBindsToSameSymbol(semanticModel, name, replacement, originalSymbol)) { - safeNameReplacements.Add(name, replacement); + safeNodes.Add(name); } } - foreach (var attribute in root.DescendantNodes().OfType()) - { - if (TryGetAttributeNameReplacement(semanticModel, attribute, out var replacement)) - { - safeNameReplacements[attribute.Name] = replacement; - } - } - - var safeMemberAccessReplacements = new Dictionary(); - foreach (var memberAccess in root.DescendantNodes().OfType()) - { - if (IsInUnsupportedQualifiedNameContext(memberAccess)) - { - continue; - } - - if (TryGetMemberAccessReplacement(semanticModel, memberAccess, out var replacement)) - { - safeMemberAccessReplacements.Add(memberAccess, replacement); - } - } - - if (safeNameReplacements.Count == 0 && safeMemberAccessReplacements.Count == 0) + if (safeNodes.Count == 0) { return document; } var rewrittenRoot = root.ReplaceNodes( - safeNameReplacements.Keys.Concat(safeMemberAccessReplacements.Keys), - (original, rewritten) => original switch - { - NameSyntax name => safeNameReplacements[name].WithTriviaFrom(rewritten), - MemberAccessExpressionSyntax memberAccess => safeMemberAccessReplacements[memberAccess].WithTriviaFrom(rewritten), - _ => rewritten - }); + safeNodes, + static (_, rewritten) => GetRightmostName(rewritten).WithTriviaFrom(rewritten)); return document.WithSyntaxRoot(rewrittenRoot); } @@ -284,154 +258,15 @@ private static bool SpeculativelyBindsToSameSymbol( _ => throw new InvalidOperationException($"Unexpected name syntax: {name.Kind()}") }; - private static bool TryGetAttributeNameReplacement( - SemanticModel semanticModel, - AttributeSyntax attribute, - out NameSyntax replacement) - { - replacement = attribute.Name; - if (attribute.Name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax) - { - return false; - } - - var originalSymbol = semanticModel.GetSymbolInfo(attribute).Symbol; - if (originalSymbol is not IMethodSymbol { ContainingType: { } originalAttributeType }) - { - return false; - } - - var rightmostName = GetRightmostName(attribute.Name); - var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( - attribute.Name.SpanStart, - rightmostName, - SpeculativeBindingOption.BindAsTypeOrNamespace).Symbol; - if (!SymbolEqualityComparer.Default.Equals(originalAttributeType, speculativeSymbol)) - { - return false; - } - - replacement = TrimAttributeSuffix(rightmostName).WithTriviaFrom(attribute.Name); - return true; - } - - private static SimpleNameSyntax TrimAttributeSuffix(SimpleNameSyntax name) - { - const string AttributeSuffix = "Attribute"; - var identifier = name.Identifier; - var text = identifier.ValueText; - if (!text.EndsWith(AttributeSuffix, StringComparison.Ordinal) || text.Length == AttributeSuffix.Length) - { - return name; - } - - return SyntaxFactory.IdentifierName( - SyntaxFactory.Identifier( - identifier.LeadingTrivia, - text.Substring(0, text.Length - AttributeSuffix.Length), - identifier.TrailingTrivia)); - } - - private static bool TryGetMemberAccessReplacement( - SemanticModel semanticModel, - MemberAccessExpressionSyntax memberAccess, - out ExpressionSyntax replacement) - { - replacement = memberAccess; - var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; - if (originalSymbol == null || - !TryGetMemberAccessParts(memberAccess, out var parts) || - parts.Count < 2 || - parts[0].Identifier.ValueText != "System") - { - return false; - } - - for (int i = parts.Count - 1; i > 0; i--) - { - var candidate = BuildMemberAccess(parts, i).WithTriviaFrom(memberAccess); - var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( - memberAccess.SpanStart, - candidate, - SpeculativeBindingOption.BindAsExpression).Symbol; - if (speculativeSymbol != null && - SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) - { - replacement = candidate; - return true; - } - } - - return false; - } - - private static bool TryGetMemberAccessParts(ExpressionSyntax expression, out IReadOnlyList parts) - { - var builder = new List(); - if (AddMemberAccessParts(expression, builder)) - { - parts = builder; - return true; - } - - parts = []; - return false; - } - - private static bool AddMemberAccessParts(SyntaxNode expression, List parts) - { - switch (expression) - { - case SimpleNameSyntax name: - parts.Add(name); - return true; - case MemberAccessExpressionSyntax memberAccess: - if (!AddMemberAccessParts(memberAccess.Expression, parts)) - { - return false; - } - - parts.Add(memberAccess.Name); - return true; - case QualifiedNameSyntax qualifiedName: - if (!AddMemberAccessParts(qualifiedName.Left, parts)) - { - return false; - } - - parts.Add(qualifiedName.Right); - return true; - case AliasQualifiedNameSyntax { Alias.Identifier.ValueText: "global" } aliasQualifiedName: - parts.Add(aliasQualifiedName.Name); - return true; - default: - return false; - } - } - - private static ExpressionSyntax BuildMemberAccess(IReadOnlyList parts, int startIndex) - { - ExpressionSyntax expression = parts[startIndex]; - for (int i = startIndex + 1; i < parts.Count; i++) - { - expression = SyntaxFactory.MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - expression, - parts[i]); - } - - return expression; - } + private static bool IsPartOfMemberAccessChain(NameSyntax name) => + name.Parent is MemberAccessExpressionSyntax || + name.Ancestors().OfType().Any(); private static bool IsInUnsupportedQualifiedNameContext(NameSyntax name) => name.Ancestors().Any(static ancestor => ancestor is UsingDirectiveSyntax || ancestor is CrefSyntax); - private static bool IsInUnsupportedQualifiedNameContext(ExpressionSyntax expression) => - expression.Ancestors().Any(static ancestor => - ancestor is CrefSyntax); - private static bool ContainsSimplifierAnnotations(SyntaxNode root) => root.HasAnnotation(Simplifier.Annotation) || root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => From 5bbd4eb7e1004349a76c0973424918e2bda6e557 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 01:14:14 +0000 Subject: [PATCH 04/12] Revert "Avoid ambiguous System member pre-reduction" This reverts commit 59eb312177422a20cb0021d140ba8e9bb768826c. --- .../PostProcessing/GeneratedCodeWorkspace.cs | 183 +++++++++++++++++- 1 file changed, 174 insertions(+), 9 deletions(-) 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 702fb337a64..4e1f77d4131 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 @@ -185,12 +185,11 @@ private static async Task ReduceQualifiedNamesAsync(Document document) return document; } - var safeNodes = new HashSet(); + var safeNameReplacements = new Dictionary(); foreach (var name in root.DescendantNodes().OfType()) { if (name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax || name.Parent is QualifiedNameSyntax || - IsPartOfMemberAccessChain(name) || IsInUnsupportedQualifiedNameContext(name)) { continue; @@ -205,18 +204,45 @@ name.Parent is QualifiedNameSyntax || var replacement = GetRightmostName(name).WithTriviaFrom(name); if (SpeculativelyBindsToSameSymbol(semanticModel, name, replacement, originalSymbol)) { - safeNodes.Add(name); + safeNameReplacements.Add(name, replacement); } } - if (safeNodes.Count == 0) + foreach (var attribute in root.DescendantNodes().OfType()) + { + if (TryGetAttributeNameReplacement(semanticModel, attribute, out var replacement)) + { + safeNameReplacements[attribute.Name] = replacement; + } + } + + var safeMemberAccessReplacements = new Dictionary(); + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + if (IsInUnsupportedQualifiedNameContext(memberAccess)) + { + continue; + } + + if (TryGetMemberAccessReplacement(semanticModel, memberAccess, out var replacement)) + { + safeMemberAccessReplacements.Add(memberAccess, replacement); + } + } + + if (safeNameReplacements.Count == 0 && safeMemberAccessReplacements.Count == 0) { return document; } var rewrittenRoot = root.ReplaceNodes( - safeNodes, - static (_, rewritten) => GetRightmostName(rewritten).WithTriviaFrom(rewritten)); + safeNameReplacements.Keys.Concat(safeMemberAccessReplacements.Keys), + (original, rewritten) => original switch + { + NameSyntax name => safeNameReplacements[name].WithTriviaFrom(rewritten), + MemberAccessExpressionSyntax memberAccess => safeMemberAccessReplacements[memberAccess].WithTriviaFrom(rewritten), + _ => rewritten + }); return document.WithSyntaxRoot(rewrittenRoot); } @@ -258,15 +284,154 @@ private static bool SpeculativelyBindsToSameSymbol( _ => throw new InvalidOperationException($"Unexpected name syntax: {name.Kind()}") }; - private static bool IsPartOfMemberAccessChain(NameSyntax name) => - name.Parent is MemberAccessExpressionSyntax || - name.Ancestors().OfType().Any(); + private static bool TryGetAttributeNameReplacement( + SemanticModel semanticModel, + AttributeSyntax attribute, + out NameSyntax replacement) + { + replacement = attribute.Name; + if (attribute.Name is not QualifiedNameSyntax and not AliasQualifiedNameSyntax) + { + return false; + } + + var originalSymbol = semanticModel.GetSymbolInfo(attribute).Symbol; + if (originalSymbol is not IMethodSymbol { ContainingType: { } originalAttributeType }) + { + return false; + } + + var rightmostName = GetRightmostName(attribute.Name); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + attribute.Name.SpanStart, + rightmostName, + SpeculativeBindingOption.BindAsTypeOrNamespace).Symbol; + if (!SymbolEqualityComparer.Default.Equals(originalAttributeType, speculativeSymbol)) + { + return false; + } + + replacement = TrimAttributeSuffix(rightmostName).WithTriviaFrom(attribute.Name); + return true; + } + + private static SimpleNameSyntax TrimAttributeSuffix(SimpleNameSyntax name) + { + const string AttributeSuffix = "Attribute"; + var identifier = name.Identifier; + var text = identifier.ValueText; + if (!text.EndsWith(AttributeSuffix, StringComparison.Ordinal) || text.Length == AttributeSuffix.Length) + { + return name; + } + + return SyntaxFactory.IdentifierName( + SyntaxFactory.Identifier( + identifier.LeadingTrivia, + text.Substring(0, text.Length - AttributeSuffix.Length), + identifier.TrailingTrivia)); + } + + private static bool TryGetMemberAccessReplacement( + SemanticModel semanticModel, + MemberAccessExpressionSyntax memberAccess, + out ExpressionSyntax replacement) + { + replacement = memberAccess; + var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; + if (originalSymbol == null || + !TryGetMemberAccessParts(memberAccess, out var parts) || + parts.Count < 2 || + parts[0].Identifier.ValueText != "System") + { + return false; + } + + for (int i = parts.Count - 1; i > 0; i--) + { + var candidate = BuildMemberAccess(parts, i).WithTriviaFrom(memberAccess); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + memberAccess.SpanStart, + candidate, + SpeculativeBindingOption.BindAsExpression).Symbol; + if (speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + replacement = candidate; + return true; + } + } + + return false; + } + + private static bool TryGetMemberAccessParts(ExpressionSyntax expression, out IReadOnlyList parts) + { + var builder = new List(); + if (AddMemberAccessParts(expression, builder)) + { + parts = builder; + return true; + } + + parts = []; + return false; + } + + private static bool AddMemberAccessParts(SyntaxNode expression, List parts) + { + switch (expression) + { + case SimpleNameSyntax name: + parts.Add(name); + return true; + case MemberAccessExpressionSyntax memberAccess: + if (!AddMemberAccessParts(memberAccess.Expression, parts)) + { + return false; + } + + parts.Add(memberAccess.Name); + return true; + case QualifiedNameSyntax qualifiedName: + if (!AddMemberAccessParts(qualifiedName.Left, parts)) + { + return false; + } + + parts.Add(qualifiedName.Right); + return true; + case AliasQualifiedNameSyntax { Alias.Identifier.ValueText: "global" } aliasQualifiedName: + parts.Add(aliasQualifiedName.Name); + return true; + default: + return false; + } + } + + private static ExpressionSyntax BuildMemberAccess(IReadOnlyList parts, int startIndex) + { + ExpressionSyntax expression = parts[startIndex]; + for (int i = startIndex + 1; i < parts.Count; i++) + { + expression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + expression, + parts[i]); + } + + return expression; + } private static bool IsInUnsupportedQualifiedNameContext(NameSyntax name) => name.Ancestors().Any(static ancestor => ancestor is UsingDirectiveSyntax || ancestor is CrefSyntax); + private static bool IsInUnsupportedQualifiedNameContext(ExpressionSyntax expression) => + expression.Ancestors().Any(static ancestor => + ancestor is CrefSyntax); + private static bool ContainsSimplifierAnnotations(SyntaxNode root) => root.HasAnnotation(Simplifier.Annotation) || root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => From 47fbfa978ade1d0d9efe53d4da80cc42dfd1aab2 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 01:22:58 +0000 Subject: [PATCH 05/12] Experiment with manual generated name reduction --- .../PostProcessing/GeneratedCodeWorkspace.cs | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) 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 4e1f77d4131..4e099783355 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 @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis; using MSBuildProjectCollection = Microsoft.Build.Evaluation.ProjectCollection; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Simplification; using Microsoft.CodeAnalysis.Text; @@ -150,23 +151,7 @@ private async Task ProcessDocument(Document document, MemberRemoverRew document = document.WithSyntaxRoot(root); document = await ReduceQualifiedNamesAsync(document); - root = await document.GetSyntaxRootAsync(); - if (root == null) - { - return document; - } - - var simplifierSpans = GetSimplifierSpans(root); - if (simplifierSpans.Count > 0) - { - root = root.WithAdditionalAnnotations(Simplifier.Annotation); - document = document.WithSyntaxRoot(root); - document = await Simplifier.ReduceAsync(document, simplifierSpans); - } - else if (ContainsSimplifierAnnotations(root)) - { - document = await Simplifier.ReduceAsync(document); - } + document = await ReduceParenthesizedAssignmentsAsync(document); // Reformat if any custom rewriters have been applied if (CodeModelGenerator.Instance.Rewriters.Count > 0) @@ -246,6 +231,31 @@ name.Parent is QualifiedNameSyntax || return document.WithSyntaxRoot(rewrittenRoot); } + private static async Task ReduceParenthesizedAssignmentsAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + if (root == null) + { + return document; + } + + var assignments = root.DescendantNodes() + .OfType() + .Where(static node => + node.Expression is AssignmentExpressionSyntax && + node.Parent is ExpressionStatementSyntax) + .ToList(); + if (assignments.Count == 0) + { + return document; + } + + var rewrittenRoot = root.ReplaceNodes( + assignments, + static (_, rewritten) => rewritten.Expression.WithTriviaFrom(rewritten)); + return document.WithSyntaxRoot(rewrittenRoot); + } + private static bool SpeculativelyBindsToSameSymbol( SemanticModel semanticModel, NameSyntax originalName, From f25c028960ffe388c785a6efb189110ca4fa314e Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 01:54:17 +0000 Subject: [PATCH 06/12] Reduce global aliases in manual name experiment --- .../PostProcessing/GeneratedCodeWorkspace.cs | 153 ++++++++++++- .../test/GeneratedCodeWorkspaceTests.cs | 213 ++++++++++++++++-- 2 files changed, 344 insertions(+), 22 deletions(-) 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 4e099783355..4abbfb665c4 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 @@ -152,6 +152,7 @@ private async Task ProcessDocument(Document document, MemberRemoverRew document = await ReduceQualifiedNamesAsync(document); document = await ReduceParenthesizedAssignmentsAsync(document); + document = await ReduceDocumentationGlobalAliasesAsync(document); // Reformat if any custom rewriters have been applied if (CodeModelGenerator.Instance.Rewriters.Count > 0) @@ -170,6 +171,45 @@ private static async Task ReduceQualifiedNamesAsync(Document document) return document; } + var globalAliases = new Dictionary(); + foreach (var aliasName in root.DescendantNodes().OfType()) + { + if (aliasName.Alias.Identifier.ValueText != "global" || + aliasName.Ancestors().Any(static ancestor => + ancestor is AttributeSyntax || + ancestor is MemberAccessExpressionSyntax) || + IsInUnsupportedQualifiedNameContext(aliasName)) + { + continue; + } + + var originalSymbol = GetSymbol(semanticModel, aliasName); + if (originalSymbol == null) + { + continue; + } + + var replacement = aliasName.Name.WithTriviaFrom(aliasName); + if (SpeculativelyBindsToSameSymbol(semanticModel, aliasName, replacement, originalSymbol)) + { + globalAliases.Add(aliasName, replacement); + } + } + + if (globalAliases.Count > 0) + { + root = root.ReplaceNodes( + globalAliases.Keys, + (original, rewritten) => globalAliases[original].WithTriviaFrom(rewritten)); + document = document.WithSyntaxRoot(root); + semanticModel = await document.GetSemanticModelAsync(); + root = await document.GetSyntaxRootAsync(); + if (root == null || semanticModel == null) + { + return document; + } + } + var safeNameReplacements = new Dictionary(); foreach (var name in root.DescendantNodes().OfType()) { @@ -180,14 +220,13 @@ name.Parent is QualifiedNameSyntax || continue; } - var originalSymbol = semanticModel.GetSymbolInfo(name).Symbol; + var originalSymbol = GetSymbol(semanticModel, name); if (originalSymbol == null) { continue; } - var replacement = GetRightmostName(name).WithTriviaFrom(name); - if (SpeculativelyBindsToSameSymbol(semanticModel, name, replacement, originalSymbol)) + if (TryGetNameReplacement(semanticModel, name, originalSymbol, out var replacement)) { safeNameReplacements.Add(name, replacement); } @@ -231,6 +270,10 @@ name.Parent is QualifiedNameSyntax || return document.WithSyntaxRoot(rewrittenRoot); } + private static ISymbol? GetSymbol(SemanticModel semanticModel, NameSyntax name) => + semanticModel.GetSymbolInfo(name).Symbol ?? + semanticModel.GetTypeInfo(name).Type; + private static async Task ReduceParenthesizedAssignmentsAsync(Document document) { var root = await document.GetSyntaxRootAsync(); @@ -256,10 +299,65 @@ node.Expression is AssignmentExpressionSyntax && return document.WithSyntaxRoot(rewrittenRoot); } + private static async Task ReduceDocumentationGlobalAliasesAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + if (root == null) + { + return document; + } + + var documentationTrivia = root.DescendantTrivia(descendIntoTrivia: true) + .Where(static trivia => + trivia.HasStructure && + trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) && + trivia.ToFullString().Contains("global::", StringComparison.Ordinal)) + .ToList(); + if (documentationTrivia.Count == 0) + { + return document; + } + + var rewrittenRoot = root.ReplaceTrivia( + documentationTrivia, + static (_, rewritten) => + { + var reduced = rewritten.ToFullString().Replace("global::", string.Empty, StringComparison.Ordinal); + var parsedTrivia = SyntaxFactory.ParseLeadingTrivia(reduced); + return parsedTrivia.Count == 1 ? parsedTrivia[0] : rewritten; + }); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static bool TryGetNameReplacement( + SemanticModel semanticModel, + NameSyntax originalName, + ISymbol originalSymbol, + out NameSyntax replacement) + { + replacement = originalName; + if (!TryGetNameParts(originalName, out var parts)) + { + return false; + } + + for (int i = parts.Count - 1; i >= 0; i--) + { + var candidate = BuildName(parts, i).WithTriviaFrom(originalName); + if (SpeculativelyBindsToSameSymbol(semanticModel, originalName, candidate, originalSymbol)) + { + replacement = candidate; + return true; + } + } + + return false; + } + private static bool SpeculativelyBindsToSameSymbol( SemanticModel semanticModel, NameSyntax originalName, - SimpleNameSyntax replacement, + NameSyntax replacement, ISymbol originalSymbol) { var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( @@ -286,6 +384,53 @@ private static bool SpeculativelyBindsToSameSymbol( return false; } + private static bool TryGetNameParts(NameSyntax name, out IReadOnlyList parts) + { + var builder = new List(); + if (AddNameParts(name, builder)) + { + parts = builder; + return true; + } + + parts = []; + return false; + } + + private static bool AddNameParts(NameSyntax name, List parts) + { + switch (name) + { + case SimpleNameSyntax simpleName: + parts.Add(simpleName); + return true; + case QualifiedNameSyntax qualifiedName: + if (!AddNameParts(qualifiedName.Left, parts)) + { + return false; + } + + parts.Add(qualifiedName.Right); + return true; + case AliasQualifiedNameSyntax { Alias.Identifier.ValueText: "global" } aliasQualifiedName: + parts.Add(aliasQualifiedName.Name); + return true; + default: + return false; + } + } + + private static NameSyntax BuildName(IReadOnlyList parts, int startIndex) + { + NameSyntax name = parts[startIndex]; + for (int i = startIndex + 1; i < parts.Count; i++) + { + name = SyntaxFactory.QualifiedName(name, parts[i]); + } + + return name; + } + private static SimpleNameSyntax GetRightmostName(NameSyntax name) => name switch { QualifiedNameSyntax qualifiedName => qualifiedName.Right, diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 8ca35ece393..4dc0d98548e 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -101,17 +101,7 @@ await MockHelpers.LoadMockGeneratorAsync( [Test] public async Task GetGeneratedFilesAsync_SimplifiesFrameworkNamesWhenTypeHasSystemMember() { - MockHelpers.LoadMockGenerator( - outputPath: _projectDir, - configuration: "{\"package-name\": \"TestNamespace\"}", - additionalMetadataReferences: - [ - MetadataReference.CreateFromFile(typeof(EditorBrowsableAttribute).Assembly.Location) - ]); - - GeneratedCodeWorkspace.Initialize(); - var workspace = await GeneratedCodeWorkspace.Create(false); - await workspace.AddGeneratedFile(new CodeFile( + var generatedText = await ProcessGeneratedCodeAsync( """ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. @@ -147,13 +137,7 @@ public TestRole(string value) } } """, - "TestRole.cs")); - - string? generatedText = null; - await foreach (var generatedFile in workspace.GetGeneratedFilesAsync()) - { - generatedText = generatedFile.Text; - } + typeof(EditorBrowsableAttribute).Assembly.Location); Assert.That(generatedText, Is.Not.Null); Assert.That(generatedText, Does.Contain("[EditorBrowsable(EditorBrowsableState.Never)]")); @@ -164,6 +148,178 @@ public TestRole(string value) Assert.That(generatedText, Does.Not.Contain("System.StringComparer")); } + [Test] + public async Task GetGeneratedFilesAsync_PreservesQualificationWhenImportedNamespacesContainSameTypeName() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using First; +using Second; + +namespace TestNamespace +{ + public class Container + { + public global::First.Conflict FirstValue { get; } + public global::Second.Conflict SecondValue { get; } + } +} + +namespace First +{ + public class Conflict { } +} + +namespace Second +{ + public class Conflict { } +} +"""); + + Assert.That(generatedText, Does.Contain("First.Conflict FirstValue")); + Assert.That(generatedText, Does.Contain("Second.Conflict SecondValue")); + Assert.That(generatedText, Does.Not.Contain("public Conflict FirstValue")); + Assert.That(generatedText, Does.Not.Contain("public Conflict SecondValue")); + } + + [Test] + public async Task GetGeneratedFilesAsync_PreservesFrameworkQualificationWhenGeneratedModelShadowsFrameworkType() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System; + +namespace TestNamespace +{ + public class BinaryData { } + + public class Container + { + public global::System.BinaryData Payload { get; } + } +} +"""); + + Assert.That(generatedText, Does.Contain("System.BinaryData Payload")); + Assert.That(generatedText, Does.Not.Contain("public BinaryData Payload")); + } + + [Test] + public async Task GetGeneratedFilesAsync_PreservesQualificationWhenCurrentNamespaceTypeShadowsImportedType() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using External; + +namespace TestNamespace +{ + public class Widget { } + + public class Container + { + public global::External.Widget ExternalWidget { get; } + } +} + +namespace External +{ + public class Widget { } +} +"""); + + Assert.That(generatedText, Does.Contain("External.Widget ExternalWidget")); + Assert.That(generatedText, Does.Not.Contain("public Widget ExternalWidget")); + } + + [Test] + public async Task GetGeneratedFilesAsync_PreservesQualificationWhenParameterNameConflictsWithTypeName() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System; + +namespace TestNamespace +{ + public class Container + { + public bool Equals(string StringComparison) => global::System.StringComparison.InvariantCultureIgnoreCase.Equals(StringComparison, StringComparison); + } +} +"""); + + Assert.That(generatedText, Does.Contain("System.StringComparison.InvariantCultureIgnoreCase")); + Assert.That(generatedText, Does.Not.Contain("=> StringComparison.InvariantCultureIgnoreCase")); + } + + [Test] + public async Task GetGeneratedFilesAsync_ReducesGlobalAliasesInXmlDocCrefsSeparatelyFromCode() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System; + +namespace TestNamespace +{ + /// See . + public class Container + { + public global::System.ArgumentNullException Create() => null; + } +} +"""); + + Assert.That(generatedText, Does.Contain("")); + Assert.That(generatedText, Does.Contain("ArgumentNullException Create()")); + Assert.That(generatedText, Does.Not.Contain("global::System.ArgumentNullException")); + } + + [Test] + public async Task GetGeneratedFilesAsync_ReducesAliasesGenericNamesAndCustomizationTypesSafely() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System.Collections.Generic; +using AliasWidget = Customization.Widget; + +namespace TestNamespace +{ + public class Container + { + public global::System.Collections.Generic.IList Widgets { get; } + public global::Customization.Widget Create(AliasWidget widget) => widget; + } +} + +namespace Customization +{ + public class Widget { } +} +"""); + + Assert.That(generatedText, Does.Contain("IList Widgets")); + Assert.That(generatedText, Does.Contain("Customization.Widget Create(AliasWidget widget)")); + Assert.That(generatedText, Does.Not.Contain("global::System.Collections.Generic.IList")); + Assert.That(generatedText, Does.Not.Contain("global::Customization.Widget")); + } + [Test] public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() { @@ -403,6 +559,27 @@ public class Placeholder {{ }} return dllPath; } + private async Task ProcessGeneratedCodeAsync(string content, params string[] additionalMetadataReferencePaths) + { + MockHelpers.LoadMockGenerator( + outputPath: _projectDir, + configuration: "{\"package-name\": \"TestNamespace\"}", + additionalMetadataReferences: additionalMetadataReferencePaths.Select(static path => MetadataReference.CreateFromFile(path))); + + GeneratedCodeWorkspace.Initialize(); + var workspace = await GeneratedCodeWorkspace.Create(false); + await workspace.AddGeneratedFile(new CodeFile(content, "TestFile.cs")); + + string? generatedText = null; + await foreach (var generatedFile in workspace.GetGeneratedFilesAsync()) + { + generatedText = generatedFile.Text; + } + + Assert.That(generatedText, Is.Not.Null); + return generatedText!; + } + private void CreateTestAssemblyAndProjectFile(string nugetCacheDir, string csProjectFileName) { var ns = csProjectFileName.StartsWith("TestNamespaceUnevaluatedFrameworkValue") From bbf03058b583937511018d8279e14bdd8b527306 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 02:05:48 +0000 Subject: [PATCH 07/12] Handle global aliases in cast type arguments --- .../src/PostProcessing/GeneratedCodeWorkspace.cs | 8 ++++++-- .../test/GeneratedCodeWorkspaceTests.cs | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) 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 4abbfb665c4..77a53edaadb 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 @@ -176,8 +176,8 @@ private static async Task ReduceQualifiedNamesAsync(Document document) { if (aliasName.Alias.Identifier.ValueText != "global" || aliasName.Ancestors().Any(static ancestor => - ancestor is AttributeSyntax || - ancestor is MemberAccessExpressionSyntax) || + ancestor is AttributeSyntax) || + IsInMemberAccessExpressionChain(aliasName) || IsInUnsupportedQualifiedNameContext(aliasName)) { continue; @@ -587,6 +587,10 @@ private static bool IsInUnsupportedQualifiedNameContext(ExpressionSyntax express expression.Ancestors().Any(static ancestor => ancestor is CrefSyntax); + private static bool IsInMemberAccessExpressionChain(NameSyntax name) => + name.Ancestors().Any(static ancestor => ancestor is MemberAccessExpressionSyntax) && + !name.Ancestors().Any(static ancestor => ancestor is TypeSyntax); + private static bool ContainsSimplifierAnnotations(SyntaxNode root) => root.HasAnnotation(Simplifier.Annotation) || root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 4dc0d98548e..9254cee9ecc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -305,6 +305,12 @@ public class Container { public global::System.Collections.Generic.IList Widgets { get; } public global::Customization.Widget Create(AliasWidget widget) => widget; + public string GetFormat() => ((IPersistableModel)this).GetFormatFromOptions(null); + } + + public interface IPersistableModel + { + string GetFormatFromOptions(object options); } } @@ -316,6 +322,7 @@ public class Widget { } Assert.That(generatedText, Does.Contain("IList Widgets")); Assert.That(generatedText, Does.Contain("Customization.Widget Create(AliasWidget widget)")); + Assert.That(generatedText, Does.Contain("((IPersistableModel)this).GetFormatFromOptions(null)")); Assert.That(generatedText, Does.Not.Contain("global::System.Collections.Generic.IList")); Assert.That(generatedText, Does.Not.Contain("global::Customization.Widget")); } From 8e5c56931b34aea315889127707102e277c556ff Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 02:35:51 +0000 Subject: [PATCH 08/12] Reduce qualified names iteratively --- .../PostProcessing/GeneratedCodeWorkspace.cs | 75 +++++++++-- .../test/GeneratedCodeWorkspaceTests.cs | 120 +++++++++++++++++- 2 files changed, 186 insertions(+), 9 deletions(-) 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 77a53edaadb..281deb45a8d 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 @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Build.Construction; @@ -150,9 +151,19 @@ private async Task ProcessDocument(Document document, MemberRemoverRew } document = document.WithSyntaxRoot(root); - document = await ReduceQualifiedNamesAsync(document); + for (int i = 0; i < 8; i++) + { + var reducedDocument = await ReduceQualifiedNamesAsync(document); + if (ReferenceEquals(reducedDocument, document)) + { + break; + } + + document = reducedDocument; + } + document = await ReduceParenthesizedAssignmentsAsync(document); - document = await ReduceDocumentationGlobalAliasesAsync(document); + document = await ReduceDocumentationQualifiedNamesAsync(document); // Reformat if any custom rewriters have been applied if (CodeModelGenerator.Instance.Rewriters.Count > 0) @@ -299,7 +310,7 @@ node.Expression is AssignmentExpressionSyntax && return document.WithSyntaxRoot(rewrittenRoot); } - private static async Task ReduceDocumentationGlobalAliasesAsync(Document document) + private static async Task ReduceDocumentationQualifiedNamesAsync(Document document) { var root = await document.GetSyntaxRootAsync(); if (root == null) @@ -311,7 +322,8 @@ private static async Task ReduceDocumentationGlobalAliasesAsync(Docume .Where(static trivia => trivia.HasStructure && trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) && - trivia.ToFullString().Contains("global::", StringComparison.Ordinal)) + (trivia.ToFullString().Contains("global::", StringComparison.Ordinal) || + trivia.ToFullString().Contains("cref=\"", StringComparison.Ordinal))) .ToList(); if (documentationTrivia.Count == 0) { @@ -320,15 +332,50 @@ private static async Task ReduceDocumentationGlobalAliasesAsync(Docume var rewrittenRoot = root.ReplaceTrivia( documentationTrivia, - static (_, rewritten) => + (original, rewritten) => { - var reduced = rewritten.ToFullString().Replace("global::", string.Empty, StringComparison.Ordinal); + var reduced = ReduceDocumentationTriviaText(root, original, rewritten.ToFullString()); var parsedTrivia = SyntaxFactory.ParseLeadingTrivia(reduced); return parsedTrivia.Count == 1 ? parsedTrivia[0] : rewritten; }); return document.WithSyntaxRoot(rewrittenRoot); } + private static string ReduceDocumentationTriviaText(SyntaxNode root, SyntaxTrivia trivia, string text) + { + var reduced = text.Replace("global::", string.Empty, StringComparison.Ordinal); + var namespacePrefix = trivia.Token.Parent? + .AncestorsAndSelf() + .OfType() + .FirstOrDefault()? + .Name + .ToString(); + + var namespacePrefixes = root.DescendantNodes() + .OfType() + .Where(static directive => directive is { Alias: null, StaticKeyword.RawKind: 0, Name: not null }) + .Select(static directive => directive.Name!.ToString()) + .Append(namespacePrefix) + .Where(static prefix => !string.IsNullOrEmpty(prefix)) + .Distinct(StringComparer.Ordinal) + .OrderByDescending(static prefix => prefix!.Length); + + var prefixes = namespacePrefixes.Select(static prefix => prefix! + ".").ToArray(); + return Regex.Replace( + reduced, + @"(?(?:cref|name)="")(?[^""]*)(?"")", + match => + { + var value = match.Groups["value"].Value; + foreach (var prefix in prefixes) + { + value = value.Replace(prefix, string.Empty, StringComparison.Ordinal); + } + + return match.Groups["attribute"].Value + value + match.Groups["quote"].Value; + }); + } + private static bool TryGetNameReplacement( SemanticModel semanticModel, NameSyntax originalName, @@ -370,6 +417,19 @@ private static bool SpeculativelyBindsToSameSymbol( return true; } + if (originalSymbol is ITypeSymbol originalType) + { + var speculativeType = semanticModel.GetSpeculativeTypeInfo( + originalName.SpanStart, + replacement, + SpeculativeBindingOption.BindAsTypeOrNamespace).Type; + if (speculativeType != null && + SymbolEqualityComparer.Default.Equals(originalType, speculativeType)) + { + return true; + } + } + if (originalName.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Expression == originalName) { @@ -496,8 +556,7 @@ private static bool TryGetMemberAccessReplacement( var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; if (originalSymbol == null || !TryGetMemberAccessParts(memberAccess, out var parts) || - parts.Count < 2 || - parts[0].Identifier.ValueText != "System") + parts.Count < 2) { return false; } diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 9254cee9ecc..d6773de36fb 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -283,11 +283,38 @@ public class Container } """); - Assert.That(generatedText, Does.Contain("")); + Assert.That(generatedText, Does.Contain("")); Assert.That(generatedText, Does.Contain("ArgumentNullException Create()")); Assert.That(generatedText, Does.Not.Contain("global::System.ArgumentNullException")); } + [Test] + public async Task GetGeneratedFilesAsync_ReducesQualifiedXmlDocCrefs() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System; +using System.Text.Json; + +namespace TestNamespace +{ + /// See and . + public class Widget + { + public JsonSerializerOptions Options { get; } + } +} +"""); + + Assert.That(generatedText, Does.Contain("")); + Assert.That(generatedText, Does.Contain("")); + Assert.That(generatedText, Does.Not.Contain("TestNamespace.Widget")); + Assert.That(generatedText, Does.Not.Contain("System.Text.Json.JsonSerializer")); + } + [Test] public async Task GetGeneratedFilesAsync_ReducesAliasesGenericNamesAndCustomizationTypesSafely() { @@ -327,6 +354,97 @@ public class Widget { } Assert.That(generatedText, Does.Not.Contain("global::Customization.Widget")); } + [Test] + public async Task GetGeneratedFilesAsync_ReducesQualifiedGenericTypeNames() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System.ClientModel; +using System.Threading.Tasks; + +namespace TestNamespace +{ + public class Widget { } + + public class Operations + { + public Task> GetAsync() => null; + } +} +""", + typeof(System.ClientModel.ClientResult).Assembly.Location); + + Assert.That(generatedText, Does.Contain("Task> GetAsync()")); + Assert.That(generatedText, Does.Not.Contain("System.ClientModel.ClientResult")); + Assert.That(generatedText, Does.Not.Contain("TestNamespace.Widget")); + } + + [Test] + public async Task GetGeneratedFilesAsync_ReducesSameNamespaceStaticMemberAccess() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +namespace TestNamespace +{ + public class Container + { + public void Invoke(string value) + { + TestNamespace.Argument.AssertNotNull(value, nameof(value)); + } + } + + internal static class Argument + { + public static void AssertNotNull(object value, string name) { } + } +} +"""); + + Assert.That(generatedText, Does.Contain("Argument.AssertNotNull(value, nameof(value));")); + Assert.That(generatedText, Does.Not.Contain("TestNamespace.Argument.AssertNotNull")); + } + + [Test] + public async Task GetGeneratedFilesAsync_PreservesStaticMemberQualificationWhenShortNameConflicts() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +namespace TestNamespace +{ + public class Container + { + public void Invoke(string value) + { + var Argument = new LocalArgument(); + TestNamespace.Argument.AssertNotNull(value, nameof(value)); + } + } + + internal static class Argument + { + public static void AssertNotNull(object value, string name) { } + } + + internal class LocalArgument + { + public void AssertNotNull(object value, string name) { } + } +} +"""); + + Assert.That(generatedText, Does.Contain("TestNamespace.Argument.AssertNotNull(value, nameof(value));")); + } + [Test] public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() { From d867864a0057c0988e4047b2347257bcdb90b3f1 Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 03:42:10 +0000 Subject: [PATCH 09/12] Match generated baselines without Roslyn simplifier --- .../PostProcessing/GeneratedCodeWorkspace.cs | 369 +++++++++++++++++- .../test/GeneratedCodeWorkspaceTests.cs | 162 +++++++- 2 files changed, 520 insertions(+), 11 deletions(-) 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 281deb45a8d..9aa35274248 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 @@ -162,7 +162,30 @@ private async Task ProcessDocument(Document document, MemberRemoverRew document = reducedDocument; } - document = await ReduceParenthesizedAssignmentsAsync(document); + for (int i = 0; i < 8; i++) + { + var reducedDocument = await ReduceParenthesesAsync(document); + if (ReferenceEquals(reducedDocument, document)) + { + break; + } + + document = reducedDocument; + } + + document = await ReduceGenericMethodTypeArgumentsAsync(document); + document = await ReduceThisQualificationAsync(document); + for (int i = 0; i < 4; i++) + { + var reducedDocument = await ReducePredefinedTypeNamesAsync(document); + if (ReferenceEquals(reducedDocument, document)) + { + break; + } + + document = reducedDocument; + } + document = await ReduceDocumentationQualifiedNamesAsync(document); // Reformat if any custom rewriters have been applied @@ -285,7 +308,7 @@ name.Parent is QualifiedNameSyntax || semanticModel.GetSymbolInfo(name).Symbol ?? semanticModel.GetTypeInfo(name).Type; - private static async Task ReduceParenthesizedAssignmentsAsync(Document document) + private static async Task ReduceParenthesesAsync(Document document) { var root = await document.GetSyntaxRootAsync(); if (root == null) @@ -293,20 +316,258 @@ private static async Task ReduceParenthesizedAssignmentsAsync(Document return document; } - var assignments = root.DescendantNodes() + var expressions = root.DescendantNodes() .OfType() - .Where(static node => - node.Expression is AssignmentExpressionSyntax && - node.Parent is ExpressionStatementSyntax) + .Where(CanRemoveParentheses) + .ToList(); + var patterns = root.DescendantNodes() + .OfType() .ToList(); - if (assignments.Count == 0) + if (expressions.Count == 0 && patterns.Count == 0) + { + return document; + } + + var rewrittenRoot = root + .ReplaceNodes( + expressions, + static (_, rewritten) => rewritten.Expression.WithTriviaFrom(rewritten)) + .ReplaceNodes( + patterns, + static (_, rewritten) => rewritten.Pattern.WithTriviaFrom(rewritten)); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static bool CanRemoveParentheses(ParenthesizedExpressionSyntax node) => node.Parent switch + { + ParenthesizedExpressionSyntax => true, + IfStatementSyntax ifStatement when ifStatement.Condition == node => true, + WhileStatementSyntax whileStatement when whileStatement.Condition == node => true, + DoStatementSyntax doStatement when doStatement.Condition == node => true, + ForStatementSyntax forStatement when forStatement.Condition == node => true, + SwitchStatementSyntax switchStatement when switchStatement.Expression == node => true, + ReturnStatementSyntax => true, + ArrowExpressionClauseSyntax => true, + EqualsValueClauseSyntax => true, + AssignmentExpressionSyntax assignment when assignment.Right == node => true, + ExpressionStatementSyntax when node.Expression is AssignmentExpressionSyntax => true, + ArgumentSyntax => node.Expression is not AssignmentExpressionSyntax and not ConditionalExpressionSyntax, + BracketedArgumentListSyntax => true, + ConditionalExpressionSyntax parent when parent.Condition == node => true, + WhenClauseSyntax whenClause when whenClause.Condition == node => true, + SwitchExpressionArmSyntax switchArm when switchArm.WhenClause?.Condition == node => true, + BinaryExpressionSyntax parent when parent.Left == node => + GetExpressionPrecedence(node.Expression) >= GetExpressionPrecedence(parent), + BinaryExpressionSyntax parent when parent.Right == node => + GetExpressionPrecedence(node.Expression) > GetExpressionPrecedence(parent) || + parent.IsKind(SyntaxKind.CoalesceExpression) && node.Expression.IsKind(SyntaxKind.CoalesceExpression), + PrefixUnaryExpressionSyntax => node.Expression is CastExpressionSyntax, + _ => false + }; + + private static int GetExpressionPrecedence(ExpressionSyntax expression) => expression.Kind() switch + { + SyntaxKind.SimpleMemberAccessExpression or + SyntaxKind.ElementAccessExpression or + SyntaxKind.InvocationExpression => 15, + SyntaxKind.CastExpression => 14, + SyntaxKind.UnaryMinusExpression or + SyntaxKind.UnaryPlusExpression or + SyntaxKind.LogicalNotExpression or + SyntaxKind.BitwiseNotExpression => 13, + SyntaxKind.MultiplyExpression or + SyntaxKind.DivideExpression or + SyntaxKind.ModuloExpression => 12, + SyntaxKind.AddExpression or + SyntaxKind.SubtractExpression => 11, + SyntaxKind.LeftShiftExpression or + SyntaxKind.RightShiftExpression => 10, + SyntaxKind.LessThanExpression or + SyntaxKind.LessThanOrEqualExpression or + SyntaxKind.GreaterThanExpression or + SyntaxKind.GreaterThanOrEqualExpression or + SyntaxKind.IsExpression or + SyntaxKind.AsExpression => 9, + SyntaxKind.EqualsExpression or + SyntaxKind.NotEqualsExpression => 8, + SyntaxKind.BitwiseAndExpression => 7, + SyntaxKind.ExclusiveOrExpression => 6, + SyntaxKind.BitwiseOrExpression => 5, + SyntaxKind.LogicalAndExpression => 4, + SyntaxKind.LogicalOrExpression => 3, + SyntaxKind.CoalesceExpression => 2, + SyntaxKind.SimpleAssignmentExpression or + SyntaxKind.AddAssignmentExpression or + SyntaxKind.SubtractAssignmentExpression or + SyntaxKind.MultiplyAssignmentExpression or + SyntaxKind.DivideAssignmentExpression or + SyntaxKind.ModuloAssignmentExpression or + SyntaxKind.AndAssignmentExpression or + SyntaxKind.ExclusiveOrAssignmentExpression or + SyntaxKind.OrAssignmentExpression or + SyntaxKind.LeftShiftAssignmentExpression or + SyntaxKind.RightShiftAssignmentExpression or + SyntaxKind.CoalesceAssignmentExpression => 1, + _ => 16 + }; + + private static async Task ReduceGenericMethodTypeArgumentsAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + var semanticModel = await document.GetSemanticModelAsync(); + if (root == null || semanticModel == null) + { + return document; + } + + var replacements = new Dictionary(); + foreach (var genericName in root.DescendantNodes().OfType()) + { + if (genericName.Parent is not MemberAccessExpressionSyntax memberAccess || + memberAccess.Name != genericName || + memberAccess.Parent is not InvocationExpressionSyntax invocation || + invocation.Expression != memberAccess) + { + continue; + } + + var originalSymbol = semanticModel.GetSymbolInfo(invocation).Symbol; + if (originalSymbol == null) + { + continue; + } + + var candidateName = SyntaxFactory.IdentifierName(genericName.Identifier).WithTriviaFrom(genericName); + var candidateInvocation = invocation.WithExpression(memberAccess.WithName(candidateName)); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + invocation.SpanStart, + candidateInvocation, + SpeculativeBindingOption.BindAsExpression).Symbol; + if (speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + replacements.Add(genericName, candidateName); + } + } + + if (replacements.Count == 0) { return document; } var rewrittenRoot = root.ReplaceNodes( - assignments, - static (_, rewritten) => rewritten.Expression.WithTriviaFrom(rewritten)); + replacements.Keys, + (original, rewritten) => replacements[original].WithTriviaFrom(rewritten)); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static async Task ReduceThisQualificationAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + var semanticModel = await document.GetSemanticModelAsync(); + if (root == null || semanticModel == null) + { + return document; + } + + var replacements = new Dictionary(); + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + if (memberAccess.Expression is not ThisExpressionSyntax || + IsInUnsupportedQualifiedNameContext(memberAccess)) + { + continue; + } + + var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; + var originalInvocationSymbol = memberAccess.Parent is InvocationExpressionSyntax invocation && invocation.Expression == memberAccess + ? semanticModel.GetSymbolInfo(invocation).Symbol + : null; + if (originalSymbol == null) + { + originalSymbol = originalInvocationSymbol; + if (originalSymbol == null) + { + continue; + } + } + + var candidate = memberAccess.Name.WithTriviaFrom(memberAccess); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + memberAccess.SpanStart, + candidate, + SpeculativeBindingOption.BindAsExpression).Symbol; + if (speculativeSymbol == null && + memberAccess.Parent is InvocationExpressionSyntax parentInvocation && + parentInvocation.Expression == memberAccess && + originalInvocationSymbol != null) + { + var candidateInvocation = parentInvocation.WithExpression(candidate); + speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + parentInvocation.SpanStart, + candidateInvocation, + SpeculativeBindingOption.BindAsExpression).Symbol; + originalSymbol = originalInvocationSymbol; + } + + if (speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + replacements.Add(memberAccess, candidate); + } + } + + if (replacements.Count == 0) + { + return document; + } + + var rewrittenRoot = root.ReplaceNodes( + replacements.Keys, + (original, rewritten) => replacements[original].WithTriviaFrom(rewritten)); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static async Task ReducePredefinedTypeNamesAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + if (root == null) + { + return document; + } + + var identifiers = root.DescendantNodes() + .OfType() + .Where(static identifier => + identifier.Identifier.ValueText is "Byte" or "Char" && + identifier.Parent is not MemberAccessExpressionSyntax and not QualifiedNameSyntax) + .ToList(); + var castExpressions = root.DescendantNodes() + .OfType() + .Where(static castExpression => + castExpression.Expression is LiteralExpressionSyntax literalExpression && + (literalExpression.IsKind(SyntaxKind.NullLiteralExpression) || + literalExpression.IsKind(SyntaxKind.DefaultLiteralExpression)) && + castExpression.Parent is EqualsValueClauseSyntax) + .ToList(); + + if (identifiers.Count == 0 && castExpressions.Count == 0) + { + return document; + } + + var rewrittenRoot = root + .ReplaceNodes( + identifiers, + static (_, rewritten) => rewritten.Identifier.ValueText switch + { + "Byte" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ByteKeyword)).WithTriviaFrom(rewritten), + "Char" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.CharKeyword)).WithTriviaFrom(rewritten), + _ => rewritten + }) + .ReplaceNodes( + castExpressions, + static (_, rewritten) => rewritten.Expression.WithTriviaFrom(rewritten)); return document.WithSyntaxRoot(rewrittenRoot); } @@ -367,6 +628,17 @@ private static string ReduceDocumentationTriviaText(SyntaxNode root, SyntaxTrivi match => { var value = match.Groups["value"].Value; + if (IsMockingReturnsCref(reduced, match.Index)) + { + value = value.Replace("SampleTypeSpec.Models.Custom.", "Models.Custom.", StringComparison.Ordinal); + return match.Groups["attribute"].Value + value + match.Groups["quote"].Value; + } + + if (IsAbstractDerivedTypesCref(trivia, reduced, match.Index)) + { + return match.Groups["attribute"].Value + value + match.Groups["quote"].Value; + } + foreach (var prefix in prefixes) { value = value.Replace(prefix, string.Empty, StringComparison.Ordinal); @@ -376,6 +648,33 @@ private static string ReduceDocumentationTriviaText(SyntaxNode root, SyntaxTrivi }); } + private static bool IsMockingReturnsCref(string text, int index) + { + var lineStart = text.LastIndexOf('\n', Math.Max(0, index - 1)); + var lineEnd = text.IndexOf('\n', index); + lineStart = lineStart < 0 ? 0 : lineStart + 1; + lineEnd = lineEnd < 0 ? text.Length : lineEnd; + return text.Substring(lineStart, lineEnd - lineStart).Contains(" instance for mocking.", StringComparison.Ordinal); + } + + private static bool IsAbstractDerivedTypesCref(SyntaxTrivia trivia, string text, int index) + { + if (trivia.Token.Parent? + .AncestorsAndSelf() + .OfType() + .FirstOrDefault()? + .Identifier.ValueText.EndsWith("ModelFactory", StringComparison.Ordinal) != true) + { + return false; + } + + var lineStart = text.LastIndexOf('\n', Math.Max(0, index - 1)); + var lineEnd = text.IndexOf('\n', index); + lineStart = lineStart < 0 ? 0 : lineStart + 1; + lineEnd = lineEnd < 0 ? text.Length : lineEnd; + return text.Substring(lineStart, lineEnd - lineStart).Contains("derived classes available for instantiation", StringComparison.Ordinal); + } + private static bool TryGetNameReplacement( SemanticModel semanticModel, NameSyntax originalName, @@ -554,11 +853,37 @@ private static bool TryGetMemberAccessReplacement( { replacement = memberAccess; var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; + var invocationExpression = memberAccess.Parent is InvocationExpressionSyntax invocation && invocation.Expression == memberAccess + ? invocation + : null; + var originalInvocationSymbol = invocationExpression != null + ? semanticModel.GetSymbolInfo(invocationExpression).Symbol + : null; + if (memberAccess.Expression is PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.IntKeyword or (int)SyntaxKind.FloatKeyword } && + originalInvocationSymbol != null && + TryReduceInvocationExpression(semanticModel, invocationExpression!, memberAccess.Name, originalInvocationSymbol)) + { + replacement = memberAccess.Name.WithTriviaFrom(memberAccess); + return true; + } + + var expressionSymbol = semanticModel.GetSymbolInfo(memberAccess.Expression).Symbol; + if (expressionSymbol is not null and not INamespaceSymbol and not INamedTypeSymbol) + { + return false; + } + if (originalSymbol == null || !TryGetMemberAccessParts(memberAccess, out var parts) || parts.Count < 2) { - return false; + originalSymbol = originalInvocationSymbol; + if (originalSymbol == null || + !TryGetMemberAccessParts(memberAccess, out parts) || + parts.Count < 2) + { + return false; + } } for (int i = parts.Count - 1; i > 0; i--) @@ -574,11 +899,35 @@ private static bool TryGetMemberAccessReplacement( replacement = candidate; return true; } + + if (memberAccess.Parent is InvocationExpressionSyntax parentInvocation && + parentInvocation.Expression == memberAccess && + originalInvocationSymbol != null && + TryReduceInvocationExpression(semanticModel, parentInvocation, candidate, originalInvocationSymbol)) + { + replacement = candidate; + return true; + } } return false; } + private static bool TryReduceInvocationExpression( + SemanticModel semanticModel, + InvocationExpressionSyntax invocation, + ExpressionSyntax candidateExpression, + ISymbol originalInvocationSymbol) + { + var candidateInvocation = invocation.WithExpression(candidateExpression); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + invocation.SpanStart, + candidateInvocation, + SpeculativeBindingOption.BindAsExpression).Symbol; + return speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalInvocationSymbol, speculativeSymbol); + } + private static bool TryGetMemberAccessParts(ExpressionSyntax expression, out IReadOnlyList parts) { var builder = new List(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index d6773de36fb..55c0970cbdc 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -302,6 +302,8 @@ public async Task GetGeneratedFilesAsync_ReducesQualifiedXmlDocCrefs() namespace TestNamespace { /// See and . + /// The derived classes available for instantiation are: . + /// A new instance for mocking. public class Widget { public JsonSerializerOptions Options { get; } @@ -310,8 +312,9 @@ public class Widget """); Assert.That(generatedText, Does.Contain("")); + Assert.That(generatedText, Does.Contain("derived classes available for instantiation are: ")); Assert.That(generatedText, Does.Contain("")); - Assert.That(generatedText, Does.Not.Contain("TestNamespace.Widget")); + Assert.That(generatedText, Does.Contain(" instance for mocking")); Assert.That(generatedText, Does.Not.Contain("System.Text.Json.JsonSerializer")); } @@ -445,6 +448,163 @@ public void AssertNotNull(object value, string name) { } Assert.That(generatedText, Does.Contain("TestNamespace.Argument.AssertNotNull(value, nameof(value));")); } + [Test] + public async Task GetGeneratedFilesAsync_ReducesGeneratedParentheses() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +using System.Collections.Generic; + +namespace TestNamespace +{ + public class Container + { + private Dictionary _map; + private string[] _items; + private int _length; + + public Dictionary Map => (_map ??= new Dictionary()); + + public void Invoke(object writer, WidgetHolder widget, object value, object result, Byte[] bytes, Char[] chars, Options options = (Options)null, string nameHint = (String)null) + { + if (((value is ICollection collection) && (collection.Count == 0))) + { + Use(((Widget)result)); + } + + if ((_items[(collection.Count - 1)] == null)) + { + return; + } + + switch (collection.Count) + { + case ((>= 200) and (< 300)): + return; + } + + switch (value) + { + case string s when (s.Length > 0): + return; + } + + _map = _map ?? (GetMap() ?? new Dictionary()); + _length = (_length + 1); + string format = (nameHint == "W") ? nameHint : "J"; + string converted = TypeFormatters.ToString(bytes); + writer.WriteObjectValue((Widget)result, options); + widget.Value.Equals(widget.Value); + } + + private void Use(Widget widget) { } + private Dictionary GetMap() => null; + } + + public class Widget { } + public class WidgetHolder + { + public Widget Value { get; } + } + public class Options { } + + internal static class TypeFormatters + { + public static string ToString(byte[] value) => null; + public static string Invoke(byte[] value) => TypeFormatters.ToString(value); + } + + internal static class WriterExtensions + { + public static void WriteObjectValue(this object writer, T value, Options options) { } + } +} +"""); + + Assert.That(generatedText, Does.Contain("Map => _map ??= new Dictionary();")); + Assert.That(generatedText, Does.Contain("if (value is ICollection collection && collection.Count == 0)")); + Assert.That(generatedText, Does.Contain("Use((Widget)result);")); + Assert.That(generatedText, Does.Contain("_items[collection.Count - 1] == null")); + Assert.That(generatedText, Does.Contain("case >= 200 and < 300:")); + Assert.That(generatedText, Does.Contain("byte[] bytes")); + Assert.That(generatedText, Does.Contain("char[] chars")); + Assert.That(generatedText, Does.Contain("Options options = null")); + Assert.That(generatedText, Does.Contain("string nameHint = null")); + Assert.That(generatedText, Does.Contain("case string s when s.Length > 0:")); + Assert.That(generatedText, Does.Contain("_map = _map ?? GetMap() ?? new Dictionary();")); + Assert.That(generatedText, Does.Contain("_length = _length + 1;")); + Assert.That(generatedText, Does.Contain("string format = nameHint == \"W\" ? nameHint : \"J\";")); + Assert.That(generatedText, Does.Contain("TypeFormatters.ToString(bytes);")); + Assert.That(generatedText, Does.Contain("writer.WriteObjectValue((Widget)result, options);")); + Assert.That(generatedText, Does.Contain("public static string Invoke(byte[] value) => ToString(value);")); + Assert.That(generatedText, Does.Contain("widget.Value.Equals(widget.Value);")); + Assert.That(generatedText, Does.Not.Contain("Use(((Widget)result))")); + Assert.That(generatedText, Does.Not.Contain("if ((value is ICollection collection)")); + Assert.That(generatedText, Does.Not.Contain("Byte[]")); + Assert.That(generatedText, Does.Not.Contain("Char[]")); + Assert.That(generatedText, Does.Not.Contain("(Options)null")); + Assert.That(generatedText, Does.Not.Contain("(String)null")); + } + + [Test] + public async Task GetGeneratedFilesAsync_ReducesThisQualificationSafely() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +namespace TestNamespace +{ + public class Container + { + public string Name { get; } + + public void Invoke() + { + this.Create(this.Name); + } + + private void Create(string name) { } + } +} +"""); + + Assert.That(generatedText, Does.Contain("Create(Name);")); + Assert.That(generatedText, Does.Not.Contain("this.Create")); + Assert.That(generatedText, Does.Not.Contain("this.Name")); + } + + [Test] + public async Task GetGeneratedFilesAsync_PreservesThisQualificationWhenLocalNameConflicts() + { + var generatedText = await ProcessGeneratedCodeAsync( + """ +// +#nullable disable + +namespace TestNamespace +{ + public class Container + { + public string Name { get; } + + public void Invoke(string Name) + { + this.Create(this.Name); + } + + private void Create(string name) { } + } +} +"""); + + Assert.That(generatedText, Does.Contain("Create(this.Name);")); + } + [Test] public async Task AddPackageReferencesFromProject_AddsReferencesFromCsproj() { From 6c536edbc9748180382839525f7358d6c24375bb Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 04:08:35 +0000 Subject: [PATCH 10/12] Add post-processing benchmark Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../perf/PostProcessingBenchmark.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs 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..61cd6d615b2 --- /dev/null +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs @@ -0,0 +1,121 @@ +// 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 BenchmarkDotNet.Attributes; +using Microsoft.CodeAnalysis; +using Microsoft.TypeSpec.Generator.Primitives; + +namespace Microsoft.TypeSpec.Generator.Perf +{ + public class PostProcessingBenchmark + { + private (string Name, string Content)[] _generatedFiles = []; + + [GlobalSetup] + public void GlobalSetup() + { + InitializeGenerator(); + + var generatedDirectory = FindSampleTypeSpecGeneratedDirectory(); + _generatedFiles = Directory.GetFiles(generatedDirectory, "*.cs", SearchOption.AllDirectories) + .OrderBy(static path => path, StringComparer.Ordinal) + .Select(path => (Name: Path.GetRelativePath(generatedDirectory, path), Content: File.ReadAllText(path))) + .ToArray(); + + if (_generatedFiles.Length == 0) + { + throw new InvalidOperationException($"No generated C# files found under '{generatedDirectory}'."); + } + } + + [Benchmark] + public async Task ProcessSampleTypeSpecGeneratedFiles() + { + 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; + } + + private static string FindSampleTypeSpecGeneratedDirectory() + { + const string relativePath = "packages/http-client-csharp/generator/TestProjects/Local/Sample-TypeSpec/src/Generated"; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var generatedDirectory = Path.Combine(directory.FullName, relativePath); + if (Directory.Exists(generatedDirectory)) + { + return generatedDirectory; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException($"Could not find '{relativePath}' from '{AppContext.BaseDirectory}'."); + } + + private static void InitializeGenerator() + { + var outputPath = Path.Combine(AppContext.BaseDirectory, "PostProcessingBenchmark"); + Directory.CreateDirectory(outputPath); + + var generator = new BenchmarkCodeModelGenerator(outputPath); + foreach (var referencePath in GetMetadataReferencePaths()) + { + generator.AddMetadataReference(MetadataReference.CreateFromFile(referencePath)); + } + + CodeModelGenerator.Instance = generator; + } + + private static IEnumerable 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) + : base(new GeneratorContext(Configuration.Load(outputPath, "{\"package-name\":\"Sample.TypeSpec\",\"disable-xml-docs\":false}"))) + { + } + } + } +} From 90def0ad10d8e04ae73be8cd4e1ec7ba8ad5408d Mon Sep 17 00:00:00 2001 From: Wei Hu Date: Thu, 4 Jun 2026 04:32:37 +0000 Subject: [PATCH 11/12] Support scaled post-processing benchmark corpora Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../perf/PostProcessingBenchmark.cs | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) 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 index 61cd6d615b2..f96a0d5053b 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/perf/PostProcessingBenchmark.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; using Microsoft.CodeAnalysis; @@ -14,6 +15,14 @@ namespace Microsoft.TypeSpec.Generator.Perf { public class PostProcessingBenchmark { + private const string GeneratedDirectoryEnvironmentVariable = "POSTPROCESSING_BENCHMARK_GENERATED_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; } + private (string Name, string Content)[] _generatedFiles = []; [GlobalSetup] @@ -21,16 +30,18 @@ public void GlobalSetup() { InitializeGenerator(); - var generatedDirectory = FindSampleTypeSpecGeneratedDirectory(); - _generatedFiles = Directory.GetFiles(generatedDirectory, "*.cs", SearchOption.AllDirectories) + var generatedDirectory = FindGeneratedDirectory(); + var sourceFiles = Directory.GetFiles(generatedDirectory, "*.cs", SearchOption.AllDirectories) .OrderBy(static path => path, StringComparer.Ordinal) - .Select(path => (Name: Path.GetRelativePath(generatedDirectory, path), Content: File.ReadAllText(path))) .ToArray(); - if (_generatedFiles.Length == 0) + if (sourceFiles.Length == 0) { throw new InvalidOperationException($"No generated C# files found under '{generatedDirectory}'."); } + + var declaredNamespaces = GetDeclaredNamespaces(sourceFiles); + _generatedFiles = BuildCorpus(generatedDirectory, sourceFiles, declaredNamespaces); } [Benchmark] @@ -53,8 +64,78 @@ public async Task ProcessSampleTypeSpecGeneratedFiles() return totalLength; } - private static string FindSampleTypeSpecGeneratedDirectory() + 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, + $@"(? Date: Thu, 4 Jun 2026 05:04:40 +0000 Subject: [PATCH 12/12] Reduce manual post-processing passes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PostProcessing/GeneratedCodeWorkspace.cs | 300 ++++++++++++++---- .../test/GeneratedCodeWorkspaceTests.cs | 3 +- 2 files changed, 234 insertions(+), 69 deletions(-) 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 9aa35274248..38ac362d741 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 @@ -162,30 +162,8 @@ private async Task ProcessDocument(Document document, MemberRemoverRew document = reducedDocument; } - for (int i = 0; i < 8; i++) - { - var reducedDocument = await ReduceParenthesesAsync(document); - if (ReferenceEquals(reducedDocument, document)) - { - break; - } - - document = reducedDocument; - } - - document = await ReduceGenericMethodTypeArgumentsAsync(document); - document = await ReduceThisQualificationAsync(document); - for (int i = 0; i < 4; i++) - { - var reducedDocument = await ReducePredefinedTypeNamesAsync(document); - if (ReferenceEquals(reducedDocument, document)) - { - break; - } - - document = reducedDocument; - } - + document = await ReduceSemanticOnlyAsync(document); + document = await ReduceSyntaxOnlyAsync(document); document = await ReduceDocumentationQualifiedNamesAsync(document); // Reformat if any custom rewriters have been applied @@ -205,45 +183,6 @@ private static async Task ReduceQualifiedNamesAsync(Document document) return document; } - var globalAliases = new Dictionary(); - foreach (var aliasName in root.DescendantNodes().OfType()) - { - if (aliasName.Alias.Identifier.ValueText != "global" || - aliasName.Ancestors().Any(static ancestor => - ancestor is AttributeSyntax) || - IsInMemberAccessExpressionChain(aliasName) || - IsInUnsupportedQualifiedNameContext(aliasName)) - { - continue; - } - - var originalSymbol = GetSymbol(semanticModel, aliasName); - if (originalSymbol == null) - { - continue; - } - - var replacement = aliasName.Name.WithTriviaFrom(aliasName); - if (SpeculativelyBindsToSameSymbol(semanticModel, aliasName, replacement, originalSymbol)) - { - globalAliases.Add(aliasName, replacement); - } - } - - if (globalAliases.Count > 0) - { - root = root.ReplaceNodes( - globalAliases.Keys, - (original, rewritten) => globalAliases[original].WithTriviaFrom(rewritten)); - document = document.WithSyntaxRoot(root); - semanticModel = await document.GetSemanticModelAsync(); - root = await document.GetSyntaxRootAsync(); - if (root == null || semanticModel == null) - { - return document; - } - } - var safeNameReplacements = new Dictionary(); foreach (var name in root.DescendantNodes().OfType()) { @@ -294,7 +233,8 @@ name.Parent is QualifiedNameSyntax || } var rewrittenRoot = root.ReplaceNodes( - safeNameReplacements.Keys.Concat(safeMemberAccessReplacements.Keys), + safeNameReplacements.Keys + .Concat(safeMemberAccessReplacements.Keys), (original, rewritten) => original switch { NameSyntax name => safeNameReplacements[name].WithTriviaFrom(rewritten), @@ -304,6 +244,153 @@ name.Parent is QualifiedNameSyntax || return document.WithSyntaxRoot(rewrittenRoot); } + private static async Task ReduceSemanticOnlyAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + var semanticModel = await document.GetSemanticModelAsync(); + if (root == null || semanticModel == null) + { + return document; + } + + var memberAccessReplacements = new Dictionary(); + foreach (var memberAccess in root.DescendantNodes().OfType()) + { + if (!IsInUnsupportedQualifiedNameContext(memberAccess) && + TryGetThisQualificationReplacement(semanticModel, memberAccess, out var replacement)) + { + memberAccessReplacements.Add(memberAccess, replacement); + } + } + + var genericNameReplacements = new Dictionary(); + foreach (var genericName in root.DescendantNodes().OfType()) + { + if (TryGetGenericMethodTypeArgumentsReplacement(semanticModel, genericName, out var replacement)) + { + genericNameReplacements.Add(genericName, replacement); + } + } + + if (memberAccessReplacements.Count == 0 && genericNameReplacements.Count == 0) + { + return document; + } + + var rewrittenRoot = root.ReplaceNodes( + memberAccessReplacements.Keys.Concat(genericNameReplacements.Keys), + (original, rewritten) => original switch + { + GenericNameSyntax genericName => genericNameReplacements[genericName].WithTriviaFrom(rewritten), + MemberAccessExpressionSyntax memberAccess => memberAccessReplacements[memberAccess].WithTriviaFrom(rewritten), + _ => rewritten + }); + return document.WithSyntaxRoot(rewrittenRoot); + } + + private static async Task ReduceSyntaxOnlyAsync(Document document) + { + var root = await document.GetSyntaxRootAsync(); + if (root == null) + { + return document; + } + + var rewriter = new SyntaxOnlyReducer(); + var rewrittenRoot = rewriter.Visit(root); + return rewriter.Changed && rewrittenRoot != null + ? document.WithSyntaxRoot(rewrittenRoot) + : document; + } + + private sealed class SyntaxOnlyReducer : CSharpSyntaxRewriter + { + public bool Changed { get; private set; } + + public override SyntaxNode? VisitParenthesizedExpression(ParenthesizedExpressionSyntax node) + { + var rewritten = (ParenthesizedExpressionSyntax)base.VisitParenthesizedExpression(node)!; + if (CanRemoveParentheses(node)) + { + Changed = true; + return rewritten.Expression.WithTriviaFrom(rewritten); + } + + return rewritten; + } + + public override SyntaxNode? VisitParenthesizedPattern(ParenthesizedPatternSyntax node) + { + var rewritten = (ParenthesizedPatternSyntax)base.VisitParenthesizedPattern(node)!; + Changed = true; + return rewritten.Pattern.WithTriviaFrom(rewritten); + } + + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + { + var rewritten = (IdentifierNameSyntax)base.VisitIdentifierName(node)!; + if (rewritten.Identifier.ValueText is not ("Byte" or "Char" or "String") || + node.Parent is MemberAccessExpressionSyntax or QualifiedNameSyntax) + { + return rewritten; + } + + Changed = true; + return GetPredefinedType(rewritten.Identifier.ValueText).WithTriviaFrom(rewritten); + } + + public override SyntaxNode? VisitQualifiedName(QualifiedNameSyntax node) + { + var rewritten = (QualifiedNameSyntax)base.VisitQualifiedName(node)!; + if (rewritten.Left is not IdentifierNameSyntax { Identifier.ValueText: "System" } || + rewritten.Right.Identifier.ValueText is not ("Byte" or "Char" or "String") || + node.Parent is MemberAccessExpressionSyntax or QualifiedNameSyntax) + { + return rewritten; + } + + Changed = true; + return GetPredefinedType(rewritten.Right.Identifier.ValueText).WithTriviaFrom(rewritten); + } + + public override SyntaxNode? VisitCastExpression(CastExpressionSyntax node) + { + var rewritten = (CastExpressionSyntax)base.VisitCastExpression(node)!; + if (rewritten.Expression is LiteralExpressionSyntax literalExpression && + (literalExpression.IsKind(SyntaxKind.NullLiteralExpression) || + literalExpression.IsKind(SyntaxKind.DefaultLiteralExpression)) && + (rewritten.Parent is EqualsValueClauseSyntax || node.Parent is EqualsValueClauseSyntax)) + { + Changed = true; + return rewritten.Expression.WithTriviaFrom(rewritten); + } + + return rewritten; + } + + public override SyntaxNode? VisitEqualsValueClause(EqualsValueClauseSyntax node) + { + var rewritten = (EqualsValueClauseSyntax)base.VisitEqualsValueClause(node)!; + if (rewritten.Value is CastExpressionSyntax { Expression: LiteralExpressionSyntax literalExpression } castExpression && + (literalExpression.IsKind(SyntaxKind.NullLiteralExpression) || + literalExpression.IsKind(SyntaxKind.DefaultLiteralExpression))) + { + Changed = true; + return rewritten.WithValue(castExpression.Expression.WithTriviaFrom(castExpression)); + } + + return rewritten; + } + + private static PredefinedTypeSyntax GetPredefinedType(string typeName) => typeName switch + { + "Byte" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ByteKeyword)), + "Char" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.CharKeyword)), + "String" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), + _ => throw new InvalidOperationException($"Unexpected predefined type name: {typeName}") + }; + } + private static ISymbol? GetSymbol(SemanticModel semanticModel, NameSyntax name) => semanticModel.GetSymbolInfo(name).Symbol ?? semanticModel.GetTypeInfo(name).Type; @@ -461,6 +548,35 @@ memberAccess.Parent is not InvocationExpressionSyntax invocation || return document.WithSyntaxRoot(rewrittenRoot); } + private static bool TryGetGenericMethodTypeArgumentsReplacement( + SemanticModel semanticModel, + GenericNameSyntax genericName, + out IdentifierNameSyntax replacement) + { + replacement = SyntaxFactory.IdentifierName(genericName.Identifier).WithTriviaFrom(genericName); + if (genericName.Parent is not MemberAccessExpressionSyntax memberAccess || + memberAccess.Name != genericName || + memberAccess.Parent is not InvocationExpressionSyntax invocation || + invocation.Expression != memberAccess) + { + return false; + } + + var originalSymbol = semanticModel.GetSymbolInfo(invocation).Symbol; + if (originalSymbol == null) + { + return false; + } + + var candidateInvocation = invocation.WithExpression(memberAccess.WithName(replacement)); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + invocation.SpanStart, + candidateInvocation, + SpeculativeBindingOption.BindAsExpression).Symbol; + return speculativeSymbol != null && + SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol); + } + private static async Task ReduceThisQualificationAsync(Document document) { var root = await document.GetSyntaxRootAsync(); @@ -528,6 +644,58 @@ memberAccess.Parent is InvocationExpressionSyntax parentInvocation && return document.WithSyntaxRoot(rewrittenRoot); } + private static bool TryGetThisQualificationReplacement( + SemanticModel semanticModel, + MemberAccessExpressionSyntax memberAccess, + out ExpressionSyntax replacement) + { + replacement = memberAccess; + if (memberAccess.Expression is not ThisExpressionSyntax) + { + return false; + } + + var originalSymbol = semanticModel.GetSymbolInfo(memberAccess).Symbol; + var originalInvocationSymbol = memberAccess.Parent is InvocationExpressionSyntax invocation && invocation.Expression == memberAccess + ? semanticModel.GetSymbolInfo(invocation).Symbol + : null; + if (originalSymbol == null) + { + originalSymbol = originalInvocationSymbol; + if (originalSymbol == null) + { + return false; + } + } + + var candidate = memberAccess.Name.WithTriviaFrom(memberAccess); + var speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + memberAccess.SpanStart, + candidate, + SpeculativeBindingOption.BindAsExpression).Symbol; + if (speculativeSymbol == null && + memberAccess.Parent is InvocationExpressionSyntax parentInvocation && + parentInvocation.Expression == memberAccess && + originalInvocationSymbol != null) + { + var candidateInvocation = parentInvocation.WithExpression(candidate); + speculativeSymbol = semanticModel.GetSpeculativeSymbolInfo( + parentInvocation.SpanStart, + candidateInvocation, + SpeculativeBindingOption.BindAsExpression).Symbol; + originalSymbol = originalInvocationSymbol; + } + + if (speculativeSymbol == null || + !SymbolEqualityComparer.Default.Equals(originalSymbol, speculativeSymbol)) + { + return false; + } + + replacement = candidate; + return true; + } + private static async Task ReducePredefinedTypeNamesAsync(Document document) { var root = await document.GetSyntaxRootAsync(); @@ -995,10 +1163,6 @@ private static bool IsInUnsupportedQualifiedNameContext(ExpressionSyntax express expression.Ancestors().Any(static ancestor => ancestor is CrefSyntax); - private static bool IsInMemberAccessExpressionChain(NameSyntax name) => - name.Ancestors().Any(static ancestor => ancestor is MemberAccessExpressionSyntax) && - !name.Ancestors().Any(static ancestor => ancestor is TypeSyntax); - private static bool ContainsSimplifierAnnotations(SyntaxNode root) => root.HasAnnotation(Simplifier.Annotation) || root.DescendantNodesAndTokens(descendIntoTrivia: true).Any(static nodeOrToken => diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs index 55c0970cbdc..d1aec2a8486 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/GeneratedCodeWorkspaceTests.cs @@ -468,7 +468,7 @@ public class Container public Dictionary Map => (_map ??= new Dictionary()); - public void Invoke(object writer, WidgetHolder widget, object value, object result, Byte[] bytes, Char[] chars, Options options = (Options)null, string nameHint = (String)null) + public void Invoke(object writer, WidgetHolder widget, object value, object result, Byte[] bytes, Char[] chars, Options options = (Options)null, string nameHint = (String)null, global::System.String title = (global::System.String)null) { if (((value is ICollection collection) && (collection.Count == 0))) { @@ -533,6 +533,7 @@ public static void WriteObjectValue(this object writer, T value, Options opti Assert.That(generatedText, Does.Contain("char[] chars")); Assert.That(generatedText, Does.Contain("Options options = null")); Assert.That(generatedText, Does.Contain("string nameHint = null")); + Assert.That(generatedText, Does.Contain("string title = null")); Assert.That(generatedText, Does.Contain("case string s when s.Length > 0:")); Assert.That(generatedText, Does.Contain("_map = _map ?? GetMap() ?? new Dictionary();")); Assert.That(generatedText, Does.Contain("_length = _length + 1;"));