From 5d876f703db39c5c8c974ecf61704ffbc54b8556 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Thu, 6 Aug 2026 23:18:15 -0700 Subject: [PATCH 01/18] Harden C# type-name shadow resolution Plan namespace and lexical shadowing across complete output units, preserve qualified identity when imports are unsafe, and emit global aliases when ordinary qualification would rebind. Add formatter, type-printer, nested-scope, primary-constructor, attribute, delegate, and compile-back regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpFormatterTests.cs | 350 ++++++++++ .../CSharpTypePrinterTests.cs | 623 ++++++++++++++++++ .../CSharpDeclarationWriter.cs | 520 ++++++++++++--- src/ILInspector.CSharp/CSharpFormatter.cs | 52 +- src/ILInspector.CSharp/CSharpTypePrinter.cs | 139 +++- .../ReturnToSenderPrototypeTests.cs | 40 ++ 6 files changed, 1570 insertions(+), 154 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index 5a24a5a524..47a626d53f 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -112,6 +112,356 @@ public void TypeNamePolicyAppliesToIndividualMemberDeclarations( Assert.Equal(expectsGeneratedUsing, declaration.Usings.Contains("System.Threading.Tasks")); } + [Fact] + public void ShortWithUsingsDerivesAlongsideCallerImports() + { + var type = new ApiType { Namespace = "Samples", Name = "Worker", Kind = "class" }; + var member = new ApiMember + { + Name = "CreateTimer", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Windows.Forms.Timer", + MemberName = "CreateTimer" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + Usings = ["System.Threading"] + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public Timer CreateTimer()", + declaration.Text, + StringComparison.Ordinal); + Assert.Equal(["System.Windows.Forms"], declaration.Usings); + } + + [Theory] + [InlineData(CSharpTypeNamePolicy.ShortWithUsings)] + [InlineData(CSharpTypeNamePolicy.ContextualShort)] + public void DeclaredTypeNameKeepsCrossNamespaceReferenceQualified( + CSharpTypeNamePolicy policy) + { + var type = new ApiType { Namespace = "Samples", Name = "Task", Kind = "class" }; + var member = new ApiMember + { + Name = "Run", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Threading.Tasks.Task", + MemberName = "Run" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = policy, + Usings = policy == CSharpTypeNamePolicy.ContextualShort + ? ["System.Threading.Tasks"] + : [] + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public System.Threading.Tasks.Task Run()", + declaration.Text, + StringComparison.Ordinal); + Assert.Empty(declaration.Usings); + } + + [Fact] + public void NamespaceSegmentKeepsSameNamedReferenceQualified() + { + var type = new ApiType { Namespace = "Samples.Models", Name = "Worker", Kind = "class" }; + var member = new ApiMember + { + Name = "Get", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "External.Models", + MemberName = "Get" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public External.Models Get()", + declaration.Text, + StringComparison.Ordinal); + Assert.Empty(declaration.Usings); + } + + [Fact] + public void KnownNamespaceRootKeepsSameNamedReferenceQualified() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class", + Members = + [ + new ApiMember + { + Name = "GetWidget", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.Beta.Widget", + MemberName = "GetWidget" + } + }, + new ApiMember + { + Name = "GetAlpha", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Zeta.Alpha", + MemberName = "GetAlpha" + } + } + ] + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatTypeUnit(type, type.Members); + + Assert.Contains("public Widget GetWidget();", declaration.Text, StringComparison.Ordinal); + Assert.Contains("public Zeta.Alpha GetAlpha();", declaration.Text, StringComparison.Ordinal); + Assert.Equal(["Alpha.Beta"], declaration.Usings); + } + + [Fact] + public void EnclosingNamespaceChildKeepsSameNamedReferenceQualified() + { + var type = new ApiType + { + Namespace = "Alpha.Beta", + Name = "Worker", + Kind = "class", + Members = + [ + new ApiMember + { + Name = "GetThing", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.Gamma.Thing", + MemberName = "GetThing" + } + }, + new ApiMember + { + Name = "GetGamma", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Other.Gamma", + MemberName = "GetGamma" + } + } + ] + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatTypeUnit(type, type.Members); + + Assert.Contains("public Thing GetThing();", declaration.Text, StringComparison.Ordinal); + Assert.Contains("public Other.Gamma GetGamma();", declaration.Text, StringComparison.Ordinal); + Assert.Equal(["Alpha.Gamma"], declaration.Usings); + } + + [Fact] + public void UnrelatedNamespaceChildDoesNotShadowSameNamedReference() + { + var type = new ApiType + { + Namespace = "Alpha.Beta", + Name = "Worker", + Kind = "class", + Members = + [ + new ApiMember + { + Name = "GetThing", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Zeta.Delta.Thing", + MemberName = "GetThing" + } + }, + new ApiMember + { + Name = "GetDelta", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Other.Delta", + MemberName = "GetDelta" + } + } + ] + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatTypeUnit(type, type.Members); + + Assert.Contains("public Thing GetThing();", declaration.Text, StringComparison.Ordinal); + Assert.Contains("public Delta GetDelta();", declaration.Text, StringComparison.Ordinal); + Assert.Equal( + ["Other", "Zeta.Delta"], + declaration.Usings.Order(StringComparer.Ordinal)); + } + + [Fact] + public void ContainingNamespaceChildShadowedRootUsesGlobalAlias() + { + var type = new ApiType { Namespace = "Alpha.System", Name = "Worker", Kind = "class" }; + var member = new ApiMember + { + Name = "GetUri", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Uri", + MemberName = "GetUri" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public global::System.Uri GetUri()", + declaration.Text, + StringComparison.Ordinal); + } + + [Fact] + public void ContainingNamespaceRootDoesNotRequireGlobalAlias() + { + var type = new ApiType { Namespace = "System.Example", Name = "Worker", Kind = "class" }; + var member = new ApiMember + { + Name = "GetUri", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Uri", + MemberName = "GetUri" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + ContainingNamespace = type.Namespace + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains("public System.Uri GetUri()", declaration.Text, StringComparison.Ordinal); + Assert.DoesNotContain("global::System.Uri", declaration.Text, StringComparison.Ordinal); + } + + [Fact] + public void QualifiedPolicyUsesGlobalAliasWhenNamespaceRootIsShadowed() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker`1", + Kind = "class", + TypeParameters = [new TypeParameter { Name = "System" }] + }; + var member = new ApiMember + { + Name = "Run", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Threading.Tasks.Task", + MemberName = "Run" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public global::System.Threading.Tasks.Task Run()", + declaration.Text, + StringComparison.Ordinal); + } + + [Fact] + public void QualifiedPolicyEscapesShadowedKeywordNamespaceRoot() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker`1", + Kind = "class", + TypeParameters = [new TypeParameter { Name = "event" }] + }; + var member = new ApiMember + { + Name = "GetWidget", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "event.Models.Widget", + MemberName = "GetWidget" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public global::@event.Models.Widget GetWidget()", + declaration.Text, + StringComparison.Ordinal); + Assert.DoesNotContain("@global::", declaration.Text, StringComparison.Ordinal); + } + [Fact] public void FormatsParameterListsWithAttributesDefaultsAndEscapedKeywords() { diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 8ec8c1c6bf..2bac1243e0 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -726,6 +726,40 @@ public void DerivedUsingShortensCrossNamespaceReference() Assert.Contains("public Task Run();", result.Units[0].Source, StringComparison.Ordinal); } + [Fact] + public void DerivationUsesOnlySelectedMembers() + { + var type = CreateEmptyType("Samples", "Worker"); + var selected = CreateMethod("Open"); + selected.SignatureModel!.ReturnType = "System.IO.Stream"; + var omitted = CreateMethod("CreateTimer"); + omitted.SignatureModel!.ReturnType = "System.Windows.Forms.Timer"; + type.Members.Add(selected); + type.Members.Add(omitted); + + var result = _printer.Print(new CSharpTypePrintRequest(type, members: [selected])); + + Assert.Equal(["System.IO"], result.Usings); + Assert.Contains("public Stream Open();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("System.Windows.Forms", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void DerivationIncludesPrimaryConstructorParameters() + { + var type = CreateEmptyType("Samples", "Worker"); + + var result = _printer.Print(new CSharpTypePrintRequest( + type, + primaryConstructorParameters: + [ + new ApiParameter { Type = "System.IO.TextWriter", Name = "writer" } + ])); + + Assert.Equal(["System.IO"], result.Usings); + Assert.Contains("public class Worker(TextWriter writer)", result.Source, StringComparison.Ordinal); + } + [Fact] public void QualifiedPolicyKeepsReferencesQualified() { @@ -907,6 +941,160 @@ public void CrossTypeAmbiguousSimpleNameStaysQualifiedAcrossUnit() Assert.Contains("public MyNamespace.String N1();", result.Source, StringComparison.Ordinal); } + [Fact] + public void CollisionDoesNotBlockUnrelatedSameNamespaceShortening() + { + var type = CreateEmptyType("Alpha", "Widget"); + type.Members.Add(new ApiMember + { + Name = "GetPanel", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.Panel", + MemberName = "GetPanel" + } + }); + type.Members.Add(new ApiMember + { + Name = "Pair", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.Button", + MemberName = "Pair", + Parameters = + [ + new ApiParameter { Type = "Beta.Button", Name = "other" }, + new ApiParameter { Type = "Alpha.Panel", Name = "panel" } + ] + } + }); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains("public Panel GetPanel();", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public Button Pair(Beta.Button other, Panel panel);", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void NestedDeclaredTypeShadowsSameNamespaceTopLevelReference() + { + var outer = CreateEmptyType("Alpha", "Widget"); + var member = CreateMethod("GetButton"); + member.SignatureModel!.ReturnType = "Alpha.Button"; + outer.Members.Add(member); + var nested = CreateEmptyType("Alpha", "Button"); + + var result = _printer.Print(new CSharpTypePrintRequest( + outer, + nestedTypes: [new CSharpTypePrintRequest(nested)])); + + Assert.Contains( + "public Alpha.Button GetButton();", + result.Source, + StringComparison.Ordinal); + Assert.Contains("public class Button", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void EnclosingTypeParameterShadowsSameNamespaceReferencesInNestedType() + { + var outer = CreateEmptyType("Alpha", "Widget`1"); + outer.TypeParameters = [new TypeParameter { Name = "T" }]; + var nested = CreateEmptyType("Alpha", "Inner"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "Alpha.T"; + nested.Members.Add(member); + + var result = _printer.Print(new CSharpTypePrintRequest( + outer, + nestedTypes: + [ + new CSharpTypePrintRequest( + nested, + primaryConstructorParameters: + [ + new ApiParameter { Type = "Alpha.T", Name = "value" } + ]) + ])); + + Assert.Contains( + "public class Inner(Alpha.T value)", + result.Source, + StringComparison.Ordinal); + Assert.Contains("public Alpha.T Get();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ContextualShortFiltersCollidingCallerImportsAcrossUnit() + { + var first = CreateEmptyType("Samples", "First"); + first.Members.Add(new ApiMember + { + Name = "Get", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.Widget", + MemberName = "Get" + } + }); + var second = CreateEmptyType("Samples", "Second"); + second.Members.Add(new ApiMember + { + Name = "Get", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Beta.Widget", + MemberName = "Get" + } + }); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(first), new CSharpTypePrintRequest(second)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ContextualShort, + Usings = ["Alpha", "Beta"] + }); + + Assert.Contains("using Alpha;", result.Source, StringComparison.Ordinal); + Assert.Contains("using Beta;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Alpha.Widget Get();", result.Source, StringComparison.Ordinal); + Assert.Contains("public Beta.Widget Get();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ShortWithUsingsDerivesAlongsideCallerImports() + { + var type = CreateEmptyType("Samples", "Worker"); + var member = CreateMethod("CreateTimer"); + member.SignatureModel!.ReturnType = "System.Windows.Forms.Timer"; + type.Members.Add(member); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + Usings = ["System.Threading"] + }); + + Assert.Equal( + ["System.Threading", "System.Windows.Forms"], + result.Usings.Order(StringComparer.Ordinal)); + Assert.Contains("using System.Windows.Forms;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public Timer CreateTimer();", + result.Units[0].Source, + StringComparison.Ordinal); + } + [Fact] public void RawSignatureMethodTypeParameterShadowsReferenceAndStaysQualified() { @@ -1085,6 +1273,441 @@ public void NestedTypeReferencedAsNamespaceIsNotImportedWhenEnclosingTypeIsRefer StringComparison.Ordinal); } + [Fact] + public void ReferenceCollidingWithNamespaceSegmentStaysQualified() + { + var type = CreateEmptyType("Samples.Models", "Worker"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "External.Models"; + type.Members.Add(member); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.DoesNotContain("using External;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public External.Models Get();", + result.Units[0].Source, + StringComparison.Ordinal); + } + + [Fact] + public void ReferenceCollidingWithDerivedNamespaceRootStaysQualified() + { + var type = CreateEmptyType("Samples", "Worker"); + var widget = CreateMethod("GetWidget"); + widget.SignatureModel!.ReturnType = "Alpha.Beta.Widget"; + var alpha = CreateMethod("GetAlpha"); + alpha.SignatureModel!.ReturnType = "Zeta.Alpha"; + type.Members.Add(widget); + type.Members.Add(alpha); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Equal(["Alpha.Beta"], result.Usings); + Assert.Contains("public Widget GetWidget();", result.Source, StringComparison.Ordinal); + Assert.Contains("public Zeta.Alpha GetAlpha();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ReferenceCollidingWithCallerNamespaceRootStaysQualified() + { + var type = CreateEmptyType("Samples", "Worker"); + var alpha = CreateMethod("GetAlpha"); + alpha.SignatureModel!.ReturnType = "Zeta.Alpha"; + type.Members.Add(alpha); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + Usings = ["Alpha.Beta"] + }); + + Assert.Equal(["Alpha.Beta"], result.Usings); + Assert.DoesNotContain("using Zeta;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Zeta.Alpha GetAlpha();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ReferenceCollidingWithEnclosingNamespaceChildStaysQualified() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + var thing = CreateMethod("GetThing"); + thing.SignatureModel!.ReturnType = "Alpha.Gamma.Thing"; + var gamma = CreateMethod("GetGamma"); + gamma.SignatureModel!.ReturnType = "Other.Gamma"; + type.Members.Add(thing); + type.Members.Add(gamma); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Equal(["Alpha.Gamma"], result.Usings); + Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); + Assert.Contains("public Other.Gamma GetGamma();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void CallerNamespaceChildShadowsSameNamedReference() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + var gamma = CreateMethod("GetGamma"); + gamma.SignatureModel!.ReturnType = "Other.Gamma"; + type.Members.Add(gamma); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ContextualShort, + Usings = ["Alpha.Gamma", "Other"] + }); + + Assert.Equal( + ["Alpha.Gamma", "Other"], + result.Usings.Order(StringComparer.Ordinal)); + Assert.Contains("public Other.Gamma GetGamma();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void UnrelatedNamespaceChildDoesNotShadowSameNamedReference() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + var thing = CreateMethod("GetThing"); + thing.SignatureModel!.ReturnType = "Zeta.Delta.Thing"; + var delta = CreateMethod("GetDelta"); + delta.SignatureModel!.ReturnType = "Other.Delta"; + type.Members.Add(thing); + type.Members.Add(delta); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Equal( + ["Other", "Zeta.Delta"], + result.Usings.Order(StringComparer.Ordinal)); + Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); + Assert.Contains("public Delta GetDelta();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void SameNamespaceTypeMatchingRootUsesShortName() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + var alpha = CreateMethod("GetAlpha"); + alpha.SignatureModel!.ReturnType = "Alpha.Beta.Alpha"; + type.Members.Add(alpha); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains("public Alpha GetAlpha();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("Alpha.Beta.Alpha", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ContainingNamespaceChildShadowedRootUsesGlobalAlias() + { + var type = CreateEmptyType("Alpha.System", "Worker"); + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + type.Members.Add(uri); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "public global::System.Uri GetUri();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void ContainingNamespaceRootDoesNotRequireGlobalAlias() + { + var type = CreateEmptyType("System.Example", "Worker"); + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + type.Members.Add(uri); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains("public System.Uri GetUri();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("global::System.Uri", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void SiblingMemberNamespaceEvidenceTriggersGlobalAlias() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + var thing = CreateMethod("GetThing"); + thing.SignatureModel!.ReturnType = "Alpha.System.Thing"; + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + type.Members.Add(thing); + type.Members.Add(uri); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains("public Alpha.System.Thing GetThing();", result.Source, StringComparison.Ordinal); + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void BaseTypeNamespaceEvidenceTriggersGlobalAlias() + { + var type = CreateEmptyType("Alpha.Beta", "Worker"); + type.BaseType = "Alpha.System.Base"; + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + type.Members.Add(uri); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains(": Alpha.System.Base", result.Source, StringComparison.Ordinal); + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void EnclosingTypeNameTriggersGlobalAliasInNestedType() + { + var outer = CreateEmptyType("Samples", "Beta"); + var nested = CreateEmptyType("Samples", "Inner"); + var widget = CreateMethod("GetWidget"); + widget.SignatureModel!.ReturnType = "Beta.Models.Widget"; + nested.Members.Add(widget); + + var result = _printer.Print(new CSharpTypePrintRequest( + outer, + nestedTypes: [new CSharpTypePrintRequest(nested)])); + + Assert.Contains( + "public global::Beta.Models.Widget GetWidget();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void TopLevelSiblingTypeNameTriggersGlobalAlias() + { + var worker = CreateEmptyType("Samples", "Worker"); + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + worker.Members.Add(uri); + var system = CreateEmptyType("Samples", "System"); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(worker), new CSharpTypePrintRequest(system)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + Assert.Contains("public class System", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void AncestorNamespaceTypeNameTriggersGlobalAlias() + { + var system = CreateEmptyType("Alpha", "System"); + var worker = CreateEmptyType("Alpha.Beta", "Worker"); + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + worker.Members.Add(uri); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains("public class System", result.Source, StringComparison.Ordinal); + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void SiblingTypeReferenceRemainsShort() + { + var widget = CreateEmptyType("Alpha", "Widget"); + var panel = CreateEmptyType("Alpha", "Panel"); + var getPanel = CreateMethod("GetPanel"); + getPanel.SignatureModel!.ReturnType = "Alpha.Panel"; + widget.Members.Add(getPanel); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(widget), new CSharpTypePrintRequest(panel)]); + + Assert.Contains("public Panel GetPanel();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("public Alpha.Panel GetPanel();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void ContextualShortUsesSafeImportThatIsAlsoADeclaringNamespace() + { + var thing = CreateEmptyType("Alpha", "Thing"); + var worker = CreateEmptyType("Beta", "Worker"); + var getThing = CreateMethod("GetThing"); + getThing.SignatureModel!.ReturnType = "Alpha.Thing"; + worker.Members.Add(getThing); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(thing), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ContextualShort, + Usings = ["Alpha"] + }); + + Assert.Contains("using Alpha;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); + } + + [Theory] + [InlineData("Alpha", "Beta")] + [InlineData("A", "A.B")] + public void ShortWithUsingsImportsOtherDeclaringNamespace( + string consumerNamespace, + string dependencyNamespace) + { + var worker = CreateEmptyType(consumerNamespace, "Worker"); + var getThing = CreateMethod("GetThing"); + getThing.SignatureModel!.ReturnType = $"{dependencyNamespace}.Thing"; + worker.Members.Add(getThing); + var thing = CreateEmptyType(dependencyNamespace, "Thing"); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(worker), new CSharpTypePrintRequest(thing)]); + + Assert.Contains($"using {dependencyNamespace};", result.Source, StringComparison.Ordinal); + Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void QualifiedPolicyPlansTypeAndMemberAttributes() + { + var system = CreateEmptyType("Samples", "System"); + var worker = CreateEmptyType("Samples", "Worker"); + worker.Attributes = ["System.ObsoleteAttribute"]; + var run = CreateMethod("Run"); + run.Attributes = ["System.ObsoleteAttribute"]; + worker.Members.Add(run); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Equal( + 2, + result.Source.Split("[global::System.ObsoleteAttribute]", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("[System.ObsoleteAttribute]", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void QualifiedPolicyPlansDelegateReferences() + { + var type = new ApiType + { + Namespace = "Alpha.System", + Name = "Callback", + Kind = "delegate", + Members = + [ + new ApiMember + { + Name = "Invoke", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "System.Uri", + MemberName = "Invoke" + } + } + ] + }; + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "public delegate global::System.Uri Callback();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void PrimaryConstructorShadowingProducesDiagnostic() + { + var type = CreateEmptyType("Samples", "Worker`1"); + type.TypeParameters = [new TypeParameter { Name = "Task" }]; + + var result = _printer.Print(new CSharpTypePrintRequest( + type, + primaryConstructorParameters: + [ + new ApiParameter + { + Type = "System.Threading.Tasks.Task", + Name = "task" + } + ])); + + Assert.Contains( + "public class Worker(System.Threading.Tasks.Task task)", + result.Source, + StringComparison.Ordinal); + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Contains("Task", diagnostic.Message, StringComparison.Ordinal); + Assert.Contains("shadowed", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public void LexicallyShadowedQualifiedRootUsesGlobalAlias() + { + var type = CreateEmptyType("Samples", "Worker`2"); + type.TypeParameters = + [ + new TypeParameter { Name = "Alpha" }, + new TypeParameter { Name = "Thing" } + ]; + var thing = CreateMethod("GetThing"); + thing.SignatureModel!.ReturnType = "Alpha.Beta.Thing"; + type.Members.Add(thing); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.DoesNotContain("using Alpha.Beta;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public global::Alpha.Beta.Thing GetThing();", + result.Source, + StringComparison.Ordinal); + } + [Theory] [InlineData(CSharpTypeNamePolicy.ShortWithUsings)] [InlineData(CSharpTypeNamePolicy.ContextualShort)] diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 25429bf022..7ac306ae92 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -23,6 +23,9 @@ internal sealed record CSharpDeclarationOptions public CSharpTypeNameMode TypeNameMode { get; init; } = CSharpTypeNameMode.Qualified; public string? ContainingNamespace { get; init; } public IReadOnlyCollection Usings { get; init; } = []; + public IReadOnlyCollection AdditionalShadowingNames { get; init; } = []; + public IReadOnlyCollection AdditionalRootShadowingNames { get; init; } = []; + public IReadOnlyCollection AdditionalKnownNamespaces { get; init; } = []; public CSharpNamespaceMode NamespaceMode { get; init; } = CSharpNamespaceMode.Omit; public bool AbbreviateSignature { get; init; } public bool TerminateMemberDeclaration { get; init; } @@ -48,6 +51,10 @@ internal sealed record CSharpRenderedDeclaration( IReadOnlyList Usings, IReadOnlyList Diagnostics); +internal sealed record CSharpTypeNameContext( + IReadOnlyList SafeUsings, + IReadOnlyList KnownNamespaces); + /// /// Cheap C# declaration and signature composition over the API metadata model. /// It never imports method bodies, opens inspected assemblies, or depends on the decompiler. @@ -61,8 +68,14 @@ public static CSharpRenderedDeclaration RenderMemberUnit( IReadOnlyList? methodParameters = null) { options ??= new CSharpDeclarationOptions(); - var references = CollectMemberTypeReferences(member); - var plan = TypeNamePlan.Create(references, options); + var attributeReferences = CollectAttributeTypeReferences(member.Attributes).ToHashSet(StringComparer.Ordinal); + var references = CollectMemberTypeReferences(member).Concat(attributeReferences); + var plan = TypeNamePlan.Create( + references, + options, + CollectShadowingNames(type, [member]), + CSharpFormatter.StripArity(type.Name), + attributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -80,8 +93,14 @@ public static string RenderMemberDeclaration( IReadOnlyList? methodParameters = null) { options ??= new CSharpDeclarationOptions(); - var references = CollectMemberTypeReferences(member); - var plan = TypeNamePlan.Create(references, options); + var attributeReferences = CollectAttributeTypeReferences(member.Attributes).ToHashSet(StringComparer.Ordinal); + var references = CollectMemberTypeReferences(member).Concat(attributeReferences); + var plan = TypeNamePlan.Create( + references, + options, + CollectShadowingNames(type, [member]), + CSharpFormatter.StripArity(type.Name), + attributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); return options.TerminateMemberDeclaration && NeedsTerminator(declaration) @@ -89,16 +108,55 @@ public static string RenderMemberDeclaration( : declaration; } + public static string ApplyTypeNamePlan( + ApiType type, + IEnumerable members, + string declaration, + CSharpDeclarationOptions? options = null, + bool preserveReferenceQualification = false) + { + options ??= new CSharpDeclarationOptions(); + var memberList = members.ToList(); + var attributeReferences = CollectAttributeTypeReferences(type.Attributes) + .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes))) + .ToHashSet(StringComparer.Ordinal); + var references = CollectTypeReferences(type) + .Concat(memberList.SelectMany(CollectMemberTypeReferences)) + .Concat(attributeReferences) + .ToList(); + var plan = TypeNamePlan.Create( + references, + options, + CollectShadowingNames(type, memberList), + CSharpFormatter.StripArity(type.Name), + preserveReferenceQualification + ? references.ToHashSet(StringComparer.Ordinal) + : attributeReferences); + return plan.Apply(declaration); + } + public static CSharpRenderedDeclaration RenderTypeUnit( ApiType type, IEnumerable? members = null, - CSharpDeclarationOptions? options = null) + CSharpDeclarationOptions? options = null, + IReadOnlyList? primaryConstructorParameters = null) { options ??= new CSharpDeclarationOptions { NamespaceMode = CSharpNamespaceMode.FileScoped }; var memberList = members?.ToList() ?? type.Members; + var parameters = primaryConstructorParameters ?? []; + var attributeReferences = CollectAttributeTypeReferences(type.Attributes) + .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes))) + .ToHashSet(StringComparer.Ordinal); var references = CollectTypeReferences(type) - .Concat(memberList.SelectMany(CollectMemberTypeReferences)); - var plan = TypeNamePlan.Create(references, options); + .Concat(memberList.SelectMany(CollectMemberTypeReferences)) + .Concat(parameters.SelectMany(CollectParameterTypeReferences)) + .Concat(attributeReferences); + var plan = TypeNamePlan.Create( + references, + options, + CollectShadowingNames(type, memberList), + CSharpFormatter.StripArity(type.Name), + attributeReferences); List lines = [plan.Apply(RenderTypeDeclarationCore(type, options))]; lines.Add("{"); @@ -124,11 +182,40 @@ static string IndentEveryLine(string text, string pad) ? string.Join('\n', text.Split('\n').Select(line => line.Length == 0 ? line : pad + line)) : pad + text; - public static string RenderTypeDeclaration(ApiType type, CSharpDeclarationOptions? options = null) + public static string RenderTypeDeclaration( + ApiType type, + CSharpDeclarationOptions? options = null, + IReadOnlyList? primaryConstructorParameters = null) { options ??= new CSharpDeclarationOptions(); - var plan = TypeNamePlan.Create(CollectTypeReferences(type), options); - return plan.Apply(RenderTypeDeclarationCore(type, options)); + var parameters = primaryConstructorParameters ?? []; + var attributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); + var plan = TypeNamePlan.Create( + CollectTypeReferences(type) + .Concat(parameters.SelectMany(CollectParameterTypeReferences)) + .Concat(attributeReferences), + options, + CollectShadowingNames(type, []), + CSharpFormatter.StripArity(type.Name), + attributeReferences); + string declaration = RenderTypeDeclarationCore(type, options); + if (parameters.Count > 0) + { + string declarationWithoutAttributes = RenderTypeDeclarationCore( + type, + options with { IncludeCustomAttributes = false }); + if (!declaration.EndsWith(declarationWithoutAttributes, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"C# type declaration for '{type.FullName}' has an unexpected attribute prefix."); + } + + declaration = declaration[..^declarationWithoutAttributes.Length] + + AddPrimaryConstructorParameters( + declarationWithoutAttributes, + parameters); + } + return plan.Apply(declaration); } /// @@ -137,101 +224,239 @@ public static string RenderTypeDeclaration(ApiType type, CSharpDeclarationOption /// (including any nested types the caller flattens in). /// A namespace is included only when every simple type name it contributes is /// unambiguous across the whole unit: the simple name maps to a single full name - /// and does not clash with a type declared in the unit. Importing such a - /// namespace and shortening those references therefore cannot introduce an - /// ambiguous or shadowed reference. References whose simple name is ambiguous - /// stay fully qualified and their namespaces are excluded. + /// and is not shadowed by a declaration or visible namespace. Importing such a + /// namespace and shortening those references therefore cannot rebind them. + /// Ambiguous or shadowed references stay qualified and their namespaces are + /// excluded. /// public static IReadOnlyList DeriveContextualUsings(IReadOnlyCollection types) { ArgumentNullException.ThrowIfNull(types); + return DeriveTypeNameContext(types.Select(type => ( + Type: type, + Members: (IEnumerable)type.Members, + AdditionalParameters: Enumerable.Empty()))) + .SafeUsings; + } - var typeRefs = types - .SelectMany(type => CollectTypeReferences(type) - .Concat(type.Members.SelectMany(CollectMemberTypeReferences))) + internal static CSharpTypeNameContext DeriveTypeNameContext( + IEnumerable<( + ApiType Type, + IEnumerable Members, + IEnumerable AdditionalParameters)> scopes, + IEnumerable? contextualNamespaces = null) + { + var scopeList = scopes + .Select(scope => ( + scope.Type, + Members: scope.Members.ToList(), + AdditionalParameters: scope.AdditionalParameters.ToList())) + .ToList(); + var typeRefs = scopeList + .SelectMany(scope => CollectTypeReferences(scope.Type) + .Concat(scope.Members.SelectMany(CollectMemberTypeReferences)) + .Concat(scope.AdditionalParameters.SelectMany(CollectParameterTypeReferences))) + .Select(TypeRef.TryCreate) + .Where(r => r is not null) + .Select(r => r!) + .DistinctBy(r => r.FullName, StringComparer.Ordinal) + .ToList(); + var attributeTypeRefs = scopeList + .SelectMany(scope => CollectAttributeTypeReferences(scope.Type.Attributes) + .Concat(scope.Members.SelectMany(member => + CollectAttributeTypeReferences(member.Attributes)))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); - var declaredSimpleNames = types - .Select(type => CSharpFormatter.StripArity(type.Name)) + var knownNamespaces = typeRefs + .Select(typeRef => typeRef.Namespace) + .Concat(attributeTypeRefs.Select(typeRef => typeRef.Namespace)) + .Concat(contextualNamespaces ?? []) + .Concat(scopeList.Select(scope => scope.Type.Namespace)) + .Where(ns => !string.IsNullOrWhiteSpace(ns)) + .Select(ns => ns!) + .Distinct(StringComparer.Ordinal) + .ToList(); + var declaredTypeNames = new HashSet(StringComparer.Ordinal); + var declaredTypeFullNames = scopeList + .Select(scope => scope.Type.FullName) .ToHashSet(StringComparer.Ordinal); - - // Generic type/method parameters shadow same-named type references within - // their scope: importing a namespace and shortening a reference to a simple - // name that matches an in-scope type parameter would rebind it to the - // parameter. Exclude those namespaces so such references stay qualified. - foreach (var type in types) - { - foreach (var typeParameter in type.TypeParameters) - declaredSimpleNames.Add(typeParameter.Name); - foreach (var member in type.Members) + var shadowingNames = new HashSet(StringComparer.Ordinal); + var rootShadowingNames = new HashSet(StringComparer.Ordinal); + foreach (var scope in scopeList) + { + string declaredTypeName = CSharpFormatter.StripArity(scope.Type.Name); + declaredTypeNames.Add(declaredTypeName); + rootShadowingNames.Add(declaredTypeName); + foreach (var knownNamespace in knownNamespaces) { - if (member.SignatureModel is { } signature) - { - foreach (var typeParameter in signature.TypeParameters) - declaredSimpleNames.Add(typeParameter.Name); - } - - // Members whose signature failed structured decoding fall back to the - // raw signature string, whose generic method parameters are not in - // SignatureModel. Parse them so they still shadow same-named references. - foreach (var name in RawSignatureGenericParameterNames(member)) - declaredSimpleNames.Add(name); + AddVisibleNamespaceNames( + shadowingNames, + scope.Type.Namespace, + knownNamespace, + rootShadowingNames); } + var lexicalShadowingNames = CollectShadowingNames(scope.Type, scope.Members); + shadowingNames.UnionWith(lexicalShadowingNames); + rootShadowingNames.UnionWith(lexicalShadowingNames); } var usings = new SortedSet(StringComparer.Ordinal); - var collidingSimpleNames = typeRefs - .GroupBy(r => r.SimpleName, StringComparer.Ordinal) - .Where(g => g.Select(r => r.FullName).Distinct(StringComparer.Ordinal).Count() > 1) - .Select(g => g.Key) - .ToHashSet(StringComparer.Ordinal); - - // A nested type referenced as a type (e.g. `System.Environment.SpecialFolder`) - // arrives here as a flat dotted string, indistinguishable from a - // namespace-qualified reference: TypeRef.TryCreate splits at the last dot and - // derives namespace `System.Environment`, which is actually a type. Emitting - // `using System.Environment;` is illegal (CS0138). When the enclosing type is - // itself referenced in the unit we can detect this — its full name appears as a - // derived namespace — and exclude that namespace. (The isolated case, where the - // enclosing type is never referenced on its own, is not detectable from the - // flattened string alone; a full fix needs nested-type identity from the - // metadata layer. The failure mode is safe-visible: the reference stays - // qualified and, for a spurious using, RTS records a RecompileFail rather than - // miscompiling.) - var referencedFullNames = typeRefs - .Select(r => r.FullName) - .ToHashSet(StringComparer.Ordinal); - - // A namespace contributes a simple name for every reference it owns. Per-type - // shortening keys off namespace membership, so importing a namespace shortens - // every reference it owns. If any of those simple names is ambiguous unit-wide - // or shadowed by a declared type or type parameter, importing the namespace is - // unsafe: the shortened reference would become ambiguous or rebind. Exclude the - // whole namespace so every reference it owns stays fully qualified. - var unsafeNamespaces = typeRefs - .Where(r => collidingSimpleNames.Contains(r.SimpleName) || declaredSimpleNames.Contains(r.SimpleName)) - .Select(r => r.Namespace) - .ToHashSet(StringComparer.Ordinal); + var collidingSimpleNames = CollidingSimpleNames(typeRefs); + var unsafeNamespaces = UnsafeNamespaces( + typeRefs, + shadowingNames, + collidingSimpleNames, + rootShadowingNames, + declaredTypeNames, + declaredTypeFullNames); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) { if (collidingSimpleNames.Contains(group.Key)) continue; - if (declaredSimpleNames.Contains(group.Key)) + if (shadowingNames.Contains(group.Key)) + continue; + if (declaredTypeNames.Contains(group.Key) + && !declaredTypeFullNames.Contains(group.First().FullName)) continue; var ns = group.First().Namespace; if (unsafeNamespaces.Contains(ns)) continue; - if (referencedFullNames.Contains(ns)) - continue; usings.Add(ns); } - return usings.ToList(); + return new CSharpTypeNameContext(usings.ToList(), knownNamespaces); + } + + static IEnumerable CollectParameterTypeReferences(ApiParameter parameter) + { + if (!string.IsNullOrWhiteSpace(parameter.Type)) + foreach (var reference in ExtractQualifiedTypeNames(parameter.Type)) + yield return reference; + foreach (var attribute in parameter.Attributes) + foreach (var reference in ExtractQualifiedTypeNames(StripAttributeArguments(attribute))) + yield return reference; + } + + static IEnumerable CollectAttributeTypeReferences(IEnumerable attributes) + { + foreach (var attribute in attributes) + foreach (var reference in ExtractQualifiedTypeNames(StripAttributeArguments(attribute))) + yield return reference; + } + + static string AddPrimaryConstructorParameters( + string declaration, + IReadOnlyList parameters) + { + string parameterList = CSharpFormatter.FormatParameterList(parameters); + int constraints = declaration.IndexOf(" where ", StringComparison.Ordinal); + string head = constraints >= 0 ? declaration[..constraints] : declaration; + string tail = constraints >= 0 ? declaration[constraints..] : ""; + int inheritance = head.IndexOf(" : ", StringComparison.Ordinal); + return inheritance >= 0 + ? head[..inheritance] + parameterList + head[inheritance..] + tail + : $"{head}{parameterList}{tail}"; + } + + static HashSet CollectShadowingNames( + ApiType type, + IEnumerable members) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var typeParameter in type.TypeParameters) + names.Add(typeParameter.Name); + foreach (var member in members) + { + if (member.SignatureModel is { } signature) + foreach (var typeParameter in signature.TypeParameters) + names.Add(typeParameter.Name); + + foreach (var name in RawSignatureGenericParameterNames(member)) + names.Add(name); + } + return names; + } + + static void AddVisibleNamespaceNames( + HashSet names, + string? containingNamespace, + string? knownNamespace, + HashSet? shadowedGlobalRoots = null) + { + if (string.IsNullOrWhiteSpace(knownNamespace)) + return; + var knownSegments = knownNamespace.Split( + '.', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (knownSegments.Length == 0) + return; + names.Add(knownSegments[0]); + + if (string.IsNullOrWhiteSpace(containingNamespace)) + return; + var containingSegments = containingNamespace.Split( + '.', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + int sharedLength = Math.Min(containingSegments.Length, knownSegments.Length - 1); + for (var i = 0; i < sharedLength; i++) + { + if (!string.Equals( + containingSegments[i], + knownSegments[i], + StringComparison.Ordinal)) + { + break; + } + names.Add(knownSegments[i + 1]); + shadowedGlobalRoots?.Add(knownSegments[i + 1]); + } + } + + static HashSet CollidingSimpleNames(IReadOnlyList typeRefs) + => typeRefs + .GroupBy(r => r.SimpleName, StringComparer.Ordinal) + .Where(g => g.Select(r => r.FullName).Distinct(StringComparer.Ordinal).Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + static string NamespaceRoot(string ns) + { + int separator = ns.IndexOf('.'); + return separator < 0 ? ns : ns[..separator]; + } + + static HashSet UnsafeNamespaces( + IReadOnlyList typeRefs, + IReadOnlySet shadowingNames, + IReadOnlySet collidingSimpleNames, + IReadOnlySet rootShadowingNames, + IReadOnlySet? declaredTypeNames = null, + IReadOnlySet? declaredTypeFullNames = null) + { + declaredTypeNames ??= new HashSet(StringComparer.Ordinal); + declaredTypeFullNames ??= new HashSet(StringComparer.Ordinal); + var unsafeNamespaces = typeRefs + .Where(r => collidingSimpleNames.Contains(r.SimpleName) + || shadowingNames.Contains(r.SimpleName) + || (declaredTypeNames.Contains(r.SimpleName) + && !declaredTypeFullNames.Contains(r.FullName)) + || rootShadowingNames.Contains(NamespaceRoot(r.Namespace))) + .Select(r => r.Namespace) + .ToHashSet(StringComparer.Ordinal); + + var referencedFullNames = typeRefs + .Select(r => r.FullName) + .ToHashSet(StringComparer.Ordinal); + unsafeNamespaces.UnionWith(typeRefs + .Where(r => referencedFullNames.Contains(r.Namespace)) + .Select(r => r.Namespace)); + + return unsafeNamespaces; } static string ComposeUnit(IReadOnlyList bodyLines, IReadOnlyList usings, CSharpDeclarationOptions options) @@ -2081,11 +2306,14 @@ public string Apply(string text) return text; } - public static TypeNamePlan Create(IEnumerable references, CSharpDeclarationOptions options) + public static TypeNamePlan Create( + IEnumerable references, + CSharpDeclarationOptions options, + IReadOnlySet shadowingNames, + string declaredTypeName, + IReadOnlySet? qualificationOnlyReferences = null) { - if (options.TypeNameMode == CSharpTypeNameMode.Qualified) - return new TypeNamePlan(new Dictionary(), [], []); - + qualificationOnlyReferences ??= new HashSet(StringComparer.Ordinal); var typeRefs = references .Select(TypeRef.TryCreate) .Where(r => r is not null) @@ -2093,32 +2321,126 @@ public static TypeNamePlan Create(IEnumerable references, CSharpDeclarat .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); - var collisions = typeRefs - .GroupBy(r => r.SimpleName, StringComparer.Ordinal) - .Where(g => g.Select(r => r.FullName).Distinct(StringComparer.Ordinal).Count() > 1) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); - + var lexicalShadowingNames = shadowingNames.ToHashSet(StringComparer.Ordinal); + lexicalShadowingNames.UnionWith(options.AdditionalShadowingNames); + var namespaceShadowingNames = new HashSet(StringComparer.Ordinal); + var namespaceRootShadowingNames = new HashSet(StringComparer.Ordinal); + foreach (var typeRef in typeRefs) + { + AddVisibleNamespaceNames( + namespaceShadowingNames, + options.ContainingNamespace, + typeRef.Namespace, + namespaceRootShadowingNames); + } + foreach (var ns in options.Usings) + { + AddVisibleNamespaceNames( + namespaceShadowingNames, + options.ContainingNamespace, + ns, + namespaceRootShadowingNames); + } + foreach (var ns in options.AdditionalKnownNamespaces) + { + AddVisibleNamespaceNames( + namespaceShadowingNames, + options.ContainingNamespace, + ns, + namespaceRootShadowingNames); + } + AddVisibleNamespaceNames( + namespaceShadowingNames, + options.ContainingNamespace, + options.ContainingNamespace, + namespaceRootShadowingNames); + if (typeRefs.Any(r => string.Equals(r.SimpleName, declaredTypeName, StringComparison.Ordinal) + && !string.Equals(r.Namespace, options.ContainingNamespace, StringComparison.Ordinal))) + { + lexicalShadowingNames.Add(declaredTypeName); + } + var rootShadowingNames = lexicalShadowingNames.ToHashSet(StringComparer.Ordinal); + rootShadowingNames.UnionWith(options.AdditionalRootShadowingNames); + rootShadowingNames.Add(declaredTypeName); + rootShadowingNames.UnionWith(namespaceRootShadowingNames); + rootShadowingNames.UnionWith(typeRefs + .Where(typeRef => string.Equals( + typeRef.Namespace, + options.ContainingNamespace, + StringComparison.Ordinal)) + .Select(typeRef => typeRef.SimpleName)); + + var collisions = CollidingSimpleNames(typeRefs); + var allShadowingNames = lexicalShadowingNames + .Concat(namespaceShadowingNames) + .ToHashSet(StringComparer.Ordinal); + var unsafeNamespaces = UnsafeNamespaces( + typeRefs, + allShadowingNames, + collisions, + rootShadowingNames); var contextualUsings = options.Usings.ToHashSet(StringComparer.Ordinal); var generatedUsings = new SortedSet(StringComparer.Ordinal); var diagnostics = new List(); var replacements = new Dictionary(StringComparer.Ordinal); + void KeepResolvableQualified(TypeRef typeRef) + { + if (rootShadowingNames.Contains(NamespaceRoot(typeRef.Namespace))) + { + string escapedFullName = EscapeNamespace(typeRef.FullName); + replacements[escapedFullName] = $"global::{escapedFullName}"; + } + } + + if (options.TypeNameMode == CSharpTypeNameMode.Qualified) + { + foreach (var typeRef in typeRefs) + KeepResolvableQualified(typeRef); + return new TypeNamePlan(replacements, [], diagnostics); + } foreach (var typeRef in typeRefs) { - if (collisions.ContainsKey(typeRef.SimpleName)) + var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) + && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); + if (!isSameNamespace && collisions.Contains(typeRef.SimpleName)) { diagnostics.Add($"Type name '{typeRef.SimpleName}' is ambiguous; kept '{typeRef.FullName}' qualified."); + KeepResolvableQualified(typeRef); + continue; + } + if (lexicalShadowingNames.Contains(typeRef.SimpleName)) + { + diagnostics.Add($"Type name '{typeRef.SimpleName}' is shadowed in this declaration; kept '{typeRef.FullName}' qualified."); + KeepResolvableQualified(typeRef); + continue; + } + if (!isSameNamespace && namespaceShadowingNames.Contains(typeRef.SimpleName)) + { + diagnostics.Add($"Type name '{typeRef.SimpleName}' is shadowed by a namespace in this declaration; kept '{typeRef.FullName}' qualified."); + KeepResolvableQualified(typeRef); + continue; + } + if (!isSameNamespace && unsafeNamespaces.Contains(typeRef.Namespace)) + { + diagnostics.Add($"Namespace '{typeRef.Namespace}' contains an ambiguous or shadowed type name; kept '{typeRef.FullName}' qualified."); + KeepResolvableQualified(typeRef); + continue; + } + if (qualificationOnlyReferences.Contains(typeRef.FullName)) + { + KeepResolvableQualified(typeRef); continue; } - - var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) - && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); var isInContext = isSameNamespace || contextualUsings.Contains(typeRef.Namespace); if (options.TypeNameMode == CSharpTypeNameMode.ContextualShort && !isInContext) + { + KeepResolvableQualified(typeRef); continue; + } - replacements[typeRef.FullName] = typeRef.SimpleName; + replacements[EscapeNamespace(typeRef.FullName)] = typeRef.SimpleName; if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings && !isSameNamespace) generatedUsings.Add(typeRef.Namespace); } @@ -2150,6 +2472,11 @@ static string ReplaceIdentifierToken(string text, string token, string replaceme && IsStartBoundary(text, i - 1) && IsEndBoundary(text, i + token.Length)) { + if (IsWithinGlobalAlias(text, i)) + { + sb.Append(text[i++]); + continue; + } sb.Append(replacement); i += token.Length; continue; @@ -2161,6 +2488,19 @@ static string ReplaceIdentifierToken(string text, string token, string replaceme return sb.ToString(); } + static bool IsWithinGlobalAlias(string text, int index) + { + var start = index; + while (start > 0 + && (IsIdentifierPart(text[start - 1]) || text[start - 1] is '.' or '+')) + { + start--; + } + return start >= "global::".Length + && text.AsSpan(start - "global::".Length, "global::".Length) + .SequenceEqual("global::"); + } + static bool IsStartBoundary(string text, int index) => index < 0 || index >= text.Length diff --git a/src/ILInspector.CSharp/CSharpFormatter.cs b/src/ILInspector.CSharp/CSharpFormatter.cs index dfdda938bf..36739bb2aa 100644 --- a/src/ILInspector.CSharp/CSharpFormatter.cs +++ b/src/ILInspector.CSharp/CSharpFormatter.cs @@ -32,6 +32,9 @@ public sealed record CSharpFormatOptions public CSharpTypeNamePolicy TypeNamePolicy { get; init; } = CSharpTypeNamePolicy.Qualified; public string? ContainingNamespace { get; init; } public IReadOnlyCollection Usings { get; init; } = []; + internal IReadOnlyCollection AdditionalShadowingNames { get; init; } = []; + internal IReadOnlyCollection AdditionalRootShadowingNames { get; init; } = []; + internal IReadOnlyCollection AdditionalKnownNamespaces { get; init; } = []; public CSharpNamespacePolicy NamespacePolicy { get; init; } = CSharpNamespacePolicy.Omit; public bool AbbreviateSignature { get; init; } public bool TerminateMemberDeclaration { get; init; } @@ -121,24 +124,10 @@ public string FormatTypeDeclaration( IReadOnlyList? primaryConstructorParameters = null) { ArgumentNullException.ThrowIfNull(type); - string declaration = CSharpDeclarationWriter.RenderTypeDeclaration(type, _declarationOptions); - if (primaryConstructorParameters is not { Count: > 0 }) - return declaration; - - string declarationWithoutAttributes = CSharpDeclarationWriter.RenderTypeDeclaration( + return CSharpDeclarationWriter.RenderTypeDeclaration( type, - _declarationOptions with { IncludeCustomAttributes = false }); - if (!declaration.EndsWith(declarationWithoutAttributes, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"C# type declaration for '{type.FullName}' has an unexpected attribute prefix."); - } - - string attributePrefix = declaration[..^declarationWithoutAttributes.Length]; - return attributePrefix - + AddPrimaryConstructorParameters( - declarationWithoutAttributes, - primaryConstructorParameters); + _declarationOptions, + primaryConstructorParameters); } public string FormatDelegate(ApiType type, ApiMember invoke) @@ -172,18 +161,25 @@ public string FormatDelegate(ApiType type, ApiMember invoke) } } - return declaration + ";"; + return CSharpDeclarationWriter.ApplyTypeNamePlan( + type, + [invoke], + declaration + ";", + _declarationOptions, + preserveReferenceQualification: true); } public CSharpFormattedDeclaration FormatTypeUnit( ApiType type, - IEnumerable? members = null) + IEnumerable? members = null, + IReadOnlyList? primaryConstructorParameters = null) { ArgumentNullException.ThrowIfNull(type); return ToFormattedDeclaration(CSharpDeclarationWriter.RenderTypeUnit( type, members, - _declarationOptions)); + _declarationOptions, + primaryConstructorParameters)); } public static string EscapeIdentifier(string identifier) @@ -513,6 +509,9 @@ static CSharpDeclarationOptions ToDeclarationOptions( }, ContainingNamespace = options.ContainingNamespace, Usings = usings, + AdditionalShadowingNames = options.AdditionalShadowingNames, + AdditionalRootShadowingNames = options.AdditionalRootShadowingNames, + AdditionalKnownNamespaces = options.AdditionalKnownNamespaces, NamespaceMode = options.NamespacePolicy switch { CSharpNamespacePolicy.Omit => CSharpNamespaceMode.Omit, @@ -537,17 +536,4 @@ static string FormatTypeParameter(TypeParameter parameter, bool includeVariance) ? $"{variance} {CSharpIdentifier.ContainIdentifierForDeclaration(parameter.Name)}" : CSharpIdentifier.ContainIdentifierForDeclaration(parameter.Name); - static string AddPrimaryConstructorParameters( - string declaration, - IReadOnlyList parameters) - { - string parameterList = FormatParameterList(parameters); - int constraints = declaration.IndexOf(" where ", StringComparison.Ordinal); - string head = constraints >= 0 ? declaration[..constraints] : declaration; - string tail = constraints >= 0 ? declaration[constraints..] : ""; - int inheritance = head.IndexOf(" : ", StringComparison.Ordinal); - return inheritance >= 0 - ? head[..inheritance] + parameterList + head[inheritance..] + tail - : $"{head}{parameterList}{tail}"; - } } diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index 0e5cf5c8e3..f04659418a 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -51,15 +51,12 @@ public CSharpTypePrintResult PrintBatch( nameof(requests))); } - var derivedUsings = ComputeDerivedUsings(preparedTypes, options); - IReadOnlyList contextualUsings = options.TypeNamePolicy switch - { - CSharpTypeNamePolicy.Qualified => [], - CSharpTypeNamePolicy.ShortWithUsings => derivedUsings, - CSharpTypeNamePolicy.ContextualShort when options.IncludeUsings => configuredUsings, - CSharpTypeNamePolicy.ContextualShort => [], - _ => throw new InvalidOperationException() - }; + var typeNameContext = ComputeTypeNameContext(preparedTypes, options); + var safeUsings = typeNameContext.SafeUsings; + var derivedUsings = options.TypeNamePolicy == CSharpTypeNamePolicy.ShortWithUsings + ? safeUsings + : []; + var contextualUsings = TypeNameContext(options, configuredUsings, safeUsings); var emittedUsings = options.IncludeUsings ? configuredUsings .Concat(derivedUsings) @@ -69,10 +66,27 @@ public CSharpTypePrintResult PrintBatch( var units = ImmutableArray.CreateBuilder(); foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { + var groupedTypes = group.ToList(); var containingNamespace = group.Key.Length == 0 ? null : group.Key; + var ancestorTypeNames = preparedTypes + .Where(candidate => IsAncestorNamespace(candidate.Namespace, group.Key)) + .Select(candidate => CSharpFormatter.StripArity(candidate.Type.Name)) + .ToImmutableHashSet(StringComparer.Ordinal); var source = string.Join( "\n\n", - group.Select(type => RenderType(type, indent: 0, options, contextualUsings, diagnostics))); + groupedTypes.Select(type => RenderType( + type, + indent: 0, + options, + contextualUsings, + inheritedShadowingNames: ImmutableHashSet.Empty, + inheritedRootShadowingNames: groupedTypes + .Where(sibling => !ReferenceEquals(sibling, type)) + .Select(sibling => CSharpFormatter.StripArity(sibling.Type.Name)) + .Concat(ancestorTypeNames) + .ToImmutableHashSet(StringComparer.Ordinal), + typeNameContext.KnownNamespaces, + diagnostics))); if (containingNamespace is not null) { string renderedNamespace = CSharpFormatter.EscapeNamespace(containingNamespace); @@ -93,38 +107,59 @@ public CSharpTypePrintResult PrintBatch( } /// - /// Derives the collision-safe namespaces to shorten against, excluding the - /// unit's own declaring namespaces (their references are already shortened by - /// the same-namespace rule, so importing them would be redundant). + /// Derives collision-safe namespaces and the namespace identities known to the + /// complete output unit. /// - static IReadOnlyList ComputeDerivedUsings( + static CSharpTypeNameContext ComputeTypeNameContext( IReadOnlyList preparedTypes, CSharpTypePrintOptions options) { - // Shortening is only sound when the enabling `using` directives are - // actually emitted. When usings are suppressed, keep references qualified - // so the composed source stays compilable. - if (options.TypeNamePolicy != CSharpTypeNamePolicy.ShortWithUsings - || !options.IncludeUsings) - return []; - - var allTypes = new List(); - var declaringNamespaces = new HashSet(StringComparer.Ordinal); + var scopes = new List<( + ApiType Type, + IEnumerable Members, + IEnumerable AdditionalParameters)>(); void Flatten(PreparedType prepared) { - allTypes.Add(prepared.Type); - declaringNamespaces.Add(prepared.Namespace); + scopes.Add(( + prepared.Type, + prepared.Members.Select(member => member.Member), + prepared.PrimaryConstructorParameters)); foreach (var nested in prepared.NestedTypes) Flatten(nested); } foreach (var prepared in preparedTypes) Flatten(prepared); - return CSharpFormatter.DeriveContextualUsings(allTypes) - .Where(ns => !declaringNamespaces.Contains(ns)) - .ToArray(); + return CSharpDeclarationWriter.DeriveTypeNameContext( + scopes, + options.Usings); } + static bool IsAncestorNamespace(string candidate, string descendant) + => candidate.Length < descendant.Length + && descendant.StartsWith(candidate, StringComparison.Ordinal) + && descendant[candidate.Length] == '.'; + + static IReadOnlyList TypeNameContext( + CSharpTypePrintOptions options, + IReadOnlyList configuredUsings, + IReadOnlyList safeUsings) + => options.TypeNamePolicy switch + { + CSharpTypeNamePolicy.Qualified => [], + CSharpTypeNamePolicy.ShortWithUsings => + options.IncludeUsings + ? safeUsings + : [], + CSharpTypeNamePolicy.ContextualShort => + options.IncludeUsings + ? configuredUsings + .Where(safeUsings.Contains) + .ToArray() + : [], + _ => throw new InvalidOperationException() + }; + static string ComposeSource( ImmutableArray units, IReadOnlyCollection usings, @@ -272,9 +307,23 @@ static string RenderType( int indent, CSharpTypePrintOptions options, IReadOnlyList contextualUsings, + IReadOnlySet inheritedShadowingNames, + IReadOnlySet inheritedRootShadowingNames, + IReadOnlyCollection knownNamespaces, ImmutableArray.Builder diagnostics) { - var formatter = DeclarationFormatter(prepared.Namespace, options, contextualUsings); + var inScopeShadowingNames = inheritedShadowingNames.ToHashSet(StringComparer.Ordinal); + inScopeShadowingNames.UnionWith(prepared.Type.TypeParameters.Select( + parameter => parameter.Name)); + inScopeShadowingNames.UnionWith(prepared.NestedTypes.Select( + nested => CSharpFormatter.StripArity(nested.Type.Name))); + var formatter = DeclarationFormatter( + prepared.Namespace, + options, + contextualUsings, + inScopeShadowingNames, + inheritedRootShadowingNames, + knownNamespaces); if (prepared.Type.Kind == "delegate") return RenderDelegate(prepared, formatter, indent); @@ -282,13 +331,22 @@ static string RenderType( prepared.Namespace, options, contextualUsings, + inScopeShadowingNames, + inheritedRootShadowingNames, + knownNamespaces, omitPropertyAccessors: true); var diagnosticPass = DeclarationFormatter( prepared.Namespace, options, contextualUsings, + inScopeShadowingNames, + inheritedRootShadowingNames, + knownNamespaces, terminateMemberDeclaration: true) - .FormatTypeUnit(prepared.Type, prepared.Type.Members); + .FormatTypeUnit( + prepared.Type, + prepared.Members.Select(member => member.Member), + prepared.PrimaryConstructorParameters); diagnostics.AddRange(diagnosticPass.Diagnostics.Select( diagnostic => new CSharpTypePrintDiagnostic(prepared.Type.FullName, diagnostic))); @@ -312,7 +370,20 @@ static string RenderType( foreach (var member in prepared.Members) lines.AddRange(RenderMember(prepared, member, formatter, propertyFormatter, indent + 1)); foreach (var nested in prepared.NestedTypes) - lines.Add(RenderType(nested, indent + 1, options, contextualUsings, diagnostics)); + { + var nestedShadowingNames = inScopeShadowingNames.ToHashSet(StringComparer.Ordinal); + var nestedRootShadowingNames = inheritedRootShadowingNames.ToHashSet(StringComparer.Ordinal); + nestedRootShadowingNames.Add(CSharpFormatter.StripArity(prepared.Type.Name)); + lines.Add(RenderType( + nested, + indent + 1, + options, + contextualUsings, + nestedShadowingNames, + nestedRootShadowingNames, + knownNamespaces, + diagnostics)); + } } lines.Add($"{pad}}}"); return string.Join('\n', lines); @@ -504,6 +575,9 @@ static CSharpFormatter DeclarationFormatter( string containingNamespace, CSharpTypePrintOptions options, IReadOnlyList contextualUsings, + IReadOnlyCollection additionalShadowingNames, + IReadOnlyCollection additionalRootShadowingNames, + IReadOnlyCollection additionalKnownNamespaces, bool omitPropertyAccessors = false, bool terminateMemberDeclaration = false) => new(new CSharpFormatOptions @@ -513,6 +587,9 @@ static CSharpFormatter DeclarationFormatter( : CSharpTypeNamePolicy.ContextualShort, ContainingNamespace = containingNamespace.Length == 0 ? null : containingNamespace, Usings = contextualUsings, + AdditionalShadowingNames = additionalShadowingNames, + AdditionalRootShadowingNames = additionalRootShadowingNames, + AdditionalKnownNamespaces = additionalKnownNamespaces, NamespacePolicy = CSharpNamespacePolicy.Omit, IncludeCustomAttributes = options.IncludeCustomAttributes, OmitPropertyAccessors = omitPropertyAccessors, diff --git a/src/ILInspector.Decompiler.Tests/ReturnToSenderPrototypeTests.cs b/src/ILInspector.Decompiler.Tests/ReturnToSenderPrototypeTests.cs index 250bde11d7..ec88ca16d7 100644 --- a/src/ILInspector.Decompiler.Tests/ReturnToSenderPrototypeTests.cs +++ b/src/ILInspector.Decompiler.Tests/ReturnToSenderPrototypeTests.cs @@ -6254,6 +6254,46 @@ public void CSharpTypePrinter_PrimaryConstructorParametersPrecedeGenericConstrai Assert.Contains("public class Class1(string message) where T : class", Assert.Single(result.Units).Source); } + [Fact] + public void CompileBackTargets_LexicallyShadowedNamespaceRootUsesGlobalAlias() + { + var assemblyPath = CompileFixture(""" + namespace Alpha.Beta + { + public class Thing + { + } + } + + public class Worker + { + public global::Alpha.Beta.Thing GetThing() + { + return null; + } + } + """); + try + { + var result = Assert.Single(ReturnToSender.CompileBackTargets( + assemblyPath, + [new ReturnToSender.RequestedTarget("Worker`2", "GetThing", 0)])); + + Assert.True( + result.Status == FidelityCheck.CompileBackStatus.Exact, + $"{result.Status}: {result.Detail}{Environment.NewLine}{result.Source}"); + Assert.False(result.UsedCompileBackFloor, result.Detail); + Assert.Contains( + "public global::Alpha.Beta.Thing GetThing()", + result.Source, + StringComparison.Ordinal); + } + finally + { + DeleteFixture(assemblyPath); + } + } + [Fact] public void CompileBackTargets_RoundTripsAutoPropertySetter() { From 7f47946351bbfe199e82fe09a665eabaaf711555 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Thu, 6 Aug 2026 23:50:37 -0700 Subject: [PATCH 02/18] Preserve authored corpus method identity Fold delegate planning into the existing declaration entry point and keep planner context as fields so the pinned RenderMemberUnit token remains stable. Update the unaffected downstream corpus token for the intentional helper-method additions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpDeclarationWriter.cs | 77 ++++++++++--------- src/ILInspector.CSharp/CSharpFormatter.cs | 27 +------ src/ILInspector.CSharp/CSharpTypePrinter.cs | 4 +- .../corpus/two-row-authored-corpus.jsonl | 2 +- 4 files changed, 47 insertions(+), 63 deletions(-) diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 7ac306ae92..1fcd3df252 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -23,9 +23,10 @@ internal sealed record CSharpDeclarationOptions public CSharpTypeNameMode TypeNameMode { get; init; } = CSharpTypeNameMode.Qualified; public string? ContainingNamespace { get; init; } public IReadOnlyCollection Usings { get; init; } = []; - public IReadOnlyCollection AdditionalShadowingNames { get; init; } = []; - public IReadOnlyCollection AdditionalRootShadowingNames { get; init; } = []; - public IReadOnlyCollection AdditionalKnownNamespaces { get; init; } = []; + // Fields avoid shifting the MethodDef tokens pinned by AuthoredCorpusHarnessProcessTests. + public IReadOnlyCollection AdditionalShadowingNames = []; + public IReadOnlyCollection AdditionalRootShadowingNames = []; + public IReadOnlyCollection AdditionalKnownNamespaces = []; public CSharpNamespaceMode NamespaceMode { get; init; } = CSharpNamespaceMode.Omit; public bool AbbreviateSignature { get; init; } public bool TerminateMemberDeclaration { get; init; } @@ -51,10 +52,6 @@ internal sealed record CSharpRenderedDeclaration( IReadOnlyList Usings, IReadOnlyList Diagnostics); -internal sealed record CSharpTypeNameContext( - IReadOnlyList SafeUsings, - IReadOnlyList KnownNamespaces); - /// /// Cheap C# declaration and signature composition over the API metadata model. /// It never imports method bodies, opens inspected assemblies, or depends on the decompiler. @@ -108,33 +105,6 @@ public static string RenderMemberDeclaration( : declaration; } - public static string ApplyTypeNamePlan( - ApiType type, - IEnumerable members, - string declaration, - CSharpDeclarationOptions? options = null, - bool preserveReferenceQualification = false) - { - options ??= new CSharpDeclarationOptions(); - var memberList = members.ToList(); - var attributeReferences = CollectAttributeTypeReferences(type.Attributes) - .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes))) - .ToHashSet(StringComparer.Ordinal); - var references = CollectTypeReferences(type) - .Concat(memberList.SelectMany(CollectMemberTypeReferences)) - .Concat(attributeReferences) - .ToList(); - var plan = TypeNamePlan.Create( - references, - options, - CollectShadowingNames(type, memberList), - CSharpFormatter.StripArity(type.Name), - preserveReferenceQualification - ? references.ToHashSet(StringComparer.Ordinal) - : attributeReferences); - return plan.Apply(declaration); - } - public static CSharpRenderedDeclaration RenderTypeUnit( ApiType type, IEnumerable? members = null, @@ -185,10 +155,41 @@ static string IndentEveryLine(string text, string pad) public static string RenderTypeDeclaration( ApiType type, CSharpDeclarationOptions? options = null, - IReadOnlyList? primaryConstructorParameters = null) + IReadOnlyList? primaryConstructorParameters = null, + ApiMember? delegateInvoke = null) { options ??= new CSharpDeclarationOptions(); var parameters = primaryConstructorParameters ?? []; + if (delegateInvoke is not null) + { + if (delegateInvoke.SignatureModel is not { } signature) + { + throw new NotSupportedException( + $"Delegate '{type.FullName}' requires a structured Invoke signature."); + } + + var delegateAttributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); + var references = CollectTypeReferences(type) + .Concat(CollectMemberTypeReferences(delegateInvoke)) + .Concat(delegateAttributeReferences) + .ToList(); + var delegatePlan = TypeNamePlan.Create( + references, + options, + CollectShadowingNames(type, [delegateInvoke]), + CSharpFormatter.StripArity(type.Name), + references.ToHashSet(StringComparer.Ordinal)); + string attributes = options.IncludeCustomAttributes && type.Attributes.Count > 0 + ? string.Join("\n", type.Attributes.Select(attribute => $"[{attribute}]")) + "\n" + : ""; + string unsafeText = delegateInvoke.IsUnsafe ? " unsafe" : ""; + string parameterList = CSharpFormatter.FormatParameterList(signature.Parameters); + string delegateDeclaration = + $"{attributes}{TypeAccessibility(type)}{unsafeText} delegate {signature.ReturnType ?? "void"} {FormatTypeDisplayName(type.Name, type.TypeParameters)}{parameterList}"; + delegateDeclaration = AppendTypeParameterConstraints(delegateDeclaration, type.TypeParameters); + return delegatePlan.Apply(delegateDeclaration + ";"); + } + var attributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); var plan = TypeNamePlan.Create( CollectTypeReferences(type) @@ -239,7 +240,9 @@ public static IReadOnlyList DeriveContextualUsings(IReadOnlyCollection SafeUsings, + IReadOnlyList KnownNamespaces) DeriveTypeNameContext( IEnumerable<( ApiType Type, IEnumerable Members, @@ -329,7 +332,7 @@ internal static CSharpTypeNameContext DeriveTypeNameContext( usings.Add(ns); } - return new CSharpTypeNameContext(usings.ToList(), knownNamespaces); + return (usings.ToList(), knownNamespaces); } static IEnumerable CollectParameterTypeReferences(ApiParameter parameter) diff --git a/src/ILInspector.CSharp/CSharpFormatter.cs b/src/ILInspector.CSharp/CSharpFormatter.cs index 36739bb2aa..8d3b1c3056 100644 --- a/src/ILInspector.CSharp/CSharpFormatter.cs +++ b/src/ILInspector.CSharp/CSharpFormatter.cs @@ -136,37 +136,16 @@ public string FormatDelegate(ApiType type, ApiMember invoke) ArgumentNullException.ThrowIfNull(invoke); if (type.Kind != "delegate") throw new ArgumentException($"Type '{type.FullName}' is not a delegate.", nameof(type)); - if (invoke.SignatureModel is not { } signature) + if (invoke.SignatureModel is null) { throw new NotSupportedException( $"Delegate '{type.FullName}' requires a structured Invoke signature."); } - string attributes = _declarationOptions.IncludeCustomAttributes && type.Attributes.Count > 0 - ? string.Join("\n", type.Attributes.Select(attribute => $"[{attribute}]")) + "\n" - : ""; - string unsafeText = invoke.IsUnsafe ? " unsafe" : ""; - string parameters = FormatParameterList(signature.Parameters); - string declaration = - $"{attributes}{CSharpDeclarationWriter.TypeAccessibility(type)}{unsafeText} delegate {signature.ReturnType ?? "void"} {FormatTypeName(type, includeVariance: true)}{parameters}"; - foreach (var typeParameter in type.TypeParameters) - { - if (typeParameter.Constraints.Count > 0) - { - declaration += - $" where {CSharpIdentifier.ContainIdentifierForDeclaration(typeParameter.Name)} : " - + CSharpDeclarationWriter.FormatConstraintList( - typeParameter, - type.TypeParameters.Select(parameter => parameter.Name)); - } - } - - return CSharpDeclarationWriter.ApplyTypeNamePlan( + return CSharpDeclarationWriter.RenderTypeDeclaration( type, - [invoke], - declaration + ";", _declarationOptions, - preserveReferenceQualification: true); + delegateInvoke: invoke); } public CSharpFormattedDeclaration FormatTypeUnit( diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index f04659418a..aca76c3a46 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -110,7 +110,9 @@ public CSharpTypePrintResult PrintBatch( /// Derives collision-safe namespaces and the namespace identities known to the /// complete output unit. /// - static CSharpTypeNameContext ComputeTypeNameContext( + static ( + IReadOnlyList SafeUsings, + IReadOnlyList KnownNamespaces) ComputeTypeNameContext( IReadOnlyList preparedTypes, CSharpTypePrintOptions options) { diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index 9f194c75c0..57ef86b821 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"`0(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"F8EB70D17DB02B261CC738AF4FFA650DFC307AB5D6FCF0732BF09D51F54E1C27","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration && NeedsTerminator(declaration))\n declaration += \";\";\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663529,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663538,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} From 0b27054da4f02f61cd8774095ebf893a3e312a22 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 02:34:49 -0700 Subject: [PATCH 03/18] Close lexical type-planning gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpDeclarationWriterTests.cs | 4 +- .../CSharpFormatterTests.cs | 100 ++++++++ .../CSharpTypePrinterTests.cs | 168 +++++++++++++ .../CSharpDeclarationWriter.cs | 227 ++++++++++++++---- src/ILInspector.CSharp/CSharpFormatter.cs | 9 +- src/ILInspector.CSharp/CSharpTypePrinter.cs | 61 ++++- .../corpus/two-row-authored-corpus.jsonl | 2 +- 7 files changed, 513 insertions(+), 58 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpDeclarationWriterTests.cs b/src/ILInspector.CSharp.Tests/CSharpDeclarationWriterTests.cs index 255da97dda..aed59eb6cd 100644 --- a/src/ILInspector.CSharp.Tests/CSharpDeclarationWriterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpDeclarationWriterTests.cs @@ -428,7 +428,7 @@ public void MethodDeclaration_DoesNotShortenTypeNamesInsideParameterAttributeStr [ new ApiParameter { - Attributes = ["System.Diagnostics.CodeAnalysis.StringSyntax(\"System.String\")"], + Attributes = ["System.Diagnostics.CodeAnalysis.StringSyntax(\"System.String (External.Kind)1\")"], Type = "string", Name = "pattern" } @@ -449,7 +449,7 @@ public void MethodDeclaration_DoesNotShortenTypeNamesInsideParameterAttributeStr """ using System.Diagnostics.CodeAnalysis; - public void Validate([StringSyntax("System.String")] string pattern); + public void Validate([StringSyntax("System.String (External.Kind)1")] string pattern); """, rendered.Source); } diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index 47a626d53f..9b16d2de90 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -78,6 +78,43 @@ public class Widget Assert.Empty(declaration.Diagnostics); } + [Fact] + public void FormatsPrimaryConstructorParametersInTypeUnit() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class" + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings + }); + + var declaration = formatter.FormatTypeUnit( + type, + members: null, + primaryConstructorParameters: + [ + new ApiParameter + { + Type = "System.String", + Name = "message", + Attributes = + [ + "Attributes.Other.Marker(typeof(External.Value))" + ] + } + ]); + + Assert.Contains( + "public class Worker([Marker(typeof(External.Value))] String message)", + declaration.Text, + StringComparison.Ordinal); + Assert.Contains("Attributes.Other", declaration.Usings); + } + [Theory] [InlineData(CSharpTypeNamePolicy.Qualified, "public System.Threading.Tasks.Task Run()", false)] [InlineData(CSharpTypeNamePolicy.ShortWithUsings, "public Task Run()", true)] @@ -462,6 +499,69 @@ public void QualifiedPolicyEscapesShadowedKeywordNamespaceRoot() Assert.DoesNotContain("@global::", declaration.Text, StringComparison.Ordinal); } + [Fact] + public void QualifiedPolicyEscapesKeywordTypeWithShadowedRoot() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker`1", + Kind = "class", + TypeParameters = [new TypeParameter { Name = "Alpha" }] + }; + var member = new ApiMember + { + Name = "GetEvent", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.event", + MemberName = "GetEvent" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public global::Alpha.@event GetEvent()", + declaration.Text, + StringComparison.Ordinal); + } + + [Fact] + public void ShortPolicyEscapesKeywordType() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class" + }; + var member = new ApiMember + { + Name = "GetEvent", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Alpha.event", + MemberName = "GetEvent" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains("Alpha", declaration.Usings); + Assert.Contains("public @event GetEvent()", declaration.Text, StringComparison.Ordinal); + } + [Fact] public void FormatsParameterListsWithAttributesDefaultsAndEscapedKeywords() { diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 2bac1243e0..b0a1ce6f16 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -1580,6 +1580,41 @@ public void ContextualShortUsesSafeImportThatIsAlsoADeclaringNamespace() Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); } + [Theory] + [InlineData(CSharpTypeNamePolicy.ShortWithUsings)] + [InlineData(CSharpTypeNamePolicy.ContextualShort)] + public void ImportedDeclaredTypeCannotCaptureQualifiedNamespaceRoot( + CSharpTypeNamePolicy policy) + { + var importedSystem = CreateEmptyType("Imported", "System"); + var importedWidget = CreateEmptyType("Imported", "Widget"); + var consumer = CreateEmptyType("Samples", "Consumer"); + var getWidget = CreateMethod("GetWidget"); + getWidget.SignatureModel!.ReturnType = "Imported.Widget"; + var getUri = CreateMethod("GetUri"); + getUri.SignatureModel!.ReturnType = "System.Uri"; + consumer.Members.Add(getWidget); + consumer.Members.Add(getUri); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest(importedSystem), + new CSharpTypePrintRequest(importedWidget), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions + { + TypeNamePolicy = policy, + Usings = policy == CSharpTypeNamePolicy.ContextualShort + ? ["Imported"] + : [] + }); + + Assert.Contains("using Imported;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Widget GetWidget();", result.Source, StringComparison.Ordinal); + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + } + [Theory] [InlineData("Alpha", "Beta")] [InlineData("A", "A.B")] @@ -1624,6 +1659,55 @@ public void QualifiedPolicyPlansTypeAndMemberAttributes() Assert.DoesNotContain("[System.ObsoleteAttribute]", result.Source, StringComparison.Ordinal); } + [Fact] + public void QualifiedPolicyPlansTypeBearingAttributeArgumentsAndReturnAttributes() + { + var type = CreateEmptyType("Samples", "External`1"); + type.Attributes = + [ + "Other.Marker(typeof(External.Value), (External.Kind)1)", + "Other.KeywordMarker(typeof(Alpha.@event), (Alpha.@event)1)" + ]; + type.TypeParameters = [new TypeParameter { Name = "Alpha" }]; + var method = CreateMethod("Get"); + method.Kind = "property"; + method.SignatureModel!.ReturnAttributes = ["External.ReturnMarker"]; + method.SignatureModel.Accessors = + [ + new ApiAccessor + { + Kind = "get", + ReturnAttributes = ["External.AccessorMarker"] + } + ]; + type.Members.Add(method); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains( + "[Other.Marker(typeof(global::External.Value), (global::External.Kind)1)]", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "[Other.KeywordMarker(typeof(global::Alpha.@event), (global::Alpha.@event)1)]", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "[return: global::External.ReturnMarker]", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "[return: global::External.AccessorMarker]", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void QualifiedPolicyPlansDelegateReferences() { @@ -1935,6 +2019,90 @@ public int Value StringComparison.Ordinal); } + [Fact] + public void FullPropertyAndEventBodiesPlanAccessorReturnAttributes() + { + var property = new ApiMember + { + Name = "Value", + Kind = "property", + SignatureModel = new ApiSignature + { + ReturnType = "int", + MemberName = "Value", + Accessors = + [ + new ApiAccessor + { + Kind = "get", + ReturnAttributes = ["External.GetterMarker"] + } + ] + } + }; + var @event = new ApiMember + { + Name = "Changed", + Kind = "event", + SignatureModel = new ApiSignature + { + ReturnType = "System.EventHandler", + MemberName = "Changed", + Accessors = + [ + new ApiAccessor + { + Kind = "add", + ReturnAttributes = ["External.AddMarker"] + }, + new ApiAccessor + { + Kind = "remove", + ReturnAttributes = ["External.RemoveMarker"] + } + ] + } + }; + var type = CreateEmptyType("Samples", "External`1"); + type.TypeParameters = [new TypeParameter { Name = "void" }]; + type.Members.Add(property); + type.Members.Add(@event); + + var result = _printer.Print( + new CSharpTypePrintRequest( + type, + memberPolicyOverrides: + [ + new CSharpMemberPolicy( + property, + CSharpBodyPolicy.Full, + new CSharpPropertyBody(CSharpAccessorBody.Block("return 42;"), null)), + new CSharpMemberPolicy( + @event, + CSharpBodyPolicy.Full, + new CSharpEventBody( + CSharpAccessorBody.Block("_changed += value;"), + CSharpAccessorBody.Block("_changed -= value;"))) + ]), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "[return: global::External.GetterMarker] get", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "[return: global::External.AddMarker] add", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "[return: global::External.RemoveMarker] remove", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void ExplicitInterfacePropertyPreservesQualifiedNameAndOmitsAccessibility() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 1fcd3df252..ff06b128b5 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -65,14 +65,18 @@ public static CSharpRenderedDeclaration RenderMemberUnit( IReadOnlyList? methodParameters = null) { options ??= new CSharpDeclarationOptions(); - var attributeReferences = CollectAttributeTypeReferences(member.Attributes).ToHashSet(StringComparer.Ordinal); + var attributeReferences = CollectAttributeTypeReferences(member.Attributes, member).ToHashSet(StringComparer.Ordinal); + var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( + member.Attributes, + member) + .ToHashSet(StringComparer.Ordinal); var references = CollectMemberTypeReferences(member).Concat(attributeReferences); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), - attributeReferences); + qualificationOnlyAttributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -90,14 +94,18 @@ public static string RenderMemberDeclaration( IReadOnlyList? methodParameters = null) { options ??= new CSharpDeclarationOptions(); - var attributeReferences = CollectAttributeTypeReferences(member.Attributes).ToHashSet(StringComparer.Ordinal); + var attributeReferences = CollectAttributeTypeReferences(member.Attributes, member).ToHashSet(StringComparer.Ordinal); + var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( + member.Attributes, + member) + .ToHashSet(StringComparer.Ordinal); var references = CollectMemberTypeReferences(member).Concat(attributeReferences); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), - attributeReferences); + qualificationOnlyAttributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); return options.TerminateMemberDeclaration && NeedsTerminator(declaration) @@ -115,20 +123,35 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var memberList = members?.ToList() ?? type.Members; var parameters = primaryConstructorParameters ?? []; var attributeReferences = CollectAttributeTypeReferences(type.Attributes) - .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes))) + .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes, member))) + .ToHashSet(StringComparer.Ordinal); + var qualificationOnlyAttributeReferences = CollectAttributeTypeReferences(type.Attributes) + .Concat(memberList.SelectMany(member => + CollectQualificationOnlyAttributeTypeReferences(member.Attributes, member))) + .Concat(parameters.SelectMany(parameter => + CollectAttributeArgumentTypeReferences(parameter.Attributes))) + .ToHashSet(StringComparer.Ordinal); + var primaryParameterAttributeReferences = parameters + .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) .ToHashSet(StringComparer.Ordinal); var references = CollectTypeReferences(type) .Concat(memberList.SelectMany(CollectMemberTypeReferences)) .Concat(parameters.SelectMany(CollectParameterTypeReferences)) - .Concat(attributeReferences); + .Concat(attributeReferences) + .Concat(primaryParameterAttributeReferences); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, memberList), CSharpFormatter.StripArity(type.Name), - attributeReferences); + qualificationOnlyAttributeReferences); - List lines = [plan.Apply(RenderTypeDeclarationCore(type, options))]; + string typeDeclaration = AddPrimaryConstructorParameters( + type, + RenderTypeDeclarationCore(type, options), + options, + parameters); + List lines = [plan.Apply(typeDeclaration)]; lines.Add("{"); foreach (var member in memberList) { @@ -168,7 +191,9 @@ public static string RenderTypeDeclaration( $"Delegate '{type.FullName}' requires a structured Invoke signature."); } - var delegateAttributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); + var delegateAttributeReferences = CollectAttributeTypeReferences(type.Attributes) + .Concat(CollectAttributeTypeReferences(delegateInvoke.Attributes, delegateInvoke)) + .ToHashSet(StringComparer.Ordinal); var references = CollectTypeReferences(type) .Concat(CollectMemberTypeReferences(delegateInvoke)) .Concat(delegateAttributeReferences) @@ -191,31 +216,27 @@ public static string RenderTypeDeclaration( } var attributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); + var parameterAttributeReferences = parameters + .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) + .ToHashSet(StringComparer.Ordinal); + var qualificationOnlyAttributeReferences = attributeReferences + .Concat(parameters.SelectMany(parameter => + CollectAttributeArgumentTypeReferences(parameter.Attributes))) + .ToHashSet(StringComparer.Ordinal); var plan = TypeNamePlan.Create( CollectTypeReferences(type) .Concat(parameters.SelectMany(CollectParameterTypeReferences)) - .Concat(attributeReferences), + .Concat(attributeReferences) + .Concat(parameterAttributeReferences), options, CollectShadowingNames(type, []), CSharpFormatter.StripArity(type.Name), - attributeReferences); - string declaration = RenderTypeDeclarationCore(type, options); - if (parameters.Count > 0) - { - string declarationWithoutAttributes = RenderTypeDeclarationCore( - type, - options with { IncludeCustomAttributes = false }); - if (!declaration.EndsWith(declarationWithoutAttributes, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"C# type declaration for '{type.FullName}' has an unexpected attribute prefix."); - } - - declaration = declaration[..^declarationWithoutAttributes.Length] - + AddPrimaryConstructorParameters( - declarationWithoutAttributes, - parameters); - } + qualificationOnlyAttributeReferences); + string declaration = AddPrimaryConstructorParameters( + type, + RenderTypeDeclarationCore(type, options), + options, + parameters); return plan.Apply(declaration); } @@ -267,7 +288,7 @@ internal static ( var attributeTypeRefs = scopeList .SelectMany(scope => CollectAttributeTypeReferences(scope.Type.Attributes) .Concat(scope.Members.SelectMany(member => - CollectAttributeTypeReferences(member.Attributes)))) + CollectAttributeTypeReferences(member.Attributes, member)))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -345,25 +366,135 @@ static IEnumerable CollectParameterTypeReferences(ApiParameter parameter yield return reference; } - static IEnumerable CollectAttributeTypeReferences(IEnumerable attributes) + static IEnumerable CollectAttributeTypeReferences( + IEnumerable attributes, + ApiMember? member = null) { - foreach (var attribute in attributes) + foreach (var attribute in AttributeTexts(attributes, member)) + { foreach (var reference in ExtractQualifiedTypeNames(StripAttributeArguments(attribute))) yield return reference; + foreach (var reference in CollectAttributeArgumentTypeReferences([attribute])) + yield return reference; + } + } + + static IEnumerable CollectAttributeArgumentTypeReferences( + IEnumerable attributes, + ApiMember? member = null) + { + foreach (var attribute in AttributeTexts(attributes, member)) + { + int firstArgumentList = attribute.IndexOf('(', StringComparison.Ordinal); + for (var index = 0; index < attribute.Length; index++) + { + if (IsStringLiteralStart(attribute, index)) + { + index = SkipStringLiteral(attribute, index) - 1; + continue; + } + if (attribute[index] == '\'') + { + index = SkipCharLiteral(attribute, index) - 1; + continue; + } + + bool isTypeOf = attribute.AsSpan(index).StartsWith("typeof(", StringComparison.Ordinal); + bool isNestedCast = attribute[index] == '(' + && index > firstArgumentList; + if (!isTypeOf && !isNestedCast) + continue; + + int open = isTypeOf ? index + "typeof".Length : index; + int close = attribute.IndexOf(')', open + 1); + if (close < 0) + break; + if (isNestedCast) + { + int next = close + 1; + while (next < attribute.Length && char.IsWhiteSpace(attribute[next])) + next++; + if (next >= attribute.Length + || (attribute[next] is not '+' and not '-' + && !char.IsAsciiDigit(attribute[next]))) + { + continue; + } + } + + foreach (var reference in ExtractQualifiedTypeNames(attribute[(open + 1)..close])) + yield return reference; + index = close; + } + } + } + + static IEnumerable CollectQualificationOnlyAttributeTypeReferences( + IEnumerable memberAttributes, + ApiMember member) + { + foreach (var reference in CollectAttributeTypeReferences(memberAttributes)) + yield return reference; + if (member.SignatureModel is not { } signature) + yield break; + + foreach (var reference in CollectAttributeTypeReferences(signature.ReturnAttributes)) + yield return reference; + foreach (var accessor in signature.Accessors) + foreach (var reference in CollectAttributeTypeReferences(accessor.ReturnAttributes)) + yield return reference; + foreach (var parameter in signature.Parameters) + foreach (var reference in CollectAttributeArgumentTypeReferences(parameter.Attributes)) + yield return reference; + } + + static IEnumerable AttributeTexts( + IEnumerable attributes, + ApiMember? member) + { + foreach (var attribute in attributes) + yield return attribute; + if (member?.SignatureModel is not { } signature) + yield break; + + foreach (var attribute in signature.ReturnAttributes) + yield return attribute; + foreach (var parameter in signature.Parameters) + foreach (var attribute in parameter.Attributes) + yield return attribute; + foreach (var accessor in signature.Accessors) + foreach (var attribute in accessor.ReturnAttributes) + yield return attribute; } static string AddPrimaryConstructorParameters( + ApiType type, string declaration, + CSharpDeclarationOptions options, IReadOnlyList parameters) { + if (parameters.Count == 0) + return declaration; + string declarationWithoutAttributes = RenderTypeDeclarationCore( + type, + options with { IncludeCustomAttributes = false }); + if (!declaration.EndsWith(declarationWithoutAttributes, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"C# type declaration for '{type.FullName}' has an unexpected attribute prefix."); + } + string parameterList = CSharpFormatter.FormatParameterList(parameters); - int constraints = declaration.IndexOf(" where ", StringComparison.Ordinal); - string head = constraints >= 0 ? declaration[..constraints] : declaration; - string tail = constraints >= 0 ? declaration[constraints..] : ""; + int constraints = declarationWithoutAttributes.IndexOf(" where ", StringComparison.Ordinal); + string head = constraints >= 0 + ? declarationWithoutAttributes[..constraints] + : declarationWithoutAttributes; + string tail = constraints >= 0 ? declarationWithoutAttributes[constraints..] : ""; int inheritance = head.IndexOf(" : ", StringComparison.Ordinal); - return inheritance >= 0 + string withParameters = inheritance >= 0 ? head[..inheritance] + parameterList + head[inheritance..] + tail : $"{head}{parameterList}{tail}"; + return declaration[..^declarationWithoutAttributes.Length] + withParameters; } static HashSet CollectShadowingNames( @@ -984,10 +1115,17 @@ static IEnumerable ExtractQualifiedTypeNames(string expression) { foreach (var token in DottedIdentifierTokens(expression)) { - if (token.Contains('.', StringComparison.Ordinal) - && !token.StartsWith("global.", StringComparison.Ordinal) - && !token.StartsWith("global::", StringComparison.Ordinal)) - yield return token; + if (!token.Contains('.', StringComparison.Ordinal) + || token.StartsWith("global.", StringComparison.Ordinal) + || token.StartsWith("global::", StringComparison.Ordinal)) + { + continue; + } + + string normalized = token.StartsWith('@') ? token[1..] : token; + yield return normalized + .Replace(".@", ".", StringComparison.Ordinal) + .Replace("+@", "+", StringComparison.Ordinal); } } @@ -1005,14 +1143,17 @@ static IEnumerable DottedIdentifierTokens(string text) i = SkipCharLiteral(text, i); continue; } - if (!IsIdentifierStart(text[i])) + if (!IsIdentifierStart(text[i]) + && (text[i] != '@' + || i + 1 >= text.Length + || !IsIdentifierStart(text[i + 1]))) { i++; continue; } var start = i++; - while (i < text.Length && (IsIdentifierPart(text[i]) || text[i] is '.' or '+')) + while (i < text.Length && (IsIdentifierPart(text[i]) || text[i] is '.' or '+' or '@')) i++; yield return text[start..i].TrimEnd('.'); } @@ -2443,7 +2584,7 @@ void KeepResolvableQualified(TypeRef typeRef) continue; } - replacements[EscapeNamespace(typeRef.FullName)] = typeRef.SimpleName; + replacements[EscapeNamespace(typeRef.FullName)] = EscapeIdentifier(typeRef.SimpleName); if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings && !isSameNamespace) generatedUsings.Add(typeRef.Namespace); } @@ -2599,8 +2740,6 @@ sealed record TypeRef(string FullName, string Namespace, string SimpleName) return null; var ns = value[..lastDot]; var simple = StripArity(value[(lastDot + 1)..]); - if (CSharpKeywords.RequiresDeclarationEscape(simple)) - return null; return new TypeRef(value, ns, simple); } diff --git a/src/ILInspector.CSharp/CSharpFormatter.cs b/src/ILInspector.CSharp/CSharpFormatter.cs index 8d3b1c3056..3851313fc5 100644 --- a/src/ILInspector.CSharp/CSharpFormatter.cs +++ b/src/ILInspector.CSharp/CSharpFormatter.cs @@ -150,8 +150,13 @@ public string FormatDelegate(ApiType type, ApiMember invoke) public CSharpFormattedDeclaration FormatTypeUnit( ApiType type, - IEnumerable? members = null, - IReadOnlyList? primaryConstructorParameters = null) + IEnumerable? members = null) + => FormatTypeUnit(type, members, primaryConstructorParameters: null); + + internal CSharpFormattedDeclaration FormatTypeUnit( + ApiType type, + IEnumerable? members, + IReadOnlyList? primaryConstructorParameters) { ArgumentNullException.ThrowIfNull(type); return ToFormattedDeclaration(CSharpDeclarationWriter.RenderTypeUnit( diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index aca76c3a46..dfd20c7aee 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -62,6 +62,10 @@ public CSharpTypePrintResult PrintBatch( .Concat(derivedUsings) .ToImmutableHashSet(StringComparer.Ordinal) : ImmutableHashSet.Create(StringComparer.Ordinal); + var importedDeclaredTypeNames = preparedTypes + .Where(type => emittedUsings.Contains(type.Namespace)) + .Select(type => CSharpFormatter.StripArity(type.Type.Name)) + .ToImmutableHashSet(StringComparer.Ordinal); var units = ImmutableArray.CreateBuilder(); foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) @@ -84,6 +88,7 @@ public CSharpTypePrintResult PrintBatch( .Where(sibling => !ReferenceEquals(sibling, type)) .Select(sibling => CSharpFormatter.StripArity(sibling.Type.Name)) .Concat(ancestorTypeNames) + .Concat(importedDeclaredTypeNames) .ToImmutableHashSet(StringComparer.Ordinal), typeNameContext.KnownNamespaces, diagnostics))); @@ -462,9 +467,13 @@ static IEnumerable RenderProperty( { var accessors = new List(); if (body.Getter is not null) - accessors.Add(AccessorHead(member.Member, "get") + ";"); + accessors.Add(AccessorHead(type.Type, member.Member, "get", formatter) + ";"); if (body.Setter is not null) - accessors.Add(AccessorHead(member.Member, SetterKeyword(member.Member)) + ";"); + accessors.Add(AccessorHead( + type.Type, + member.Member, + SetterKeyword(member.Member), + formatter) + ";"); return [$"{PadDeclaration(declaration, pad)} {{ {string.Join(" ", accessors)} }}"]; } @@ -473,8 +482,15 @@ static IEnumerable RenderProperty( PadDeclaration(declaration, pad), $"{pad}{{" }; - AddAccessor(lines, member.Member, "get", body.Getter, indent + 1); - AddAccessor(lines, member.Member, SetterKeyword(member.Member), body.Setter, indent + 1); + AddAccessor(lines, type.Type, member.Member, "get", body.Getter, formatter, indent + 1); + AddAccessor( + lines, + type.Type, + member.Member, + SetterKeyword(member.Member), + body.Setter, + formatter, + indent + 1); lines.Add($"{pad}}}"); return lines; } @@ -511,23 +527,25 @@ static IEnumerable RenderEvent( PadDeclaration(declaration, pad), $"{pad}{{" }; - AddAccessor(lines, member.Member, "add", body.Adder, indent + 1); - AddAccessor(lines, member.Member, "remove", body.Remover, indent + 1); + AddAccessor(lines, type.Type, member.Member, "add", body.Adder, formatter, indent + 1); + AddAccessor(lines, type.Type, member.Member, "remove", body.Remover, formatter, indent + 1); lines.Add($"{pad}}}"); return lines; } static void AddAccessor( List lines, + ApiType declaringType, ApiMember member, string kind, CSharpAccessorBody? body, + CSharpFormatter formatter, int indent) { if (body is null) return; string pad = new(' ', indent * 4); - string head = AccessorHead(member, kind); + string head = AccessorHead(declaringType, member, kind, formatter); if (body.Kind == CSharpAccessorBodyKind.Auto) { lines.Add($"{pad}{head};"); @@ -544,13 +562,38 @@ static void AddAccessor( lines.Add($"{pad}}}"); } - static string AccessorHead(ApiMember member, string kind) + static string AccessorHead( + ApiType declaringType, + ApiMember member, + string kind, + CSharpFormatter formatter) { var accessor = member.SignatureModel?.Accessors .FirstOrDefault(candidate => candidate.Kind == kind); var parts = new List(); if (accessor?.ReturnAttributes is { Count: > 0 } returnAttributes) - parts.Add($"[return: {string.Join(", ", returnAttributes)}]"); + { + var attributeProbe = new ApiMember + { + Name = "__AccessorAttributeProbe", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "void", + MemberName = "__AccessorAttributeProbe", + ReturnAttributes = returnAttributes + } + }; + string formattedProbe = formatter.FormatMember(declaringType, attributeProbe); + attributeProbe.SignatureModel.ReturnAttributes = []; + string formattedDeclaration = formatter.FormatMember(declaringType, attributeProbe); + if (!formattedProbe.EndsWith(formattedDeclaration, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"C# accessor '{member.Name}.{kind}' return attributes were not rendered."); + } + parts.Add(formattedProbe[..^formattedDeclaration.Length].TrimEnd()); + } if (!string.IsNullOrWhiteSpace(accessor?.Accessibility)) parts.Add(accessor.Accessibility!); parts.Add(kind); diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index 57ef86b821..2b9da6e340 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"`0(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"F8EB70D17DB02B261CC738AF4FFA650DFC307AB5D6FCF0732BF09D51F54E1C27","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration && NeedsTerminator(declaration))\n declaration += \";\";\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663538,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663541,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} From 29a83b4603da997a6e4ead4d04f2494e260fdac7 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 04:37:58 -0700 Subject: [PATCH 04/18] Fix reviewed type rewrite edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpFormatterTests.cs | 81 ++++++++++++++++ .../CSharpTypePrinterTests.cs | 66 ++++++++++++- .../CSharpDeclarationWriter.cs | 96 ++++++++++++++++--- 3 files changed, 231 insertions(+), 12 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index 9b16d2de90..b7a8cb0c93 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -499,6 +499,46 @@ public void QualifiedPolicyEscapesShadowedKeywordNamespaceRoot() Assert.DoesNotContain("@global::", declaration.Text, StringComparison.Ordinal); } + [Theory] + [InlineData(CSharpTypeNamePolicy.Qualified, "public @event.Models.Widget GetWidget()", false)] + [InlineData(CSharpTypeNamePolicy.ShortWithUsings, "public Widget GetWidget()", true)] + [InlineData(CSharpTypeNamePolicy.ContextualShort, "public Widget GetWidget()", false)] + public void KeywordNamespaceRootMatchesTypeNamePlan( + CSharpTypeNamePolicy policy, + string expectedDeclaration, + bool importNamespace) + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class" + }; + var member = new ApiMember + { + Name = "GetWidget", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "event.Models.Widget", + MemberName = "GetWidget" + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = policy, + Usings = policy == CSharpTypeNamePolicy.ContextualShort + ? ["event.Models"] + : [] + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains(expectedDeclaration, declaration.Text, StringComparison.Ordinal); + if (importNamespace) + Assert.Contains("using @event.Models;", declaration.Text, StringComparison.Ordinal); + } + [Fact] public void QualifiedPolicyEscapesKeywordTypeWithShadowedRoot() { @@ -627,6 +667,47 @@ public void FormatsDelegateWithStructuredAccessibility() new CSharpFormatter().FormatDelegate(type, type.Members.Single())); } + [Fact] + public void FormatsDelegateWithContainedHostileName() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Call\nback", + Kind = "delegate", + Accessibility = "private" + }; + var invoke = new ApiMember + { + Name = "Invoke", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "void", + MemberName = "Invoke" + } + }; + + Assert.Equal( + "private delegate void Call_back();", + new CSharpFormatter().FormatDelegate(type, invoke)); + } + + [Fact] + public void FormatsNestedTypeNameWithoutFoldingItsNestingSeparator() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Outer.Inner", + Kind = "class" + }; + + Assert.Equal( + "public class Outer.Inner", + new CSharpFormatter().FormatTypeDeclaration(type)); + } + [Fact] public void KnownIdentifierEscapingIsIdempotent() { diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index b0a1ce6f16..1831fa56cc 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -1176,7 +1176,7 @@ public void AttributeArgumentEnumAccessDoesNotDeriveTypeAsNamespace() Kind = "method", SignatureModel = new ApiSignature { - ReturnType = "int", + ReturnType = "System.Runtime.InteropServices.UnmanagedType", MemberName = "Encode", Parameters = [ @@ -1204,6 +1204,70 @@ public void AttributeArgumentEnumAccessDoesNotDeriveTypeAsNamespace() "[MarshalAs(System.Runtime.InteropServices.UnmanagedType.I4)]", result.Units[0].Source, StringComparison.Ordinal); + Assert.DoesNotContain( + "[MarshalAs(UnmanagedType.I4)]", + result.Units[0].Source, + StringComparison.Ordinal); + } + + [Fact] + public void DottedAttributeValueKeepsQualificationButStillEscapesAndRequalifies() + { + var type = CreateEmptyType("App", "Samples"); + var member = CreateMethod("GetColor"); + member.SignatureModel!.ReturnType = "Samples.Models.Color"; + member.Attributes = + [ + "System.ComponentModel.DefaultValue(Samples.Models.Color.Red)", + "System.ComponentModel.Description(\"Items[0]\")" + ]; + type.Members.Add(member); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains( + "[System.ComponentModel.DefaultValue(global::Samples.Models.Color.Red)]", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "public global::Samples.Models.Color GetColor();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void KeywordRootInDottedAttributeValueIsEscapedWithoutShortening() + { + var type = CreateEmptyType("Samples", "Worker"); + var member = CreateMethod("GetColor"); + member.SignatureModel!.ReturnType = "event.Models.Color"; + member.Attributes = + [ + "System.ComponentModel.DefaultValue(event.Models.Color.Red)" + ]; + type.Members.Add(member); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + IncludeCustomAttributes = true + }); + + Assert.Contains("using @event.Models;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "@event.Models.Color.Red", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("DefaultValue(Color.Red)", result.Source, StringComparison.Ordinal); + Assert.Contains("public Color GetColor();", result.Source, StringComparison.Ordinal); } [Fact] diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index ff06b128b5..bd96190baa 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -597,7 +597,7 @@ static string ComposeUnit(IReadOnlyList bodyLines, IReadOnlyList { var sb = new StringBuilder(); foreach (var ns in usings) - sb.AppendLf($"using {ns};"); + sb.AppendLf($"using {EscapeNamespace(ns)};"); if (usings.Count > 0) sb.AppendLf(); @@ -605,7 +605,7 @@ static string ComposeUnit(IReadOnlyList bodyLines, IReadOnlyList if (options.NamespaceMode == CSharpNamespaceMode.FileScoped && !string.IsNullOrWhiteSpace(options.ContainingNamespace)) { - sb.AppendLf($"namespace {options.ContainingNamespace};"); + sb.AppendLf($"namespace {EscapeNamespace(options.ContainingNamespace)};"); sb.AppendLf(); } @@ -1650,7 +1650,7 @@ static string FormatTypeDisplayName(string name, IReadOnlyList ty var tick = name.IndexOf('`'); if (tick >= 0) name = name[..tick]; - name = EscapeQualifiedIdentifier(name); + name = ContainQualifiedName(name); if (typeParameters.Count > 0) name += $"<{string.Join(", ", typeParameters.Select(TypeParameterDisplayName))}>"; return name; @@ -2439,14 +2439,14 @@ static int SkipInterpolationHole(string text, int open, int depth) } sealed record TypeNamePlan( - IReadOnlyDictionary Replacements, + IReadOnlyDictionary Replacements, IReadOnlyList GeneratedUsings, IReadOnlyList Diagnostics) { public string Apply(string text) { - foreach (var (qualified, replacement) in Replacements.OrderByDescending(kvp => kvp.Key.Length)) - text = ReplaceIdentifierToken(text, qualified, replacement); + foreach (var (qualified, replacements) in Replacements.OrderByDescending(kvp => kvp.Key.Length)) + text = ReplaceIdentifierToken(text, qualified, replacements); return text; } @@ -2526,13 +2526,30 @@ public static TypeNamePlan Create( var contextualUsings = options.Usings.ToHashSet(StringComparer.Ordinal); var generatedUsings = new SortedSet(StringComparer.Ordinal); var diagnostics = new List(); - var replacements = new Dictionary(StringComparer.Ordinal); + var replacements = new Dictionary( + StringComparer.Ordinal); + void ReplaceQualifiedName( + TypeRef typeRef, + string qualifiedReplacement, + string? shortenedReplacement = null) + { + var plan = (qualifiedReplacement, shortenedReplacement); + replacements[EscapeQualifiedKeywordSegments(typeRef.FullName)] = plan; + replacements[EscapeNamespace(typeRef.FullName)] = plan; + } void KeepResolvableQualified(TypeRef typeRef) { if (rootShadowingNames.Contains(NamespaceRoot(typeRef.Namespace))) { string escapedFullName = EscapeNamespace(typeRef.FullName); - replacements[escapedFullName] = $"global::{escapedFullName}"; + ReplaceQualifiedName(typeRef, $"global::{escapedFullName}"); + } + else + { + string renderedFullName = EscapeQualifiedKeywordSegments(typeRef.FullName); + string escapedFullName = EscapeNamespace(typeRef.FullName); + if (!string.Equals(renderedFullName, escapedFullName, StringComparison.Ordinal)) + replacements[renderedFullName] = (escapedFullName, null); } } @@ -2584,7 +2601,10 @@ void KeepResolvableQualified(TypeRef typeRef) continue; } - replacements[EscapeNamespace(typeRef.FullName)] = EscapeIdentifier(typeRef.SimpleName); + ReplaceQualifiedName( + typeRef, + EscapeNamespace(typeRef.FullName), + EscapeIdentifier(typeRef.SimpleName)); if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings && !isSameNamespace) generatedUsings.Add(typeRef.Namespace); } @@ -2592,7 +2612,10 @@ void KeepResolvableQualified(TypeRef typeRef) return new TypeNamePlan(replacements, generatedUsings.ToList(), diagnostics); } - static string ReplaceIdentifierToken(string text, string token, string replacement) + static string ReplaceIdentifierToken( + string text, + string token, + (string Qualified, string? Shortened) replacements) { var sb = new StringBuilder(text.Length); for (var i = 0; i < text.Length;) @@ -2621,6 +2644,13 @@ static string ReplaceIdentifierToken(string text, string token, string replaceme sb.Append(text[i++]); continue; } + bool preserveQualification = IsAttributeValuePrefix( + text, + i, + i + token.Length); + string replacement = preserveQualification + ? replacements.Qualified + : replacements.Shortened ?? replacements.Qualified; sb.Append(replacement); i += token.Length; continue; @@ -2648,12 +2678,56 @@ static bool IsWithinGlobalAlias(string text, int index) static bool IsStartBoundary(string text, int index) => index < 0 || index >= text.Length - || (!IsIdentifierPart(text[index]) && text[index] is not '.' and not '+'); + || (!IsIdentifierPart(text[index]) && text[index] is not '.' and not '+' and not '@'); static bool IsEndBoundary(string text, int index) => index < 0 || index >= text.Length || (!IsIdentifierPart(text[index]) && text[index] != '+'); + + static bool IsAttributeValuePrefix(string text, int start, int end) + { + if (end >= text.Length || text[end] != '.') + return false; + + int parenthesisDepth = 0; + var bracketParenthesisDepths = new Stack(); + for (int index = 0; index < start;) + { + if (IsStringLiteralStart(text, index)) + { + index = SkipStringLiteral(text, index); + continue; + } + if (text[index] == '\'') + { + index = SkipCharLiteral(text, index); + continue; + } + + switch (text[index]) + { + case '[': + bracketParenthesisDepths.Push(parenthesisDepth); + break; + case ']': + if (bracketParenthesisDepths.Count > 0) + bracketParenthesisDepths.Pop(); + break; + case '(': + parenthesisDepth++; + break; + case ')': + if (parenthesisDepth > 0) + parenthesisDepth--; + break; + } + index++; + } + + return bracketParenthesisDepths.Count > 0 + && parenthesisDepth > bracketParenthesisDepths.Peek(); + } } static bool IsStringLiteralStart(string text, int index) From 09c6e0ae2ecbc9692c164d5c5a6b1eb49284e3e5 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 05:22:40 -0700 Subject: [PATCH 05/18] Qualify roots shadowed from global namespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 20 +++++++++++++++++++ src/ILInspector.CSharp/CSharpTypePrinter.cs | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 1831fa56cc..72b029b802 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -1607,6 +1607,26 @@ public void AncestorNamespaceTypeNameTriggersGlobalAlias() Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); } + [Fact] + public void GlobalNamespaceTypeNameTriggersGlobalAliasInNamespacedUnit() + { + var system = CreateEmptyType("", "System"); + var worker = CreateEmptyType("Samples", "Worker"); + var uri = CreateMethod("GetUri"); + uri.SignatureModel!.ReturnType = "System.Uri"; + worker.Members.Add(uri); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains("public class System", result.Source, StringComparison.Ordinal); + Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + } + [Fact] public void SiblingTypeReferenceRemainsShort() { diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index dfd20c7aee..1bd5508be8 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -145,7 +145,7 @@ void Flatten(PreparedType prepared) static bool IsAncestorNamespace(string candidate, string descendant) => candidate.Length < descendant.Length && descendant.StartsWith(candidate, StringComparison.Ordinal) - && descendant[candidate.Length] == '.'; + && (candidate.Length == 0 || descendant[candidate.Length] == '.'); static IReadOnlyList TypeNameContext( CSharpTypePrintOptions options, From e70424da0e21a1ecc14b5ded40c95cc7677c337f Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 09:35:03 -0700 Subject: [PATCH 06/18] Resolve ambiguous type rewrite provenance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpFormatterTests.cs | 29 ++ .../CSharpTypePrinterTests.cs | 278 +++++++++++++++++- .../CSharpDeclarationWriter.cs | 246 +++++++++++++--- src/ILInspector.CSharp/CSharpFormatter.cs | 4 + src/ILInspector.CSharp/CSharpTypePrinter.cs | 54 +++- .../ReturnToSenderTypePlanner.cs | 34 ++- .../corpus/two-row-authored-corpus.jsonl | 2 +- 7 files changed, 572 insertions(+), 75 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index b7a8cb0c93..9b62b785cd 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -499,6 +499,35 @@ public void QualifiedPolicyEscapesShadowedKeywordNamespaceRoot() Assert.DoesNotContain("@global::", declaration.Text, StringComparison.Ordinal); } + [Fact] + public void QualifiedPolicyEscapesKeywordRootInsideExistingGlobalAlias() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class" + }; + var member = new ApiMember + { + Name = "GetWidget", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "global::event.Models.Widget", + MemberName = "GetWidget" + } + }; + + var declaration = new CSharpFormatter().FormatMemberUnit(type, member); + + Assert.Contains( + "public global::@event.Models.Widget GetWidget()", + declaration.Text, + StringComparison.Ordinal); + Assert.DoesNotContain("global::global::", declaration.Text, StringComparison.Ordinal); + } + [Theory] [InlineData(CSharpTypeNamePolicy.Qualified, "public @event.Models.Widget GetWidget()", false)] [InlineData(CSharpTypeNamePolicy.ShortWithUsings, "public Widget GetWidget()", true)] diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 72b029b802..4158cd1b51 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -1208,14 +1208,15 @@ public void AttributeArgumentEnumAccessDoesNotDeriveTypeAsNamespace() "[MarshalAs(UnmanagedType.I4)]", result.Units[0].Source, StringComparison.Ordinal); + Assert.Contains("public UnmanagedType Encode(", result.Source, StringComparison.Ordinal); } [Fact] - public void DottedAttributeValueKeepsQualificationButStillEscapesAndRequalifies() + public void DottedAttributeValueSharingDeclaredTypeRootUsesGlobalNamespace() { var type = CreateEmptyType("App", "Samples"); var member = CreateMethod("GetColor"); - member.SignatureModel!.ReturnType = "Samples.Models.Color"; + member.SignatureModel!.ReturnType = "int"; member.Attributes = [ "System.ComponentModel.DefaultValue(Samples.Models.Color.Red)", @@ -1236,7 +1237,60 @@ public void DottedAttributeValueKeepsQualificationButStillEscapesAndRequalifies( result.Source, StringComparison.Ordinal); Assert.Contains( - "public global::Samples.Models.Color GetColor();", + "public int GetColor();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DeclaredNestedPathInOtherNamespaceDoesNotCaptureAttributeValue() + { + var otherContainer = CreateEmptyType("Other", "Container"); + var kind = CreateEmptyType("Other", "Kind"); + var appContainer = CreateEmptyType("App", "Container"); + appContainer.Attributes = ["Ext.Opt(Container.Kind.Fast)"]; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + otherContainer, + nestedTypes: [new CSharpTypePrintRequest(kind)]), + new CSharpTypePrintRequest(appContainer) + ], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains( + "[Ext.Opt(global::Container.Kind.Fast)]", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DeclaredNestedPathDoesNotCaptureKnownNamespaceReference() + { + var system = CreateEmptyType("App", "System"); + var uri = CreateEmptyType("App", "Uri"); + system.Attributes = ["Ext.Opt(System.Uri.SchemeDelimiter)"]; + var member = CreateMethod("Create"); + member.SignatureModel!.ReturnType = "System.Text.StringBuilder"; + system.Members.Add(member); + + var result = _printer.Print( + new CSharpTypePrintRequest( + system, + nestedTypes: [new CSharpTypePrintRequest(uri)]), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains( + "[Ext.Opt(global::System.Uri.SchemeDelimiter)]", result.Source, StringComparison.Ordinal); } @@ -1246,7 +1300,7 @@ public void KeywordRootInDottedAttributeValueIsEscapedWithoutShortening() { var type = CreateEmptyType("Samples", "Worker"); var member = CreateMethod("GetColor"); - member.SignatureModel!.ReturnType = "event.Models.Color"; + member.SignatureModel!.ReturnType = "int"; member.Attributes = [ "System.ComponentModel.DefaultValue(event.Models.Color.Red)" @@ -1261,13 +1315,144 @@ public void KeywordRootInDottedAttributeValueIsEscapedWithoutShortening() IncludeCustomAttributes = true }); - Assert.Contains("using @event.Models;", result.Source, StringComparison.Ordinal); Assert.Contains( "@event.Models.Color.Red", result.Source, StringComparison.Ordinal); Assert.DoesNotContain("DefaultValue(Color.Red)", result.Source, StringComparison.Ordinal); - Assert.Contains("public Color GetColor();", result.Source, StringComparison.Ordinal); + Assert.Contains("public int GetColor();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void DottedAttributeValueRootedAtGlobalTypeDoesNotAbortBatch() + { + var host = CreateEmptyType("", "Host"); + var worker = CreateEmptyType("Samples", "Worker"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "int"; + member.Attributes = ["Ext.Opt(Host.Options.Fast)"]; + worker.Members.Add(member); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(host), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains("[Ext.Opt(global::Host.Options.Fast)]", result.Source, StringComparison.Ordinal); + Assert.Empty(result.Diagnostics); + } + + [Fact] + public void TypeAndDelegateAttributeValuesKeepDeclaredTypeRoots() + { + var samples = CreateEmptyType("App", "Samples"); + samples.Attributes = ["Ext.Opt(Samples.Options.Fast)"]; + var options = CreateEmptyType("App", "Options"); + var handler = CreateEmptyType("App", "Handler"); + handler.Kind = "delegate"; + handler.Attributes = ["Ext.Opt(Samples.Options.Fast)"]; + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "void"; + handler.Members.Add(invoke); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + samples, + nestedTypes: [new CSharpTypePrintRequest(options)]), + new CSharpTypePrintRequest(handler) + ], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Equal( + 2, + result.Source.Split( + "[Ext.Opt(Samples.Options.Fast)]", + StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("global::Samples.Options.Fast", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void DottedAttributeValueDoesNotInventShadowingNamespace() + { + var type = CreateEmptyType("Lib.Sub", "Widget"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "Foo.Deep"; + member.Attributes = ["Ext.Opt(Lib.Sub.Deep.Const.Field)"]; + type.Members.Add(member); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + IncludeCustomAttributes = true + }); + + Assert.Contains("using Foo;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Deep Get();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("shadowed by a namespace", string.Join('\n', result.Diagnostics), StringComparison.Ordinal); + } + + [Fact] + public void SameNamespaceRootShadowRequalifiesDottedAttributeValue() + { + var widget = CreateEmptyType("Lib.Sub", "Widget"); + var member = CreateMethod("GetThing"); + member.SignatureModel!.ReturnType = "Lib.Sub.Thing"; + member.Attributes = ["Ext.Opt(Lib.Sub.Thing.Value)"]; + widget.Members.Add(member); + var lib = CreateEmptyType("Lib.Sub", "Lib"); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(widget), new CSharpTypePrintRequest(lib)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + IncludeCustomAttributes = true + }); + + Assert.Contains("[Ext.Opt(global::Lib.Sub.Thing.Value)]", result.Source, StringComparison.Ordinal); + Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void RawKeywordSegmentsArePlannedAcrossDeclarationSurfaces() + { + var type = CreateEmptyType("Samples", "Widget"); + type.BaseType = "Lib.event.Base"; + type.Interfaces.Add("Lib.event.IThing"); + type.Attributes = ["Lib.event.Marker"]; + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Lib.event.Color"; + var handler = CreateEmptyType("Samples", "Handler"); + handler.Kind = "delegate"; + handler.Members.Add(invoke); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(type), new CSharpTypePrintRequest(handler)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeCustomAttributes = true + }); + + Assert.Contains("[Lib.@event.Marker]", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public class Widget : Lib.@event.Base, Lib.@event.IThing", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + "public delegate Lib.@event.Color Handler();", + result.Source, + StringComparison.Ordinal); } [Fact] @@ -1608,7 +1793,7 @@ public void AncestorNamespaceTypeNameTriggersGlobalAlias() } [Fact] - public void GlobalNamespaceTypeNameTriggersGlobalAliasInNamespacedUnit() + public void GlobalNamespaceTypeConflictingWithNamespaceRootReportsDiagnostic() { var system = CreateEmptyType("", "System"); var worker = CreateEmptyType("Samples", "Worker"); @@ -1623,8 +1808,83 @@ public void GlobalNamespaceTypeNameTriggersGlobalAliasInNamespacedUnit() TypeNamePolicy = CSharpTypeNamePolicy.Qualified }); - Assert.Contains("public class System", result.Source, StringComparison.Ordinal); - Assert.Contains("public global::System.Uri GetUri();", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public global::System.Uri GetUri();", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains("conflicts with global type 'System'", StringComparison.Ordinal)); + } + + [Fact] + public void GlobalNamespaceTypeConflictRecognizesNestedNamespaceRoot() + { + var system = CreateEmptyType("", "System"); + var worker = CreateEmptyType("Samples", "Worker"); + var method = CreateMethod("GetItems"); + method.SignatureModel!.ReturnType = "System.Collections.Generic.List"; + worker.Members.Add(method); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(worker)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "global::System.Collections.Generic.List", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains("conflicts with global type 'System'", StringComparison.Ordinal)); + } + + [Fact] + public void DelegateWithGlobalNamespaceRootConflictReportsDiagnostic() + { + var system = CreateEmptyType("", "System"); + var handler = CreateEmptyType("Samples", "Handler"); + handler.Kind = "delegate"; + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "System.Uri"; + handler.Members.Add(invoke); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(handler)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "public delegate global::System.Uri Handler();", + result.Source, + StringComparison.Ordinal); + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.TypeName == "Samples.Handler" + && diagnostic.Message.Contains("conflicts with global type 'System'", StringComparison.Ordinal)); + } + + [Fact] + public void GlobalTypeCanReferenceItsDeclaredNestedType() + { + var host = CreateEmptyType("", "Host"); + var classify = CreateMethod("Classify"); + classify.SignatureModel!.ReturnType = "Host.Kind"; + host.Members.Add(classify); + var kind = CreateEmptyType("", "Kind"); + kind.Kind = "enum"; + + var result = _printer.Print(new CSharpTypePrintRequest( + host, + nestedTypes: [new CSharpTypePrintRequest(kind)])); + + Assert.Contains("public global::Host.Kind Classify();", result.Source, StringComparison.Ordinal); + Assert.Contains("public enum Kind", result.Source, StringComparison.Ordinal); } [Fact] diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index bd96190baa..a47bd9938b 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -26,6 +26,8 @@ internal sealed record CSharpDeclarationOptions // Fields avoid shifting the MethodDef tokens pinned by AuthoredCorpusHarnessProcessTests. public IReadOnlyCollection AdditionalShadowingNames = []; public IReadOnlyCollection AdditionalRootShadowingNames = []; + public IReadOnlyCollection AdditionalUnresolvableRootNames = []; + public IReadOnlyCollection AdditionalDeclaredTypeFullNames = []; public IReadOnlyCollection AdditionalKnownNamespaces = []; public CSharpNamespaceMode NamespaceMode { get; init; } = CSharpNamespaceMode.Omit; public bool AbbreviateSignature { get; init; } @@ -66,17 +68,22 @@ public static CSharpRenderedDeclaration RenderMemberUnit( { options ??= new CSharpDeclarationOptions(); var attributeReferences = CollectAttributeTypeReferences(member.Attributes, member).ToHashSet(StringComparer.Ordinal); + var attributeValueReferences = CollectAttributeValueTypeReferences(member.Attributes, member) + .ToHashSet(StringComparer.Ordinal); var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( member.Attributes, member) .ToHashSet(StringComparer.Ordinal); - var references = CollectMemberTypeReferences(member).Concat(attributeReferences); + var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); + var references = memberReferences.Concat(attributeReferences); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), - qualificationOnlyAttributeReferences); + qualificationOnlyAttributeReferences, + memberReferences, + attributeValueReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -95,17 +102,22 @@ public static string RenderMemberDeclaration( { options ??= new CSharpDeclarationOptions(); var attributeReferences = CollectAttributeTypeReferences(member.Attributes, member).ToHashSet(StringComparer.Ordinal); + var attributeValueReferences = CollectAttributeValueTypeReferences(member.Attributes, member) + .ToHashSet(StringComparer.Ordinal); var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( member.Attributes, member) .ToHashSet(StringComparer.Ordinal); - var references = CollectMemberTypeReferences(member).Concat(attributeReferences); + var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); + var references = memberReferences.Concat(attributeReferences); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), - qualificationOnlyAttributeReferences); + qualificationOnlyAttributeReferences, + memberReferences, + attributeValueReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); return options.TerminateMemberDeclaration && NeedsTerminator(declaration) @@ -125,7 +137,13 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var attributeReferences = CollectAttributeTypeReferences(type.Attributes) .Concat(memberList.SelectMany(member => CollectAttributeTypeReferences(member.Attributes, member))) .ToHashSet(StringComparer.Ordinal); - var qualificationOnlyAttributeReferences = CollectAttributeTypeReferences(type.Attributes) + var attributeValueReferences = CollectAttributeValueTypeReferences(type.Attributes) + .Concat(memberList.SelectMany(member => + CollectAttributeValueTypeReferences(member.Attributes, member))) + .Concat(parameters.SelectMany(parameter => + CollectAttributeValueTypeReferences(parameter.Attributes))) + .ToHashSet(StringComparer.Ordinal); + var qualificationOnlyAttributeReferences = CollectDeclaredAttributeTypeReferences(type.Attributes) .Concat(memberList.SelectMany(member => CollectQualificationOnlyAttributeTypeReferences(member.Attributes, member))) .Concat(parameters.SelectMany(parameter => @@ -134,8 +152,12 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var primaryParameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) .ToHashSet(StringComparer.Ordinal); - var references = CollectTypeReferences(type) + var shortenableReferences = CollectTypeReferences(type) .Concat(memberList.SelectMany(CollectMemberTypeReferences)) + .Concat(parameters.SelectMany(parameter => + ExtractQualifiedTypeNames(parameter.Type))) + .ToHashSet(StringComparer.Ordinal); + var references = shortenableReferences .Concat(parameters.SelectMany(CollectParameterTypeReferences)) .Concat(attributeReferences) .Concat(primaryParameterAttributeReferences); @@ -144,7 +166,9 @@ public static CSharpRenderedDeclaration RenderTypeUnit( options, CollectShadowingNames(type, memberList), CSharpFormatter.StripArity(type.Name), - qualificationOnlyAttributeReferences); + qualificationOnlyAttributeReferences, + shortenableReferences, + attributeValueReferences); string typeDeclaration = AddPrimaryConstructorParameters( type, @@ -194,8 +218,15 @@ public static string RenderTypeDeclaration( var delegateAttributeReferences = CollectAttributeTypeReferences(type.Attributes) .Concat(CollectAttributeTypeReferences(delegateInvoke.Attributes, delegateInvoke)) .ToHashSet(StringComparer.Ordinal); - var references = CollectTypeReferences(type) + var delegateAttributeValueReferences = CollectAttributeValueTypeReferences(type.Attributes) + .Concat(CollectAttributeValueTypeReferences(delegateInvoke.Attributes, delegateInvoke)) + .ToHashSet(StringComparer.Ordinal); + var delegateStrongReferences = CollectTypeReferences(type) .Concat(CollectMemberTypeReferences(delegateInvoke)) + .Concat(CollectDeclaredAttributeTypeReferences(type.Attributes)) + .Concat(CollectDeclaredAttributeTypeReferences(delegateInvoke.Attributes, delegateInvoke)) + .ToHashSet(StringComparer.Ordinal); + var references = delegateStrongReferences .Concat(delegateAttributeReferences) .ToList(); var delegatePlan = TypeNamePlan.Create( @@ -203,7 +234,8 @@ public static string RenderTypeDeclaration( options, CollectShadowingNames(type, [delegateInvoke]), CSharpFormatter.StripArity(type.Name), - references.ToHashSet(StringComparer.Ordinal)); + delegateStrongReferences, + valueReferences: delegateAttributeValueReferences); string attributes = options.IncludeCustomAttributes && type.Attributes.Count > 0 ? string.Join("\n", type.Attributes.Select(attribute => $"[{attribute}]")) + "\n" : ""; @@ -216,22 +248,32 @@ public static string RenderTypeDeclaration( } var attributeReferences = CollectAttributeTypeReferences(type.Attributes).ToHashSet(StringComparer.Ordinal); + var attributeValueReferences = CollectAttributeValueTypeReferences(type.Attributes) + .Concat(parameters.SelectMany(parameter => + CollectAttributeValueTypeReferences(parameter.Attributes))) + .ToHashSet(StringComparer.Ordinal); var parameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) .ToHashSet(StringComparer.Ordinal); - var qualificationOnlyAttributeReferences = attributeReferences + var qualificationOnlyAttributeReferences = CollectDeclaredAttributeTypeReferences(type.Attributes) .Concat(parameters.SelectMany(parameter => CollectAttributeArgumentTypeReferences(parameter.Attributes))) .ToHashSet(StringComparer.Ordinal); + var shortenableReferences = CollectTypeReferences(type) + .Concat(parameters.SelectMany(parameter => + ExtractQualifiedTypeNames(parameter.Type))) + .ToHashSet(StringComparer.Ordinal); var plan = TypeNamePlan.Create( - CollectTypeReferences(type) + shortenableReferences .Concat(parameters.SelectMany(CollectParameterTypeReferences)) .Concat(attributeReferences) .Concat(parameterAttributeReferences), options, CollectShadowingNames(type, []), CSharpFormatter.StripArity(type.Name), - qualificationOnlyAttributeReferences); + qualificationOnlyAttributeReferences, + shortenableReferences, + attributeValueReferences); string declaration = AddPrimaryConstructorParameters( type, RenderTypeDeclarationCore(type, options), @@ -286,9 +328,9 @@ internal static ( .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); var attributeTypeRefs = scopeList - .SelectMany(scope => CollectAttributeTypeReferences(scope.Type.Attributes) + .SelectMany(scope => CollectDeclaredAttributeTypeReferences(scope.Type.Attributes) .Concat(scope.Members.SelectMany(member => - CollectAttributeTypeReferences(member.Attributes, member)))) + CollectDeclaredAttributeTypeReferences(member.Attributes, member)))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -369,6 +411,12 @@ static IEnumerable CollectParameterTypeReferences(ApiParameter parameter static IEnumerable CollectAttributeTypeReferences( IEnumerable attributes, ApiMember? member = null) + => CollectDeclaredAttributeTypeReferences(attributes, member) + .Concat(CollectAttributeValueTypeReferences(attributes, member)); + + static IEnumerable CollectDeclaredAttributeTypeReferences( + IEnumerable attributes, + ApiMember? member = null) { foreach (var attribute in AttributeTexts(attributes, member)) { @@ -429,19 +477,45 @@ static IEnumerable CollectAttributeArgumentTypeReferences( } } + static IEnumerable CollectAttributeValueTypeReferences( + IEnumerable attributes, + ApiMember? member = null) + { + foreach (var attribute in AttributeTexts(attributes, member)) + { + int firstArgumentList = attribute.IndexOf('(', StringComparison.Ordinal); + if (firstArgumentList < 0) + continue; + var recognizedReferences = CollectAttributeArgumentTypeReferences([attribute]) + .ToHashSet(StringComparer.Ordinal); + foreach (var valueExpression in ExtractQualifiedTypeNames( + attribute[(firstArgumentList + 1)..])) + { + if (recognizedReferences.Contains(valueExpression)) + continue; + int memberSeparator = valueExpression.LastIndexOf('.'); + if (memberSeparator <= 0) + continue; + string declaringType = valueExpression[..memberSeparator]; + if (declaringType.Contains('.', StringComparison.Ordinal)) + yield return declaringType; + } + } + } + static IEnumerable CollectQualificationOnlyAttributeTypeReferences( IEnumerable memberAttributes, ApiMember member) { - foreach (var reference in CollectAttributeTypeReferences(memberAttributes)) + foreach (var reference in CollectDeclaredAttributeTypeReferences(memberAttributes)) yield return reference; if (member.SignatureModel is not { } signature) yield break; - foreach (var reference in CollectAttributeTypeReferences(signature.ReturnAttributes)) + foreach (var reference in CollectDeclaredAttributeTypeReferences(signature.ReturnAttributes)) yield return reference; foreach (var accessor in signature.Accessors) - foreach (var reference in CollectAttributeTypeReferences(accessor.ReturnAttributes)) + foreach (var reference in CollectDeclaredAttributeTypeReferences(accessor.ReturnAttributes)) yield return reference; foreach (var parameter in signature.Parameters) foreach (var reference in CollectAttributeArgumentTypeReferences(parameter.Attributes)) @@ -2439,13 +2513,13 @@ static int SkipInterpolationHole(string text, int open, int depth) } sealed record TypeNamePlan( - IReadOnlyDictionary Replacements, + IReadOnlyList> Replacements, IReadOnlyList GeneratedUsings, IReadOnlyList Diagnostics) { public string Apply(string text) { - foreach (var (qualified, replacements) in Replacements.OrderByDescending(kvp => kvp.Key.Length)) + foreach (var (qualified, replacements) in Replacements) text = ReplaceIdentifierToken(text, qualified, replacements); return text; } @@ -2455,21 +2529,53 @@ public static TypeNamePlan Create( CSharpDeclarationOptions options, IReadOnlySet shadowingNames, string declaredTypeName, - IReadOnlySet? qualificationOnlyReferences = null) + IReadOnlySet? qualificationOnlyReferences = null, + IReadOnlySet? shortenableReferences = null, + IReadOnlySet? valueReferences = null) { - qualificationOnlyReferences ??= new HashSet(StringComparer.Ordinal); + var qualificationOnlyFullNames = qualificationOnlyReferences? + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); + if (shortenableReferences is not null) + { + qualificationOnlyFullNames.ExceptWith(shortenableReferences + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName)); + } + var valueFullNames = valueReferences? + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); + var valueOnlyFullNames = valueFullNames.ToHashSet(StringComparer.Ordinal); + valueOnlyFullNames.ExceptWith(qualificationOnlyFullNames); + if (shortenableReferences is not null) + { + valueOnlyFullNames.ExceptWith(shortenableReferences + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName)); + } var typeRefs = references .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); + var bindingTypeRefs = typeRefs + .Where(reference => !valueOnlyFullNames.Contains(reference.FullName)) + .ToList(); var lexicalShadowingNames = shadowingNames.ToHashSet(StringComparer.Ordinal); lexicalShadowingNames.UnionWith(options.AdditionalShadowingNames); var namespaceShadowingNames = new HashSet(StringComparer.Ordinal); var namespaceRootShadowingNames = new HashSet(StringComparer.Ordinal); - foreach (var typeRef in typeRefs) + foreach (var typeRef in bindingTypeRefs) { AddVisibleNamespaceNames( namespaceShadowingNames, @@ -2498,7 +2604,7 @@ public static TypeNamePlan Create( options.ContainingNamespace, options.ContainingNamespace, namespaceRootShadowingNames); - if (typeRefs.Any(r => string.Equals(r.SimpleName, declaredTypeName, StringComparison.Ordinal) + if (bindingTypeRefs.Any(r => string.Equals(r.SimpleName, declaredTypeName, StringComparison.Ordinal) && !string.Equals(r.Namespace, options.ContainingNamespace, StringComparison.Ordinal))) { lexicalShadowingNames.Add(declaredTypeName); @@ -2507,19 +2613,19 @@ public static TypeNamePlan Create( rootShadowingNames.UnionWith(options.AdditionalRootShadowingNames); rootShadowingNames.Add(declaredTypeName); rootShadowingNames.UnionWith(namespaceRootShadowingNames); - rootShadowingNames.UnionWith(typeRefs + rootShadowingNames.UnionWith(bindingTypeRefs .Where(typeRef => string.Equals( typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal)) .Select(typeRef => typeRef.SimpleName)); - var collisions = CollidingSimpleNames(typeRefs); + var collisions = CollidingSimpleNames(bindingTypeRefs); var allShadowingNames = lexicalShadowingNames .Concat(namespaceShadowingNames) .ToHashSet(StringComparer.Ordinal); var unsafeNamespaces = UnsafeNamespaces( - typeRefs, + bindingTypeRefs, allShadowingNames, collisions, rootShadowingNames); @@ -2534,34 +2640,75 @@ void ReplaceQualifiedName( string? shortenedReplacement = null) { var plan = (qualifiedReplacement, shortenedReplacement); - replacements[EscapeQualifiedKeywordSegments(typeRef.FullName)] = plan; - replacements[EscapeNamespace(typeRef.FullName)] = plan; - } - void KeepResolvableQualified(TypeRef typeRef) - { - if (rootShadowingNames.Contains(NamespaceRoot(typeRef.Namespace))) + Add(typeRef.FullName); + Add(EscapeQualifiedKeywordSegments(typeRef.FullName)); + Add(EscapeNamespace(typeRef.FullName)); + + void Add(string key) { - string escapedFullName = EscapeNamespace(typeRef.FullName); - ReplaceQualifiedName(typeRef, $"global::{escapedFullName}"); + if (shortenedReplacement is null + && string.Equals(key, qualifiedReplacement, StringComparison.Ordinal)) + { + return; + } + replacements[key] = plan; } - else + } + string ResolvableQualifiedName(TypeRef typeRef) + { + string root = NamespaceRoot(typeRef.Namespace); + if (options.AdditionalUnresolvableRootNames.Contains(root) + && IsKnownNamespaceRoot(root)) { - string renderedFullName = EscapeQualifiedKeywordSegments(typeRef.FullName); - string escapedFullName = EscapeNamespace(typeRef.FullName); - if (!string.Equals(renderedFullName, escapedFullName, StringComparison.Ordinal)) - replacements[renderedFullName] = (escapedFullName, null); + diagnostics.Add( + $"Type name '{typeRef.FullName}' conflicts with global type '{root}'; emitted the only available global-qualified spelling."); } + + string escapedFullName = EscapeNamespace(typeRef.FullName); + return rootShadowingNames.Contains(root) + ? $"global::{escapedFullName}" + : escapedFullName; } + void KeepResolvableQualified(TypeRef typeRef) + { + ReplaceQualifiedName(typeRef, ResolvableQualifiedName(typeRef)); + } + void KeepAttributeValueQualified(TypeRef typeRef) + { + string root = NamespaceRoot(typeRef.Namespace); + string qualified = options.AdditionalDeclaredTypeFullNames.Contains(typeRef.FullName) + && !IsKnownNamespaceRoot(root) + ? EscapeNamespace(typeRef.FullName) + : ResolvableQualifiedName(typeRef); + ReplaceQualifiedName(typeRef, qualified); + } + bool IsKnownNamespaceRoot(string root) + => options.AdditionalKnownNamespaces.Any(@namespace => + string.Equals(@namespace, root, StringComparison.Ordinal) + || @namespace.StartsWith($"{root}.", StringComparison.Ordinal)); if (options.TypeNameMode == CSharpTypeNameMode.Qualified) { foreach (var typeRef in typeRefs) - KeepResolvableQualified(typeRef); - return new TypeNamePlan(replacements, [], diagnostics); + { + if (valueOnlyFullNames.Contains(typeRef.FullName)) + KeepAttributeValueQualified(typeRef); + else + KeepResolvableQualified(typeRef); + } + return new TypeNamePlan( + replacements.OrderByDescending(kvp => kvp.Key.Length).ToArray(), + [], + diagnostics); } foreach (var typeRef in typeRefs) { + if (valueOnlyFullNames.Contains(typeRef.FullName)) + { + KeepAttributeValueQualified(typeRef); + continue; + } var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); if (!isSameNamespace && collisions.Contains(typeRef.SimpleName)) @@ -2588,7 +2735,7 @@ void KeepResolvableQualified(TypeRef typeRef) KeepResolvableQualified(typeRef); continue; } - if (qualificationOnlyReferences.Contains(typeRef.FullName)) + if (qualificationOnlyFullNames.Contains(typeRef.FullName)) { KeepResolvableQualified(typeRef); continue; @@ -2603,13 +2750,18 @@ void KeepResolvableQualified(TypeRef typeRef) ReplaceQualifiedName( typeRef, - EscapeNamespace(typeRef.FullName), + valueFullNames.Contains(typeRef.FullName) + ? ResolvableQualifiedName(typeRef) + : EscapeNamespace(typeRef.FullName), EscapeIdentifier(typeRef.SimpleName)); if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings && !isSameNamespace) generatedUsings.Add(typeRef.Namespace); } - return new TypeNamePlan(replacements, generatedUsings.ToList(), diagnostics); + return new TypeNamePlan( + replacements.OrderByDescending(kvp => kvp.Key.Length).ToArray(), + generatedUsings.ToList(), + diagnostics); } static string ReplaceIdentifierToken( @@ -2641,7 +2793,11 @@ static string ReplaceIdentifierToken( { if (IsWithinGlobalAlias(text, i)) { - sb.Append(text[i++]); + string qualified = replacements.Qualified; + sb.Append(qualified.StartsWith("global::", StringComparison.Ordinal) + ? qualified["global::".Length..] + : qualified); + i += token.Length; continue; } bool preserveQualification = IsAttributeValuePrefix( diff --git a/src/ILInspector.CSharp/CSharpFormatter.cs b/src/ILInspector.CSharp/CSharpFormatter.cs index 3851313fc5..6fd2c95683 100644 --- a/src/ILInspector.CSharp/CSharpFormatter.cs +++ b/src/ILInspector.CSharp/CSharpFormatter.cs @@ -34,6 +34,8 @@ public sealed record CSharpFormatOptions public IReadOnlyCollection Usings { get; init; } = []; internal IReadOnlyCollection AdditionalShadowingNames { get; init; } = []; internal IReadOnlyCollection AdditionalRootShadowingNames { get; init; } = []; + internal IReadOnlyCollection AdditionalUnresolvableRootNames { get; init; } = []; + internal IReadOnlyCollection AdditionalDeclaredTypeFullNames { get; init; } = []; internal IReadOnlyCollection AdditionalKnownNamespaces { get; init; } = []; public CSharpNamespacePolicy NamespacePolicy { get; init; } = CSharpNamespacePolicy.Omit; public bool AbbreviateSignature { get; init; } @@ -495,6 +497,8 @@ static CSharpDeclarationOptions ToDeclarationOptions( Usings = usings, AdditionalShadowingNames = options.AdditionalShadowingNames, AdditionalRootShadowingNames = options.AdditionalRootShadowingNames, + AdditionalUnresolvableRootNames = options.AdditionalUnresolvableRootNames, + AdditionalDeclaredTypeFullNames = options.AdditionalDeclaredTypeFullNames, AdditionalKnownNamespaces = options.AdditionalKnownNamespaces, NamespaceMode = options.NamespacePolicy switch { diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index 1bd5508be8..f701183e4c 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -66,11 +66,26 @@ public CSharpTypePrintResult PrintBatch( .Where(type => emittedUsings.Contains(type.Namespace)) .Select(type => CSharpFormatter.StripArity(type.Type.Name)) .ToImmutableHashSet(StringComparer.Ordinal); - + var globalDeclaredTypeNames = preparedTypes + .Where(type => type.Namespace.Length == 0) + .Select(type => CSharpFormatter.StripArity(type.Type.Name)) + .ToImmutableHashSet(StringComparer.Ordinal); var units = ImmutableArray.CreateBuilder(); foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { var groupedTypes = group.ToList(); + var declaredTypeFullNames = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + var pendingTypes = new Stack<(PreparedType Type, string? Parent)>( + groupedTypes.Select(type => (type, (string?)null))); + while (pendingTypes.TryPop(out var pending)) + { + string name = CSharpFormatter.StripArity(pending.Type.Type.Name); + string fullName = pending.Parent is null ? name : $"{pending.Parent}.{name}"; + declaredTypeFullNames.Add(fullName); + foreach (var nested in pending.Type.NestedTypes) + pendingTypes.Push((nested, fullName)); + } + var declaredTypeFullNameSet = declaredTypeFullNames.ToImmutable(); var containingNamespace = group.Key.Length == 0 ? null : group.Key; var ancestorTypeNames = preparedTypes .Where(candidate => IsAncestorNamespace(candidate.Namespace, group.Key)) @@ -90,6 +105,8 @@ public CSharpTypePrintResult PrintBatch( .Concat(ancestorTypeNames) .Concat(importedDeclaredTypeNames) .ToImmutableHashSet(StringComparer.Ordinal), + globalDeclaredTypeNames, + declaredTypeFullNameSet, typeNameContext.KnownNamespaces, diagnostics))); if (containingNamespace is not null) @@ -316,6 +333,8 @@ static string RenderType( IReadOnlyList contextualUsings, IReadOnlySet inheritedShadowingNames, IReadOnlySet inheritedRootShadowingNames, + IReadOnlySet unresolvableRootNames, + IReadOnlySet declaredTypeFullNames, IReadOnlyCollection knownNamespaces, ImmutableArray.Builder diagnostics) { @@ -330,24 +349,17 @@ static string RenderType( contextualUsings, inScopeShadowingNames, inheritedRootShadowingNames, + unresolvableRootNames, + declaredTypeFullNames, knownNamespaces); - if (prepared.Type.Kind == "delegate") - return RenderDelegate(prepared, formatter, indent); - - var propertyFormatter = DeclarationFormatter( - prepared.Namespace, - options, - contextualUsings, - inScopeShadowingNames, - inheritedRootShadowingNames, - knownNamespaces, - omitPropertyAccessors: true); var diagnosticPass = DeclarationFormatter( prepared.Namespace, options, contextualUsings, inScopeShadowingNames, inheritedRootShadowingNames, + unresolvableRootNames, + declaredTypeFullNames, knownNamespaces, terminateMemberDeclaration: true) .FormatTypeUnit( @@ -356,7 +368,19 @@ static string RenderType( prepared.PrimaryConstructorParameters); diagnostics.AddRange(diagnosticPass.Diagnostics.Select( diagnostic => new CSharpTypePrintDiagnostic(prepared.Type.FullName, diagnostic))); + if (prepared.Type.Kind == "delegate") + return RenderDelegate(prepared, formatter, indent); + var propertyFormatter = DeclarationFormatter( + prepared.Namespace, + options, + contextualUsings, + inScopeShadowingNames, + inheritedRootShadowingNames, + unresolvableRootNames, + declaredTypeFullNames, + knownNamespaces, + omitPropertyAccessors: true); string pad = new(' ', indent * 4); string declaration = formatter.FormatTypeDeclaration( prepared.Type, @@ -388,6 +412,8 @@ static string RenderType( contextualUsings, nestedShadowingNames, nestedRootShadowingNames, + unresolvableRootNames, + declaredTypeFullNames, knownNamespaces, diagnostics)); } @@ -622,6 +648,8 @@ static CSharpFormatter DeclarationFormatter( IReadOnlyList contextualUsings, IReadOnlyCollection additionalShadowingNames, IReadOnlyCollection additionalRootShadowingNames, + IReadOnlyCollection additionalUnresolvableRootNames, + IReadOnlyCollection additionalDeclaredTypeFullNames, IReadOnlyCollection additionalKnownNamespaces, bool omitPropertyAccessors = false, bool terminateMemberDeclaration = false) @@ -634,6 +662,8 @@ static CSharpFormatter DeclarationFormatter( Usings = contextualUsings, AdditionalShadowingNames = additionalShadowingNames, AdditionalRootShadowingNames = additionalRootShadowingNames, + AdditionalUnresolvableRootNames = additionalUnresolvableRootNames, + AdditionalDeclaredTypeFullNames = additionalDeclaredTypeFullNames, AdditionalKnownNamespaces = additionalKnownNamespaces, NamespacePolicy = CSharpNamespacePolicy.Omit, IncludeCustomAttributes = options.IncludeCustomAttributes, diff --git a/tools/DecompilerHarness/ReturnToSenderTypePlanner.cs b/tools/DecompilerHarness/ReturnToSenderTypePlanner.cs index cbf9c7a857..610cbbba21 100644 --- a/tools/DecompilerHarness/ReturnToSenderTypePlanner.cs +++ b/tools/DecompilerHarness/ReturnToSenderTypePlanner.cs @@ -592,7 +592,7 @@ static CompileBackSourceResult ApplyFullBodies( Diagnostics = diagnostics, }; evidence = rows; - return new CompileBackSourceResult(plan, ComposeCompilationUnit(plan)); + return ComposeCompilationUnit(plan); CSharpTypePrintRequest Enrich(CSharpTypePrintRequest request) { @@ -1178,7 +1178,7 @@ public static CompileBackSourceResult ComposePropertyGetter( production.Requirements, declarations, diagnostics); - return new CompileBackSourceResult(plan, ComposeCompilationUnit(plan)); + return ComposeCompilationUnit(plan); } static void AddRequiredMembers( @@ -2397,7 +2397,7 @@ public static CompileBackSourceResult ComposePropertySetter( production.Requirements, declarations, diagnostics); - return new CompileBackSourceResult(plan, ComposeCompilationUnit(plan)); + return ComposeCompilationUnit(plan); } public static CompileBackSourceResult ComposeEventAccessor( @@ -2526,7 +2526,7 @@ [new CompileBackFact("metadata", "target-event-accessor", reader.GetString(acces production.Requirements, production.Requests, diagnostics); - return new CompileBackSourceResult(plan, ComposeCompilationUnit(plan)); + return ComposeCompilationUnit(plan); } public static CompileBackSourceResult ComposeMethod( @@ -2809,11 +2809,13 @@ chainParameterTypes is { } chainParams production.Requirements, declarations, diagnostics); - return new CompileBackSourceResult(plan, ComposeCompilationUnit(plan)); + return ComposeCompilationUnit(plan); } - static string ComposeCompilationUnit(CompileBackReconstructionPlan plan) - => new CSharpTypePrinter().PrintBatch( + static CompileBackSourceResult ComposeCompilationUnit(CompileBackReconstructionPlan plan) + { + const string typeNamePlanningLayer = "type name planning"; + var rendered = new CSharpTypePrinter().PrintBatch( plan.PrintRequests, new CSharpTypePrintOptions { @@ -2822,7 +2824,23 @@ static string ComposeCompilationUnit(CompileBackReconstructionPlan plan) AssemblyAttributes = plan.Module.AssemblyAttributes.Select(attribute => attribute.Text).ToArray(), ModuleAttributes = plan.Module.ModuleAttributes.Select(attribute => attribute.Text).ToArray(), Usings = plan.Module.Usings, - }).Source; + }); + var enrichedPlan = plan with + { + Diagnostics = plan.Diagnostics + .Where(diagnostic => diagnostic.Layer != typeNamePlanningLayer) + .Concat(rendered.Diagnostics + .Where(diagnostic => diagnostic.Message.Contains( + "conflicts with global type '", + StringComparison.Ordinal)) + .Select(diagnostic => new CompileBackPlanningDiagnostic( + typeNamePlanningLayer, + "unresolvable namespace root", + $"{diagnostic.TypeName}: {diagnostic.Message}"))) + .ToArray() + }; + return new CompileBackSourceResult(enrichedPlan, rendered.Source); + } static ApiMember ToApiMember(CompileBackMemberRequirement member) { diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index 2b9da6e340..f1a7dbe9d3 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"`0(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"F8EB70D17DB02B261CC738AF4FFA650DFC307AB5D6FCF0732BF09D51F54E1C27","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration && NeedsTerminator(declaration))\n declaration += \";\";\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663541,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663543,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} From 8e2dd48fe660a66411bf9a271af4e87c7519c0b0 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 14:29:10 -0700 Subject: [PATCH 07/18] Close reviewed type binding gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 168 ++++++++++++++++++ .../CSharpDeclarationWriter.cs | 58 ++++-- src/ILInspector.CSharp/CSharpTypePrinter.cs | 29 ++- .../corpus/two-row-authored-corpus.jsonl | 2 +- 4 files changed, 243 insertions(+), 14 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 4158cd1b51..20ffc3bef2 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2546,6 +2546,150 @@ int Samples.IValues.this[int index] StringComparison.Ordinal); } + [Fact] + public void ExplicitInterfaceQualifierRespectsLexicalShadowing() + { + var property = new ApiMember + { + Name = "Samples.IValue.Value", + Kind = "explicit-interface-implementation", + SignatureModel = new ApiSignature + { + ReturnType = "int", + MemberName = "Samples.IValue.Value", + Accessors = [new ApiAccessor { Kind = "get" }] + } + }; + var type = CreateEmptyType("Samples", "Widget`1"); + type.MetadataName = "Widget`1"; + type.TypeParameters = [new TypeParameter { Name = "Samples" }]; + type.Interfaces.Add("Samples.IValue"); + type.Members.Add(property); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "int global::Samples.IValue.Value", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void SiblingMemberTypeReferenceContributesRootShadowing() + { + var type = CreateEmptyType("Contoso.Data", "Store"); + var getJson = CreateMethod("GetJson"); + getJson.SignatureModel!.ReturnType = "Contoso.Data.Json"; + var getNode = CreateMethod("GetNode"); + getNode.SignatureModel!.ReturnType = "Json.Node"; + type.Members.Add(getJson); + type.Members.Add(getNode); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeUsings = false + }); + + Assert.Contains( + "public global::Json.Node GetNode();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void AncestorNamespaceTypeReferenceContributesRootShadowing() + { + var type = CreateEmptyType("Contoso.Data.Serialization", "Store"); + var convert = CreateMethod("Convert"); + convert.SignatureModel!.ReturnType = "Json.Node"; + convert.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "Contoso.Data.Json", + Name = "value" + }); + type.Members.Add(convert); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + IncludeUsings = false + }); + + Assert.Contains( + "public global::Json.Node Convert(Contoso.Data.Json value);", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void HiddenCustomAttributesStillContributeBindingEvidence() + { + var type = CreateEmptyType("App", "Widget"); + type.Attributes = ["App.Foo.MarkerAttribute"]; + var method = CreateMethod("Get"); + method.SignatureModel!.ReturnType = "Foo.Bar.Baz"; + type.Members.Add(method); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions + { + IncludeCustomAttributes = false, + IncludeUsings = false + }); + + Assert.DoesNotContain("MarkerAttribute", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public global::Foo.Bar.Baz Get();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void GlobalTypeConflictingWithNamespaceDeclarationReportsDiagnostic() + { + var root = CreateEmptyType("", "Foo"); + var namespaced = CreateEmptyType("Foo.Bar", "Worker"); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(root), new CSharpTypePrintRequest(namespaced)]); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "Namespace root 'Foo' conflicts with global type 'Foo'", + StringComparison.Ordinal)); + } + + [Fact] + public void GlobalTypeConflictingWithUsingReportsDiagnostic() + { + var system = CreateEmptyType("", "System"); + + var result = _printer.Print( + new CSharpTypePrintRequest(system), + new CSharpTypePrintOptions + { + Usings = ["System.Text"] + }); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "Namespace root 'System' conflicts with global type 'System'", + StringComparison.Ordinal)); + } + [Fact] public void NestedTypesAndPrimaryConstructorsRenderInFileScopedNamespaceUnits() { @@ -2654,6 +2798,30 @@ public void EnumAndDelegateRequestsUseTheirLanguageDeclarations() StringComparison.Ordinal); } + [Fact] + public void DelegateReturnAttributesAreRenderedAndPlanned() + { + var external = CreateEmptyType("", "External"); + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "bool"; + invoke.SignatureModel.ReturnAttributes = ["External.Marker"]; + var delegateType = CreateEmptyType("Samples", "Predicate"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(external), new CSharpTypePrintRequest(delegateType)], + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "[return: global::External.Marker]\n public delegate bool Predicate();", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void NestedTypeFailsWithoutItsDeclaringType() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index a47bd9938b..37da47f31c 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -73,9 +73,12 @@ public static CSharpRenderedDeclaration RenderMemberUnit( var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( member.Attributes, member) + .Concat(CollectExplicitInterfaceTypeReferences(member)) .ToHashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); - var references = memberReferences.Concat(attributeReferences); + var references = memberReferences + .Concat(attributeReferences) + .Concat(CollectExplicitInterfaceTypeReferences(member)); var plan = TypeNamePlan.Create( references, options, @@ -107,9 +110,12 @@ public static string RenderMemberDeclaration( var qualificationOnlyAttributeReferences = CollectQualificationOnlyAttributeTypeReferences( member.Attributes, member) + .Concat(CollectExplicitInterfaceTypeReferences(member)) .ToHashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); - var references = memberReferences.Concat(attributeReferences); + var references = memberReferences + .Concat(attributeReferences) + .Concat(CollectExplicitInterfaceTypeReferences(member)); var plan = TypeNamePlan.Create( references, options, @@ -148,6 +154,7 @@ public static CSharpRenderedDeclaration RenderTypeUnit( CollectQualificationOnlyAttributeTypeReferences(member.Attributes, member))) .Concat(parameters.SelectMany(parameter => CollectAttributeArgumentTypeReferences(parameter.Attributes))) + .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)) .ToHashSet(StringComparer.Ordinal); var primaryParameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) @@ -160,7 +167,8 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var references = shortenableReferences .Concat(parameters.SelectMany(CollectParameterTypeReferences)) .Concat(attributeReferences) - .Concat(primaryParameterAttributeReferences); + .Concat(primaryParameterAttributeReferences) + .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)); var plan = TypeNamePlan.Create( references, options, @@ -241,8 +249,11 @@ public static string RenderTypeDeclaration( : ""; string unsafeText = delegateInvoke.IsUnsafe ? " unsafe" : ""; string parameterList = CSharpFormatter.FormatParameterList(signature.Parameters); + string returnAttributes = signature.ReturnAttributes.Count > 0 + ? $"[return: {string.Join(", ", signature.ReturnAttributes)}]\n" + : ""; string delegateDeclaration = - $"{attributes}{TypeAccessibility(type)}{unsafeText} delegate {signature.ReturnType ?? "void"} {FormatTypeDisplayName(type.Name, type.TypeParameters)}{parameterList}"; + $"{attributes}{returnAttributes}{TypeAccessibility(type)}{unsafeText} delegate {signature.ReturnType ?? "void"} {FormatTypeDisplayName(type.Name, type.TypeParameters)}{parameterList}"; delegateDeclaration = AppendTypeParameterConstraints(delegateDeclaration, type.TypeParameters); return delegatePlan.Apply(delegateDeclaration + ";"); } @@ -305,7 +316,8 @@ public static IReadOnlyList DeriveContextualUsings(IReadOnlyCollection SafeUsings, - IReadOnlyList KnownNamespaces) DeriveTypeNameContext( + IReadOnlyList KnownNamespaces, + IReadOnlyList<(string Namespace, string SimpleName)> ReferencedTypeNames) DeriveTypeNameContext( IEnumerable<( ApiType Type, IEnumerable Members, @@ -330,7 +342,8 @@ internal static ( var attributeTypeRefs = scopeList .SelectMany(scope => CollectDeclaredAttributeTypeReferences(scope.Type.Attributes) .Concat(scope.Members.SelectMany(member => - CollectDeclaredAttributeTypeReferences(member.Attributes, member)))) + CollectDeclaredAttributeTypeReferences(member.Attributes, member))) + .Concat(scope.Members.SelectMany(CollectExplicitInterfaceTypeReferences))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -395,7 +408,12 @@ internal static ( usings.Add(ns); } - return (usings.ToList(), knownNamespaces); + var referencedTypeNames = typeRefs + .Concat(attributeTypeRefs) + .Select(typeRef => (typeRef.Namespace, typeRef.SimpleName)) + .Distinct() + .ToList(); + return (usings.ToList(), knownNamespaces, referencedTypeNames); } static IEnumerable CollectParameterTypeReferences(ApiParameter parameter) @@ -943,6 +961,19 @@ static IEnumerable CollectMemberTypeReferences(ApiMember member) } } + static IEnumerable CollectExplicitInterfaceTypeReferences(ApiMember member) + { + if (member.Kind != "explicit-interface-implementation") + yield break; + + int memberSeparator = member.Name.LastIndexOf('.'); + if (memberSeparator > 0) + { + foreach (var reference in ExtractQualifiedTypeNames(member.Name[..memberSeparator])) + yield return reference; + } + } + static IEnumerable MemberTypeExpressions(ApiMember member) { if (!string.IsNullOrWhiteSpace(member.ReturnType)) @@ -2614,10 +2645,15 @@ public static TypeNamePlan Create( rootShadowingNames.Add(declaredTypeName); rootShadowingNames.UnionWith(namespaceRootShadowingNames); rootShadowingNames.UnionWith(bindingTypeRefs - .Where(typeRef => string.Equals( - typeRef.Namespace, - options.ContainingNamespace, - StringComparison.Ordinal)) + .Where(typeRef => + { + string containingNamespace = options.ContainingNamespace ?? ""; + return typeRef.Namespace.Length <= containingNamespace.Length + && containingNamespace.StartsWith(typeRef.Namespace, StringComparison.Ordinal) + && (typeRef.Namespace.Length == containingNamespace.Length + || typeRef.Namespace.Length == 0 + || containingNamespace[typeRef.Namespace.Length] == '.'); + }) .Select(typeRef => typeRef.SimpleName)); var collisions = CollidingSimpleNames(bindingTypeRefs); diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index f701183e4c..fae55904c5 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -70,6 +70,19 @@ public CSharpTypePrintResult PrintBatch( .Where(type => type.Namespace.Length == 0) .Select(type => CSharpFormatter.StripArity(type.Type.Name)) .ToImmutableHashSet(StringComparer.Ordinal); + foreach (string globalTypeName in globalDeclaredTypeNames) + { + bool conflictsWithNamespace = preparedTypes.Any(type => + NamespaceRoot(type.Namespace) == globalTypeName); + bool conflictsWithUsing = emittedUsings.Any(@namespace => + NamespaceRoot(@namespace) == globalTypeName); + if (conflictsWithNamespace || conflictsWithUsing) + { + diagnostics.Add(new CSharpTypePrintDiagnostic( + globalTypeName, + $"Namespace root '{globalTypeName}' conflicts with global type '{globalTypeName}'; emitted namespace or using directives cannot bind that root as a namespace.")); + } + } var units = ImmutableArray.CreateBuilder(); foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { @@ -91,6 +104,10 @@ public CSharpTypePrintResult PrintBatch( .Where(candidate => IsAncestorNamespace(candidate.Namespace, group.Key)) .Select(candidate => CSharpFormatter.StripArity(candidate.Type.Name)) .ToImmutableHashSet(StringComparer.Ordinal); + var referencedAncestorTypeNames = typeNameContext.ReferencedTypeNames + .Where(reference => string.Equals(reference.Namespace, group.Key, StringComparison.Ordinal) + || IsAncestorNamespace(reference.Namespace, group.Key)) + .Select(reference => reference.SimpleName); var source = string.Join( "\n\n", groupedTypes.Select(type => RenderType( @@ -103,6 +120,7 @@ public CSharpTypePrintResult PrintBatch( .Where(sibling => !ReferenceEquals(sibling, type)) .Select(sibling => CSharpFormatter.StripArity(sibling.Type.Name)) .Concat(ancestorTypeNames) + .Concat(referencedAncestorTypeNames) .Concat(importedDeclaredTypeNames) .ToImmutableHashSet(StringComparer.Ordinal), globalDeclaredTypeNames, @@ -123,7 +141,7 @@ public CSharpTypePrintResult PrintBatch( var unitList = units.ToImmutable(); return new CSharpTypePrintResult( unitList, - diagnostics.ToImmutable(), + diagnostics.Distinct().ToImmutableArray(), emittedUsings, () => ComposeSource(unitList, emittedUsings, options)); } @@ -134,7 +152,8 @@ public CSharpTypePrintResult PrintBatch( /// static ( IReadOnlyList SafeUsings, - IReadOnlyList KnownNamespaces) ComputeTypeNameContext( + IReadOnlyList KnownNamespaces, + IReadOnlyList<(string Namespace, string SimpleName)> ReferencedTypeNames) ComputeTypeNameContext( IReadOnlyList preparedTypes, CSharpTypePrintOptions options) { @@ -159,6 +178,12 @@ void Flatten(PreparedType prepared) options.Usings); } + static string NamespaceRoot(string @namespace) + { + int separator = @namespace.IndexOf('.'); + return separator < 0 ? @namespace : @namespace[..separator]; + } + static bool IsAncestorNamespace(string candidate, string descendant) => candidate.Length < descendant.Length && descendant.StartsWith(candidate, StringComparison.Ordinal) diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index f1a7dbe9d3..11d6df2a9c 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"`0(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"F8EB70D17DB02B261CC738AF4FFA650DFC307AB5D6FCF0732BF09D51F54E1C27","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration && NeedsTerminator(declaration))\n declaration += \";\";\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663543,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpExpressionBody","method":"FromSingleStatement","overload":0,"signature":"`0(string)","metadataToken":100663544,"parameterCount":1,"ilSize":179,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/cdb4826be3812a851a72a2f4b6f4da6ee2449fad/src/ILInspector.CSharp/CSharpExpressionBody.cs","checksumAlgorithm":"SHA256","checksum":"DC1A1AF45A35F48303896A50FB575B679559FE4DFED977498792E1B584FBEEEE","authoredBody":"var line = body.Trim();\n\n if (line.Length == 0\n || line.Contains('\\n')\n || !line.EndsWith(';')\n || line.StartsWith(\"//\", StringComparison.Ordinal)\n || line.StartsWith(\"/*\", StringComparison.Ordinal))\n return null;\n\n\n line = line[..^1].TrimEnd();\n\n if (line.StartsWith(\"return \", StringComparison.Ordinal))\n {\n var expression = line[\"return \".Length..].TrimStart();\n return expression.Length == 0 ? null : expression;\n }\n\n if (line is \"return\")\n return null;\n\n if (line.StartsWith(\"throw \", StringComparison.Ordinal))\n return line;\n\n return IsStatementExpression(line) ? line : null;"} From 5dbe27cf0b25eb14840afe44731d352b417b8810 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 14:30:31 -0700 Subject: [PATCH 08/18] Reconcile authored corpus identity after merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index b5c6a7f643..5b591f1a73 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"\u00600(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"97A66F7359EB8307DEBCCC7F0BD0D9B6E3D496206758EF4A061113F9675F27E2","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration \u0026\u0026 NeedsTerminator(declaration))\n declaration \u002B= \u0022;\u0022;\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663583,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663608,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} From 127a7642812bb9f24cb17966cbe65b8720717d7e Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 18:16:13 -0700 Subject: [PATCH 09/18] Close final type planner review gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 320 +++++++++++++++++- .../CSharpDeclarationWriter.cs | 315 +++++++++++------ src/ILInspector.CSharp/CSharpTypePrinter.cs | 36 +- .../corpus/two-row-authored-corpus.jsonl | 2 +- 4 files changed, 567 insertions(+), 106 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 20ffc3bef2..a4243e1465 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2631,6 +2631,148 @@ public void AncestorNamespaceTypeReferenceContributesRootShadowing() StringComparison.Ordinal); } + [Fact] + public void GenericInterfaceReferencesArePlannedByTypeComponent() + { + var type = CreateEmptyType("App", "Host"); + type.Interfaces.Add("A.IFoo"); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains("using A;", result.Source, StringComparison.Ordinal); + Assert.Contains("using B;", result.Source, StringComparison.Ordinal); + Assert.Contains("public class Host : IFoo", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using A.IFoo.Middle.Inner"); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains("using N;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public class Host : Outer.Middle.Inner", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("using Middle;", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void PrimaryConstructorAttributeEvidenceContributesToMemberPlanning() + { + var type = CreateEmptyType("Contoso.Data", "Store"); + var method = CreateMethod("GetNode"); + method.SignatureModel!.ReturnType = "Json.Node"; + type.Members.Add(method); + var parameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["Marker(typeof(Contoso.Data.Json))"] + }; + + var result = _printer.Print( + new CSharpTypePrintRequest( + type, + primaryConstructorParameters: [parameter]), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified + }); + + Assert.Contains( + "public global::Json.Node GetNode();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void ExplicitInterfaceDualProvenanceRemainsQualified() + { + var property = new ApiMember + { + Name = "Contracts.IValue.Value", + Kind = "explicit-interface-implementation", + SignatureModel = new ApiSignature + { + ReturnType = "Contracts.IValue", + MemberName = "Contracts.IValue.Value", + Accessors = [new ApiAccessor { Kind = "get" }] + } + }; + var type = CreateEmptyType("App", "Widget"); + type.Interfaces.Add("Contracts.IValue"); + type.Members.Add(property); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains( + "Contracts.IValue Contracts.IValue.Value", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "IValue IValue.Value", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void ExplicitInterfaceCollisionKeepsBothReferencesQualified() + { + var property = new ApiMember + { + Name = "Contracts.IValue.Value", + Kind = "explicit-interface-implementation", + SignatureModel = new ApiSignature + { + ReturnType = "Other.IValue", + MemberName = "Contracts.IValue.Value", + Accessors = [new ApiAccessor { Kind = "get" }] + } + }; + var sibling = CreateMethod("GetThing"); + sibling.SignatureModel!.ReturnType = "Contracts.Thing"; + var type = CreateEmptyType("App", "Widget"); + type.Interfaces.Add("Contracts.IValue"); + type.Members.Add(property); + type.Members.Add(sibling); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.DoesNotContain("using Contracts;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using Other;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "Other.IValue Contracts.IValue.Value", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void KeptQualifiedReferenceIsNotRewrittenByShorterPrefix() + { + var type = CreateEmptyType("App", "Widget"); + var method = CreateMethod("Get"); + method.SignatureModel!.ReturnType = "A.B"; + method.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "A.B.C", + Name = "value" + }); + type.Members.Add(method); + + var result = _printer.Print(new CSharpTypePrintRequest(type)); + + Assert.Contains( + "public B Get(A.B.C value);", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("public B Get(B.C value);", result.Source, StringComparison.Ordinal); + } + [Fact] public void HiddenCustomAttributesStillContributeBindingEvidence() { @@ -2655,6 +2797,45 @@ public void HiddenCustomAttributesStillContributeBindingEvidence() StringComparison.Ordinal); } + [Fact] + public void ImportedHiddenAttributeNamespacePreventsConflictingSignatureShortening() + { + var type = CreateEmptyType("App", "Widget"); + var getFoo = CreateMethod("GetFoo"); + getFoo.SignatureModel!.ReturnType = "A.Foo"; + getFoo.Attributes = ["B.Foo"]; + var getOther = CreateMethod("GetOther"); + getOther.SignatureModel!.ReturnType = "B.Other"; + type.Members.Add(getFoo); + type.Members.Add(getOther); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions { IncludeCustomAttributes = false }); + + Assert.Contains("using A;", result.Source, StringComparison.Ordinal); + Assert.Contains("using B;", result.Source, StringComparison.Ordinal); + Assert.Contains("public A.Foo GetFoo();", result.Source, StringComparison.Ordinal); + Assert.Contains("public Other GetOther();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void AttributeTypeIsNotImportedAsNestedTypeNamespace() + { + var type = CreateEmptyType("App", "Widget"); + type.Attributes = ["N.Outer"]; + var method = CreateMethod("Get"); + method.SignatureModel!.ReturnType = "N.Outer.Inner"; + type.Members.Add(method); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions { IncludeCustomAttributes = false }); + + Assert.DoesNotContain("using N.Outer;", result.Source, StringComparison.Ordinal); + Assert.Contains("public N.Outer.Inner Get();", result.Source, StringComparison.Ordinal); + } + [Fact] public void GlobalTypeConflictingWithNamespaceDeclarationReportsDiagnostic() { @@ -2793,7 +2974,7 @@ public void EnumAndDelegateRequestsUseTheirLanguageDeclarations() Assert.Contains(" public enum Choice\n {\n One = 1\n }", result.Units[0].Source, StringComparison.Ordinal); Assert.Contains( - " internal delegate int Converter(string value) where @event : System.IEquatable<@event>;", + " internal delegate int Converter(string value) where @event : IEquatable<@event>;", result.Units[0].Source, StringComparison.Ordinal); } @@ -2822,6 +3003,83 @@ public void DelegateReturnAttributesAreRenderedAndPlanned() StringComparison.Ordinal); } + [Fact] + public void DelegateSignatureTypesRespectShorteningPolicy() + { + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "External.Result"; + invoke.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "External.Input", + Name = "value" + }); + var delegateType = CreateEmptyType("Samples", "Handler"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); + + Assert.Contains("using External;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public delegate Result Handler(Input value);", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DelegateParameterAttributesRemainQualified() + { + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Contracts.Marker"; + invoke.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "string", + Name = "value", + Attributes = ["Attributes.Marker"] + }); + var delegateType = CreateEmptyType("Samples", "Handler"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); + + Assert.Contains("using Contracts;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public delegate Marker Handler([Attributes.Marker] string value);", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DelegateAttributeDoesNotEraseSameTypeSignatureEvidence() + { + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "A.Foo"; + invoke.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "B.Foo", + Name = "value", + Attributes = ["B.Foo"] + }); + invoke.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "B.Other", + Name = "other" + }); + var delegateType = CreateEmptyType("Samples", "Handler"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); + + Assert.DoesNotContain("using A;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using B;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public delegate A.Foo Handler([B.Foo] B.Foo value, B.Other other);", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void NestedTypeFailsWithoutItsDeclaringType() { @@ -3140,6 +3398,66 @@ public void SourceEmitsPragmaAndAssemblyAndModuleAttributesWhenRequested() StringComparison.Ordinal); } + [Fact] + public void GlobalAttributesAreEscapedAndDiagnoseGlobalRootConflicts() + { + var result = _printer.Print( + new CSharpTypePrintRequest(CreateEmptyType("", "System")), + new CSharpTypePrintOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.Qualified, + AssemblyAttributes = ["System.CLSCompliantAttribute(true)"], + ModuleAttributes = ["event.Marker"] + }); + + Assert.Contains( + "[assembly: global::System.CLSCompliantAttribute(true)]", + result.Source, + StringComparison.Ordinal); + Assert.Contains("[module: @event.Marker]", result.Source, StringComparison.Ordinal); + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.TypeName == "" + && diagnostic.Message.Contains("conflicts with global type 'System'", StringComparison.Ordinal)); + } + + [Fact] + public void SynthesizedObsoleteAttributeCannotBindToSiblingType() + { + var obsolete = CreateEmptyType("Samples", "Obsolete"); + var widget = CreateEmptyType("Samples", "Widget"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "int"; + member.IsObsolete = true; + widget.Members.Add(member); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(obsolete), new CSharpTypePrintRequest(widget)]); + + Assert.Contains("[System.Obsolete] public int Get();", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void SynthesizedObsoleteReportsGlobalSystemConflict() + { + var system = CreateEmptyType("", "System"); + var widget = CreateEmptyType("Samples", "Widget"); + var member = CreateMethod("Get"); + member.SignatureModel!.ReturnType = "int"; + member.IsObsolete = true; + widget.Members.Add(member); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(system), new CSharpTypePrintRequest(widget)]); + + Assert.Contains("[global::System.Obsolete]", result.Source, StringComparison.Ordinal); + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "conflicts with global type 'System'", + StringComparison.Ordinal)); + } + [Fact] public void SourceEscapesAndDeduplicatesUsings() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 37da47f31c..7d72e9b433 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -74,18 +74,22 @@ public static CSharpRenderedDeclaration RenderMemberUnit( member.Attributes, member) .Concat(CollectExplicitInterfaceTypeReferences(member)) + .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []) .ToHashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); + var explicitInterfaceReferences = CollectExplicitInterfaceTypeReferences(member) + .ToHashSet(StringComparer.Ordinal); var references = memberReferences .Concat(attributeReferences) - .Concat(CollectExplicitInterfaceTypeReferences(member)); + .Concat(explicitInterfaceReferences) + .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, - memberReferences, + memberReferences.Except(explicitInterfaceReferences).ToHashSet(StringComparer.Ordinal), attributeValueReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -111,18 +115,22 @@ public static string RenderMemberDeclaration( member.Attributes, member) .Concat(CollectExplicitInterfaceTypeReferences(member)) + .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []) .ToHashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); + var explicitInterfaceReferences = CollectExplicitInterfaceTypeReferences(member) + .ToHashSet(StringComparer.Ordinal); var references = memberReferences .Concat(attributeReferences) - .Concat(CollectExplicitInterfaceTypeReferences(member)); + .Concat(explicitInterfaceReferences) + .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []); var plan = TypeNamePlan.Create( references, options, CollectShadowingNames(type, [member]), CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, - memberReferences, + memberReferences.Except(explicitInterfaceReferences).ToHashSet(StringComparer.Ordinal), attributeValueReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -155,20 +163,27 @@ public static CSharpRenderedDeclaration RenderTypeUnit( .Concat(parameters.SelectMany(parameter => CollectAttributeArgumentTypeReferences(parameter.Attributes))) .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)) + .Concat(memberList + .Where(member => member.IsObsolete && options.IncludeObsoleteAttribute) + .Select(_ => "System.Obsolete")) .ToHashSet(StringComparer.Ordinal); var primaryParameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) .ToHashSet(StringComparer.Ordinal); var shortenableReferences = CollectTypeReferences(type) - .Concat(memberList.SelectMany(CollectMemberTypeReferences)) + .Concat(memberList.SelectMany(member => CollectMemberTypeReferences(member))) .Concat(parameters.SelectMany(parameter => ExtractQualifiedTypeNames(parameter.Type))) .ToHashSet(StringComparer.Ordinal); + shortenableReferences.ExceptWith(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)); var references = shortenableReferences .Concat(parameters.SelectMany(CollectParameterTypeReferences)) .Concat(attributeReferences) .Concat(primaryParameterAttributeReferences) - .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)); + .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)) + .Concat(memberList + .Where(member => member.IsObsolete && options.IncludeObsoleteAttribute) + .Select(_ => "System.Obsolete")); var plan = TypeNamePlan.Create( references, options, @@ -229,12 +244,15 @@ public static string RenderTypeDeclaration( var delegateAttributeValueReferences = CollectAttributeValueTypeReferences(type.Attributes) .Concat(CollectAttributeValueTypeReferences(delegateInvoke.Attributes, delegateInvoke)) .ToHashSet(StringComparer.Ordinal); - var delegateStrongReferences = CollectTypeReferences(type) + var delegateSignatureReferences = CollectTypeReferences(type) .Concat(CollectMemberTypeReferences(delegateInvoke)) - .Concat(CollectDeclaredAttributeTypeReferences(type.Attributes)) + .ToHashSet(StringComparer.Ordinal); + var delegateQualificationOnlyReferences = CollectDeclaredAttributeTypeReferences(type.Attributes) .Concat(CollectDeclaredAttributeTypeReferences(delegateInvoke.Attributes, delegateInvoke)) .ToHashSet(StringComparer.Ordinal); - var references = delegateStrongReferences + delegateSignatureReferences.ExceptWith(delegateQualificationOnlyReferences); + var references = delegateSignatureReferences + .Concat(delegateQualificationOnlyReferences) .Concat(delegateAttributeReferences) .ToList(); var delegatePlan = TypeNamePlan.Create( @@ -242,7 +260,8 @@ public static string RenderTypeDeclaration( options, CollectShadowingNames(type, [delegateInvoke]), CSharpFormatter.StripArity(type.Name), - delegateStrongReferences, + delegateQualificationOnlyReferences, + delegateSignatureReferences, valueReferences: delegateAttributeValueReferences); string attributes = options.IncludeCustomAttributes && type.Attributes.Count > 0 ? string.Join("\n", type.Attributes.Select(attribute => $"[{attribute}]")) + "\n" @@ -293,6 +312,26 @@ public static string RenderTypeDeclaration( return plan.Apply(declaration); } + internal static ( + IReadOnlyList Attributes, + IReadOnlyList Diagnostics) RenderAttributeBodies( + IReadOnlyList attributes, + CSharpDeclarationOptions options) + { + var references = CollectAttributeTypeReferences(attributes).ToHashSet(StringComparer.Ordinal); + var valueReferences = CollectAttributeValueTypeReferences(attributes).ToHashSet(StringComparer.Ordinal); + var qualificationOnlyReferences = CollectDeclaredAttributeTypeReferences(attributes) + .ToHashSet(StringComparer.Ordinal); + var plan = TypeNamePlan.Create( + references, + options, + new HashSet(StringComparer.Ordinal), + "", + qualificationOnlyReferences, + valueReferences: valueReferences); + return (attributes.Select(plan.Apply).ToArray(), plan.Diagnostics); + } + /// /// Computes a collision-safe set of namespaces that can be imported as /// using directives for a compilation unit declaring @@ -322,7 +361,8 @@ internal static ( ApiType Type, IEnumerable Members, IEnumerable AdditionalParameters)> scopes, - IEnumerable? contextualNamespaces = null) + IEnumerable? contextualNamespaces = null, + IEnumerable? additionalAttributes = null) { var scopeList = scopes .Select(scope => ( @@ -332,7 +372,10 @@ internal static ( .ToList(); var typeRefs = scopeList .SelectMany(scope => CollectTypeReferences(scope.Type) - .Concat(scope.Members.SelectMany(CollectMemberTypeReferences)) + .Concat(scope.Members.SelectMany(member => + CollectMemberTypeReferences( + member, + includeParameterAttributes: scope.Type.Kind != "delegate"))) .Concat(scope.AdditionalParameters.SelectMany(CollectParameterTypeReferences))) .Select(TypeRef.TryCreate) .Where(r => r is not null) @@ -343,7 +386,13 @@ internal static ( .SelectMany(scope => CollectDeclaredAttributeTypeReferences(scope.Type.Attributes) .Concat(scope.Members.SelectMany(member => CollectDeclaredAttributeTypeReferences(member.Attributes, member))) - .Concat(scope.Members.SelectMany(CollectExplicitInterfaceTypeReferences))) + .Concat(scope.Members.SelectMany(CollectExplicitInterfaceTypeReferences)) + .Concat(scope.AdditionalParameters.SelectMany(parameter => + CollectDeclaredAttributeTypeReferences(parameter.Attributes))) + .Concat(scope.Members + .Where(member => member.IsObsolete) + .Select(_ => "System.Obsolete"))) + .Concat(CollectDeclaredAttributeTypeReferences(additionalAttributes ?? [])) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -391,7 +440,8 @@ internal static ( collidingSimpleNames, rootShadowingNames, declaredTypeNames, - declaredTypeFullNames); + declaredTypeFullNames, + typeRefs.Concat(attributeTypeRefs).ToList()); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) { @@ -662,7 +712,8 @@ static HashSet UnsafeNamespaces( IReadOnlySet collidingSimpleNames, IReadOnlySet rootShadowingNames, IReadOnlySet? declaredTypeNames = null, - IReadOnlySet? declaredTypeFullNames = null) + IReadOnlySet? declaredTypeFullNames = null, + IReadOnlyList? bindingEvidence = null) { declaredTypeNames ??= new HashSet(StringComparer.Ordinal); declaredTypeFullNames ??= new HashSet(StringComparer.Ordinal); @@ -675,7 +726,7 @@ static HashSet UnsafeNamespaces( .Select(r => r.Namespace) .ToHashSet(StringComparer.Ordinal); - var referencedFullNames = typeRefs + var referencedFullNames = (bindingEvidence ?? typeRefs) .Select(r => r.FullName) .ToHashSet(StringComparer.Ordinal); unsafeNamespaces.UnionWith(typeRefs @@ -939,9 +990,15 @@ static string RenderMemberDeclarationCore( static IEnumerable CollectTypeReferences(ApiType type) { if (type.BaseType is { Length: > 0 }) - yield return type.BaseType; + { + foreach (var reference in ExtractQualifiedTypeNames(type.BaseType)) + yield return reference; + } foreach (var iface in type.Interfaces) - yield return iface; + { + foreach (var reference in ExtractQualifiedTypeNames(iface)) + yield return reference; + } foreach (var typeParameter in type.TypeParameters) { foreach (var constraint in typeParameter.Constraints) @@ -952,9 +1009,11 @@ static IEnumerable CollectTypeReferences(ApiType type) } } - static IEnumerable CollectMemberTypeReferences(ApiMember member) + static IEnumerable CollectMemberTypeReferences( + ApiMember member, + bool includeParameterAttributes = true) { - foreach (var expression in MemberTypeExpressions(member)) + foreach (var expression in MemberTypeExpressions(member, includeParameterAttributes)) { foreach (var reference in ExtractQualifiedTypeNames(expression)) yield return reference; @@ -974,7 +1033,9 @@ static IEnumerable CollectExplicitInterfaceTypeReferences(ApiMember memb } } - static IEnumerable MemberTypeExpressions(ApiMember member) + static IEnumerable MemberTypeExpressions( + ApiMember member, + bool includeParameterAttributes) { if (!string.IsNullOrWhiteSpace(member.ReturnType)) yield return member.ReturnType!; @@ -986,8 +1047,11 @@ static IEnumerable MemberTypeExpressions(ApiMember member) { if (!string.IsNullOrWhiteSpace(parameter.Type)) yield return parameter.Type; - foreach (var attribute in parameter.Attributes) - yield return StripAttributeArguments(attribute); + if (includeParameterAttributes) + { + foreach (var attribute in parameter.Attributes) + yield return StripAttributeArguments(attribute); + } } foreach (var typeParameter in signatureModel.TypeParameters) foreach (var constraint in typeParameter.Constraints) @@ -1257,11 +1321,55 @@ static IEnumerable DottedIdentifierTokens(string text) continue; } - var start = i++; - while (i < text.Length && (IsIdentifierPart(text[i]) || text[i] is '.' or '+' or '@')) - i++; - yield return text[start..i].TrimEnd('.'); + var token = new StringBuilder(); + bool yieldedConstructedRoot = false; + while (i < text.Length) + { + int segmentStart = i++; + while (i < text.Length && IsIdentifierPart(text[i])) + i++; + token.Append(text.AsSpan(segmentStart, i - segmentStart)); + + if (i < text.Length && text[i] == '<') + { + if (!yieldedConstructedRoot) + { + yield return token.ToString(); + yieldedConstructedRoot = true; + } + int close = MatchingAngleBracket(text, i); + if (close < 0) + break; + foreach (string nested in DottedIdentifierTokens(text[(i + 1)..close])) + yield return nested; + i = close + 1; + } + + if (i + 1 >= text.Length + || text[i] is not ('.' or '+') + || (!IsIdentifierStart(text[i + 1]) && text[i + 1] != '@')) + { + break; + } + + token.Append(text[i++]); + } + if (!yieldedConstructedRoot) + yield return token.ToString(); + } + } + + static int MatchingAngleBracket(string text, int open) + { + int depth = 0; + for (int i = open; i < text.Length; i++) + { + if (text[i] == '<') + depth++; + else if (text[i] == '>' && --depth == 0) + return i; } + return -1; } /// @@ -1790,8 +1898,8 @@ static string TypeParameterDisplayName(TypeParameter typeParameter) static string FormatObsoleteAttribute(string? message) => string.IsNullOrWhiteSpace(message) - ? "[Obsolete]" - : $"[Obsolete(\"{EscapeCSharpString(message)}\")]"; + ? "[System.Obsolete]" + : $"[System.Obsolete(\"{EscapeCSharpString(message)}\")]"; // The Obsolete message is attacker-controlled attribute text rendered inside a // C# string literal. Escaping only the classic C-escapes leaves vertical tabs, @@ -2550,9 +2658,62 @@ sealed record TypeNamePlan( { public string Apply(string text) { - foreach (var (qualified, replacements) in Replacements) - text = ReplaceIdentifierToken(text, qualified, replacements); - return text; + var sb = new StringBuilder(text.Length); + for (var i = 0; i < text.Length;) + { + if (IsStringLiteralStart(text, i)) + { + var end = SkipStringLiteral(text, i); + sb.Append(text.AsSpan(i, end - i)); + i = end; + continue; + } + if (text[i] == '\'') + { + var end = SkipCharLiteral(text, i); + sb.Append(text.AsSpan(i, end - i)); + i = end; + continue; + } + + bool matched = false; + foreach (var (token, replacements) in Replacements) + { + if (i + token.Length > text.Length + || !text.AsSpan(i, token.Length).SequenceEqual(token) + || !IsStartBoundary(text, i - 1) + || !IsEndBoundary(text, i + token.Length)) + { + continue; + } + + if (IsWithinGlobalAlias(text, i)) + { + string qualified = replacements.Qualified; + sb.Append(qualified.StartsWith("global::", StringComparison.Ordinal) + ? qualified["global::".Length..] + : qualified); + } + else + { + bool preserveQualification = IsAttributeValuePrefix( + text, + i, + i + token.Length); + sb.Append(preserveQualification + ? replacements.Qualified + : replacements.Shortened ?? replacements.Qualified); + } + i += token.Length; + matched = true; + break; + } + + if (!matched) + sb.Append(text[i++]); + } + + return sb.ToString(); } public static TypeNamePlan Create( @@ -2656,16 +2817,29 @@ public static TypeNamePlan Create( }) .Select(typeRef => typeRef.SimpleName)); - var collisions = CollidingSimpleNames(bindingTypeRefs); + var shorteningTypeRefs = bindingTypeRefs + .Where(reference => !qualificationOnlyFullNames.Contains(reference.FullName)) + .ToList(); + var contextualUsings = options.Usings.ToHashSet(StringComparer.Ordinal); + var potentiallyImportedNamespaces = contextualUsings.ToHashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(options.ContainingNamespace)) + potentiallyImportedNamespaces.Add(options.ContainingNamespace); + if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings) + potentiallyImportedNamespaces.UnionWith(shorteningTypeRefs.Select(reference => reference.Namespace)); + var collisionEvidence = bindingTypeRefs + .Where(reference => !qualificationOnlyFullNames.Contains(reference.FullName) + || potentiallyImportedNamespaces.Contains(reference.Namespace)) + .ToList(); + var collisions = CollidingSimpleNames(collisionEvidence); var allShadowingNames = lexicalShadowingNames .Concat(namespaceShadowingNames) .ToHashSet(StringComparer.Ordinal); var unsafeNamespaces = UnsafeNamespaces( - bindingTypeRefs, + shorteningTypeRefs, allShadowingNames, collisions, - rootShadowingNames); - var contextualUsings = options.Usings.ToHashSet(StringComparer.Ordinal); + rootShadowingNames, + bindingEvidence: bindingTypeRefs); var generatedUsings = new SortedSet(StringComparer.Ordinal); var diagnostics = new List(); var replacements = new Dictionary( @@ -2682,11 +2856,6 @@ void ReplaceQualifiedName( void Add(string key) { - if (shortenedReplacement is null - && string.Equals(key, qualifiedReplacement, StringComparison.Ordinal)) - { - return; - } replacements[key] = plan; } } @@ -2745,6 +2914,11 @@ bool IsKnownNamespaceRoot(string root) KeepAttributeValueQualified(typeRef); continue; } + if (qualificationOnlyFullNames.Contains(typeRef.FullName)) + { + KeepResolvableQualified(typeRef); + continue; + } var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); if (!isSameNamespace && collisions.Contains(typeRef.SimpleName)) @@ -2771,11 +2945,6 @@ bool IsKnownNamespaceRoot(string root) KeepResolvableQualified(typeRef); continue; } - if (qualificationOnlyFullNames.Contains(typeRef.FullName)) - { - KeepResolvableQualified(typeRef); - continue; - } var isInContext = isSameNamespace || contextualUsings.Contains(typeRef.Namespace); if (options.TypeNameMode == CSharpTypeNameMode.ContextualShort && !isInContext) @@ -2800,60 +2969,6 @@ bool IsKnownNamespaceRoot(string root) diagnostics); } - static string ReplaceIdentifierToken( - string text, - string token, - (string Qualified, string? Shortened) replacements) - { - var sb = new StringBuilder(text.Length); - for (var i = 0; i < text.Length;) - { - if (IsStringLiteralStart(text, i)) - { - var end = SkipStringLiteral(text, i); - sb.Append(text.AsSpan(i, end - i)); - i = end; - continue; - } - if (text[i] == '\'') - { - var end = SkipCharLiteral(text, i); - sb.Append(text.AsSpan(i, end - i)); - i = end; - continue; - } - if (i + token.Length <= text.Length - && text.AsSpan(i, token.Length).SequenceEqual(token) - && IsStartBoundary(text, i - 1) - && IsEndBoundary(text, i + token.Length)) - { - if (IsWithinGlobalAlias(text, i)) - { - string qualified = replacements.Qualified; - sb.Append(qualified.StartsWith("global::", StringComparison.Ordinal) - ? qualified["global::".Length..] - : qualified); - i += token.Length; - continue; - } - bool preserveQualification = IsAttributeValuePrefix( - text, - i, - i + token.Length); - string replacement = preserveQualification - ? replacements.Qualified - : replacements.Shortened ?? replacements.Qualified; - sb.Append(replacement); - i += token.Length; - continue; - } - - sb.Append(text[i++]); - } - - return sb.ToString(); - } - static bool IsWithinGlobalAlias(string text, int index) { var start = index; diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index fae55904c5..f8e4c6239e 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -83,6 +83,26 @@ public CSharpTypePrintResult PrintBatch( $"Namespace root '{globalTypeName}' conflicts with global type '{globalTypeName}'; emitted namespace or using directives cannot bind that root as a namespace.")); } } + var globalAttributeOptions = new CSharpDeclarationOptions + { + TypeNameMode = options.TypeNamePolicy == CSharpTypeNamePolicy.Qualified + ? CSharpTypeNameMode.Qualified + : CSharpTypeNameMode.ContextualShort, + Usings = contextualUsings, + AdditionalRootShadowingNames = globalDeclaredTypeNames, + AdditionalUnresolvableRootNames = globalDeclaredTypeNames, + AdditionalKnownNamespaces = typeNameContext.KnownNamespaces + }; + var plannedAssemblyAttributes = CSharpDeclarationWriter.RenderAttributeBodies( + options.AssemblyAttributes, + globalAttributeOptions); + var plannedModuleAttributes = CSharpDeclarationWriter.RenderAttributeBodies( + options.ModuleAttributes, + globalAttributeOptions); + diagnostics.AddRange(plannedAssemblyAttributes.Diagnostics.Select( + diagnostic => new CSharpTypePrintDiagnostic("", diagnostic))); + diagnostics.AddRange(plannedModuleAttributes.Diagnostics.Select( + diagnostic => new CSharpTypePrintDiagnostic("", diagnostic))); var units = ImmutableArray.CreateBuilder(); foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { @@ -143,7 +163,12 @@ public CSharpTypePrintResult PrintBatch( unitList, diagnostics.Distinct().ToImmutableArray(), emittedUsings, - () => ComposeSource(unitList, emittedUsings, options)); + () => ComposeSource( + unitList, + emittedUsings, + plannedAssemblyAttributes.Attributes, + plannedModuleAttributes.Attributes, + options)); } /// @@ -175,7 +200,8 @@ void Flatten(PreparedType prepared) return CSharpDeclarationWriter.DeriveTypeNameContext( scopes, - options.Usings); + options.Usings, + options.AssemblyAttributes.Concat(options.ModuleAttributes)); } static string NamespaceRoot(string @namespace) @@ -212,14 +238,16 @@ static IReadOnlyList TypeNameContext( static string ComposeSource( ImmutableArray units, IReadOnlyCollection usings, + IReadOnlyList assemblyAttributes, + IReadOnlyList moduleAttributes, CSharpTypePrintOptions options) { var sb = new System.Text.StringBuilder(); if (options.EmitPragmaWarningDisable) sb.AppendLf("#pragma warning disable"); - foreach (var attribute in options.AssemblyAttributes) + foreach (var attribute in assemblyAttributes) sb.AppendLf($"[assembly: {attribute}]"); - foreach (var attribute in options.ModuleAttributes) + foreach (var attribute in moduleAttributes) sb.AppendLf($"[module: {attribute}]"); if (options.IncludeUsings) { diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index 5b591f1a73..0389e8cc76 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"\u00600(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"97A66F7359EB8307DEBCCC7F0BD0D9B6E3D496206758EF4A061113F9675F27E2","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration \u0026\u0026 NeedsTerminator(declaration))\n declaration \u002B= \u0022;\u0022;\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663608,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663610,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} From 155218b3fe865b58bf36f2ec4e5a92aa232e2ea7 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Fri, 7 Aug 2026 18:50:55 -0700 Subject: [PATCH 10/18] Preserve synthesized attribute spelling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpDeclarationWriter.cs | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 7d72e9b433..95df61d762 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -74,15 +74,17 @@ public static CSharpRenderedDeclaration RenderMemberUnit( member.Attributes, member) .Concat(CollectExplicitInterfaceTypeReferences(member)) - .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []) .ToHashSet(StringComparer.Ordinal); + var synthesizedAttributeReferences = member.IsObsolete && options.IncludeObsoleteAttribute + ? new HashSet(["System.Obsolete"], StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); var explicitInterfaceReferences = CollectExplicitInterfaceTypeReferences(member) .ToHashSet(StringComparer.Ordinal); var references = memberReferences .Concat(attributeReferences) .Concat(explicitInterfaceReferences) - .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []); + .Concat(synthesizedAttributeReferences); var plan = TypeNamePlan.Create( references, options, @@ -90,7 +92,8 @@ public static CSharpRenderedDeclaration RenderMemberUnit( CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, memberReferences.Except(explicitInterfaceReferences).ToHashSet(StringComparer.Ordinal), - attributeValueReferences); + attributeValueReferences, + synthesizedAttributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); @@ -115,15 +118,17 @@ public static string RenderMemberDeclaration( member.Attributes, member) .Concat(CollectExplicitInterfaceTypeReferences(member)) - .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []) .ToHashSet(StringComparer.Ordinal); + var synthesizedAttributeReferences = member.IsObsolete && options.IncludeObsoleteAttribute + ? new HashSet(["System.Obsolete"], StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); var memberReferences = CollectMemberTypeReferences(member).ToHashSet(StringComparer.Ordinal); var explicitInterfaceReferences = CollectExplicitInterfaceTypeReferences(member) .ToHashSet(StringComparer.Ordinal); var references = memberReferences .Concat(attributeReferences) .Concat(explicitInterfaceReferences) - .Concat(member.IsObsolete && options.IncludeObsoleteAttribute ? ["System.Obsolete"] : []); + .Concat(synthesizedAttributeReferences); var plan = TypeNamePlan.Create( references, options, @@ -131,7 +136,8 @@ public static string RenderMemberDeclaration( CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, memberReferences.Except(explicitInterfaceReferences).ToHashSet(StringComparer.Ordinal), - attributeValueReferences); + attributeValueReferences, + synthesizedAttributeReferences); var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters); declaration = plan.Apply(declaration); return options.TerminateMemberDeclaration && NeedsTerminator(declaration) @@ -163,9 +169,10 @@ public static CSharpRenderedDeclaration RenderTypeUnit( .Concat(parameters.SelectMany(parameter => CollectAttributeArgumentTypeReferences(parameter.Attributes))) .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)) - .Concat(memberList - .Where(member => member.IsObsolete && options.IncludeObsoleteAttribute) - .Select(_ => "System.Obsolete")) + .ToHashSet(StringComparer.Ordinal); + var synthesizedAttributeReferences = memberList + .Where(member => member.IsObsolete && options.IncludeObsoleteAttribute) + .Select(_ => "System.Obsolete") .ToHashSet(StringComparer.Ordinal); var primaryParameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) @@ -181,9 +188,7 @@ public static CSharpRenderedDeclaration RenderTypeUnit( .Concat(attributeReferences) .Concat(primaryParameterAttributeReferences) .Concat(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)) - .Concat(memberList - .Where(member => member.IsObsolete && options.IncludeObsoleteAttribute) - .Select(_ => "System.Obsolete")); + .Concat(synthesizedAttributeReferences); var plan = TypeNamePlan.Create( references, options, @@ -191,7 +196,8 @@ public static CSharpRenderedDeclaration RenderTypeUnit( CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, shortenableReferences, - attributeValueReferences); + attributeValueReferences, + synthesizedAttributeReferences); string typeDeclaration = AddPrimaryConstructorParameters( type, @@ -2723,7 +2729,8 @@ public static TypeNamePlan Create( string declaredTypeName, IReadOnlySet? qualificationOnlyReferences = null, IReadOnlySet? shortenableReferences = null, - IReadOnlySet? valueReferences = null) + IReadOnlySet? valueReferences = null, + IReadOnlySet? preferredSimpleNameReferences = null) { var qualificationOnlyFullNames = qualificationOnlyReferences? .Select(TypeRef.TryCreate) @@ -2753,6 +2760,12 @@ public static TypeNamePlan Create( .Where(reference => reference is not null) .Select(reference => reference!.FullName)); } + var preferredSimpleNameFullNames = preferredSimpleNameReferences? + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); var typeRefs = references .Select(TypeRef.TryCreate) .Where(r => r is not null) @@ -2892,21 +2905,6 @@ bool IsKnownNamespaceRoot(string root) string.Equals(@namespace, root, StringComparison.Ordinal) || @namespace.StartsWith($"{root}.", StringComparison.Ordinal)); - if (options.TypeNameMode == CSharpTypeNameMode.Qualified) - { - foreach (var typeRef in typeRefs) - { - if (valueOnlyFullNames.Contains(typeRef.FullName)) - KeepAttributeValueQualified(typeRef); - else - KeepResolvableQualified(typeRef); - } - return new TypeNamePlan( - replacements.OrderByDescending(kvp => kvp.Key.Length).ToArray(), - [], - diagnostics); - } - foreach (var typeRef in typeRefs) { if (valueOnlyFullNames.Contains(typeRef.FullName)) @@ -2919,6 +2917,12 @@ bool IsKnownNamespaceRoot(string root) KeepResolvableQualified(typeRef); continue; } + if (options.TypeNameMode == CSharpTypeNameMode.Qualified + && !preferredSimpleNameFullNames.Contains(typeRef.FullName)) + { + KeepResolvableQualified(typeRef); + continue; + } var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); if (!isSameNamespace && collisions.Contains(typeRef.SimpleName)) From 9e76750261dc547534ad6c7c6bdcde91f9978612 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 8 Aug 2026 09:58:06 -0700 Subject: [PATCH 11/18] Resolve round seven planner findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpFormatterTests.cs | 35 ++++++ .../CSharpTypePrinterTests.cs | 64 +++++++++++ .../CSharpDeclarationWriter.cs | 100 ++++++++++++++---- src/ILInspector.CSharp/CSharpTypePrinter.cs | 3 +- 4 files changed, 178 insertions(+), 24 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index 9b62b785cd..ecafe67b61 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -115,6 +115,41 @@ public void FormatsPrimaryConstructorParametersInTypeUnit() Assert.Contains("Attributes.Other", declaration.Usings); } + [Fact] + public void PrimaryConstructorAttributeSuffixShadowKeepsQualifiedName() + { + var type = new ApiType + { + Namespace = "Samples", + Name = "Worker", + Kind = "class" + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings, + AdditionalRootShadowingNames = ["MarkerAttribute"] + }); + + var declaration = formatter.FormatTypeUnit( + type, + members: null, + primaryConstructorParameters: + [ + new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["External.Marker"] + } + ]); + + Assert.Contains( + "public class Worker([External.Marker] int value)", + declaration.Text, + StringComparison.Ordinal); + Assert.DoesNotContain("External", declaration.Usings); + } + [Theory] [InlineData(CSharpTypeNamePolicy.Qualified, "public System.Threading.Tasks.Task Run()", false)] [InlineData(CSharpTypeNamePolicy.ShortWithUsings, "public Task Run()", true)] diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index a4243e1465..ee523b5718 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2690,6 +2690,34 @@ public void PrimaryConstructorAttributeEvidenceContributesToMemberPlanning() StringComparison.Ordinal); } + [Fact] + public void PrimaryConstructorAttributeNameRemainsQualified() + { + var type = CreateEmptyType("Samples", "Host"); + var method = CreateMethod("Get"); + method.SignatureModel!.ReturnType = "B.Marker"; + type.Members.Add(method); + var parameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["External.Marker"] + }; + + var result = _printer.Print(new CSharpTypePrintRequest( + type, + primaryConstructorParameters: [parameter])); + + Assert.Contains( + "public class Host([External.Marker] int value)", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("using External;", result.Source, StringComparison.Ordinal); + Assert.Contains("using B;", result.Source, StringComparison.Ordinal); + Assert.Contains("public Marker Get();", result.Source, StringComparison.Ordinal); + Assert.Empty(result.Diagnostics); + } + [Fact] public void ExplicitInterfaceDualProvenanceRemainsQualified() { @@ -2797,6 +2825,25 @@ public void HiddenCustomAttributesStillContributeBindingEvidence() StringComparison.Ordinal); } + [Fact] + public void HiddenCustomAttributeDoesNotReportAnEmittedRootConflict() + { + var root = CreateEmptyType("", "Foo"); + var type = CreateEmptyType("App", "Widget"); + type.Attributes = ["Foo.Bar.Marker"]; + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(root), new CSharpTypePrintRequest(type)], + new CSharpTypePrintOptions { IncludeCustomAttributes = false }); + + Assert.DoesNotContain("Marker", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "Type name 'Foo.Bar.Marker' conflicts with global type 'Foo'", + StringComparison.Ordinal)); + } + [Fact] public void ImportedHiddenAttributeNamespacePreventsConflictingSignatureShortening() { @@ -3458,6 +3505,23 @@ public void SynthesizedObsoleteReportsGlobalSystemConflict() StringComparison.Ordinal)); } + [Fact] + public void GenericGlobalTypeDoesNotConflictWithNamespaceRoot() + { + var generic = CreateEmptyType("", "Foo`1"); + generic.TypeParameters = [new TypeParameter { Name = "T" }]; + var namespaced = CreateEmptyType("Foo.Bar", "Worker"); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(generic), new CSharpTypePrintRequest(namespaced)]); + + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "Namespace root 'Foo' conflicts with global type 'Foo'", + StringComparison.Ordinal)); + } + [Fact] public void SourceEscapesAndDeduplicatesUsings() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 95df61d762..f446614099 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -177,6 +177,9 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var primaryParameterAttributeReferences = parameters .SelectMany(parameter => CollectAttributeTypeReferences(parameter.Attributes)) .ToHashSet(StringComparer.Ordinal); + var primaryParameterDeclaredAttributeReferences = parameters + .SelectMany(parameter => CollectDeclaredAttributeTypeReferences(parameter.Attributes)) + .ToHashSet(StringComparer.Ordinal); var shortenableReferences = CollectTypeReferences(type) .Concat(memberList.SelectMany(member => CollectMemberTypeReferences(member))) .Concat(parameters.SelectMany(parameter => @@ -197,7 +200,8 @@ public static CSharpRenderedDeclaration RenderTypeUnit( qualificationOnlyAttributeReferences, shortenableReferences, attributeValueReferences, - synthesizedAttributeReferences); + synthesizedAttributeReferences, + primaryParameterDeclaredAttributeReferences); string typeDeclaration = AddPrimaryConstructorParameters( type, @@ -299,6 +303,9 @@ public static string RenderTypeDeclaration( .Concat(parameters.SelectMany(parameter => ExtractQualifiedTypeNames(parameter.Type))) .ToHashSet(StringComparer.Ordinal); + var primaryParameterDeclaredAttributeReferences = parameters + .SelectMany(parameter => CollectDeclaredAttributeTypeReferences(parameter.Attributes)) + .ToHashSet(StringComparer.Ordinal); var plan = TypeNamePlan.Create( shortenableReferences .Concat(parameters.SelectMany(CollectParameterTypeReferences)) @@ -309,7 +316,8 @@ public static string RenderTypeDeclaration( CSharpFormatter.StripArity(type.Name), qualificationOnlyAttributeReferences, shortenableReferences, - attributeValueReferences); + attributeValueReferences, + attributeNameReferences: primaryParameterDeclaredAttributeReferences); string declaration = AddPrimaryConstructorParameters( type, RenderTypeDeclarationCore(type, options), @@ -382,7 +390,8 @@ internal static ( CollectMemberTypeReferences( member, includeParameterAttributes: scope.Type.Kind != "delegate"))) - .Concat(scope.AdditionalParameters.SelectMany(CollectParameterTypeReferences))) + .Concat(scope.AdditionalParameters.SelectMany(parameter => + ExtractQualifiedTypeNames(parameter.Type)))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -2658,9 +2667,9 @@ static int SkipInterpolationHole(string text, int open, int depth) } sealed record TypeNamePlan( - IReadOnlyList> Replacements, + IReadOnlyList> Replacements, IReadOnlyList GeneratedUsings, - IReadOnlyList Diagnostics) + List Diagnostics) { public string Apply(string text) { @@ -2699,6 +2708,7 @@ public string Apply(string text) sb.Append(qualified.StartsWith("global::", StringComparison.Ordinal) ? qualified["global::".Length..] : qualified); + AddDiagnostic(replacements.Diagnostic); } else { @@ -2706,9 +2716,12 @@ public string Apply(string text) text, i, i + token.Length); - sb.Append(preserveQualification + string replacement = preserveQualification ? replacements.Qualified - : replacements.Shortened ?? replacements.Qualified); + : replacements.Shortened ?? replacements.Qualified; + sb.Append(replacement); + if (replacement == replacements.Qualified) + AddDiagnostic(replacements.Diagnostic); } i += token.Length; matched = true; @@ -2720,6 +2733,15 @@ public string Apply(string text) } return sb.ToString(); + + void AddDiagnostic(string? diagnostic) + { + if (diagnostic is not null + && !Diagnostics.Contains(diagnostic, StringComparer.Ordinal)) + { + Diagnostics.Add(diagnostic); + } + } } public static TypeNamePlan Create( @@ -2730,7 +2752,8 @@ public static TypeNamePlan Create( IReadOnlySet? qualificationOnlyReferences = null, IReadOnlySet? shortenableReferences = null, IReadOnlySet? valueReferences = null, - IReadOnlySet? preferredSimpleNameReferences = null) + IReadOnlySet? preferredSimpleNameReferences = null, + IReadOnlySet? attributeNameReferences = null) { var qualificationOnlyFullNames = qualificationOnlyReferences? .Select(TypeRef.TryCreate) @@ -2766,6 +2789,12 @@ public static TypeNamePlan Create( .Select(reference => reference!.FullName) .ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal); + var attributeNameFullNames = attributeNameReferences? + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal) + ?? new HashSet(StringComparer.Ordinal); var typeRefs = references .Select(TypeRef.TryCreate) .Where(r => r is not null) @@ -2840,8 +2869,7 @@ public static TypeNamePlan Create( if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings) potentiallyImportedNamespaces.UnionWith(shorteningTypeRefs.Select(reference => reference.Namespace)); var collisionEvidence = bindingTypeRefs - .Where(reference => !qualificationOnlyFullNames.Contains(reference.FullName) - || potentiallyImportedNamespaces.Contains(reference.Namespace)) + .Where(reference => potentiallyImportedNamespaces.Contains(reference.Namespace)) .ToList(); var collisions = CollidingSimpleNames(collisionEvidence); var allShadowingNames = lexicalShadowingNames @@ -2855,14 +2883,15 @@ public static TypeNamePlan Create( bindingEvidence: bindingTypeRefs); var generatedUsings = new SortedSet(StringComparer.Ordinal); var diagnostics = new List(); - var replacements = new Dictionary( + var replacements = new Dictionary( StringComparer.Ordinal); void ReplaceQualifiedName( TypeRef typeRef, string qualifiedReplacement, - string? shortenedReplacement = null) + string? shortenedReplacement = null, + string? diagnostic = null) { - var plan = (qualifiedReplacement, shortenedReplacement); + var plan = (qualifiedReplacement, shortenedReplacement, diagnostic); Add(typeRef.FullName); Add(EscapeQualifiedKeywordSegments(typeRef.FullName)); Add(EscapeNamespace(typeRef.FullName)); @@ -2875,21 +2904,25 @@ void Add(string key) string ResolvableQualifiedName(TypeRef typeRef) { string root = NamespaceRoot(typeRef.Namespace); - if (options.AdditionalUnresolvableRootNames.Contains(root) - && IsKnownNamespaceRoot(root)) - { - diagnostics.Add( - $"Type name '{typeRef.FullName}' conflicts with global type '{root}'; emitted the only available global-qualified spelling."); - } - string escapedFullName = EscapeNamespace(typeRef.FullName); return rootShadowingNames.Contains(root) ? $"global::{escapedFullName}" : escapedFullName; } + string? UnresolvableRootDiagnostic(TypeRef typeRef) + { + string root = NamespaceRoot(typeRef.Namespace); + return options.AdditionalUnresolvableRootNames.Contains(root) + && IsKnownNamespaceRoot(root) + ? $"Type name '{typeRef.FullName}' conflicts with global type '{root}'; emitted the only available global-qualified spelling." + : null; + } void KeepResolvableQualified(TypeRef typeRef) { - ReplaceQualifiedName(typeRef, ResolvableQualifiedName(typeRef)); + ReplaceQualifiedName( + typeRef, + ResolvableQualifiedName(typeRef), + diagnostic: UnresolvableRootDiagnostic(typeRef)); } void KeepAttributeValueQualified(TypeRef typeRef) { @@ -2898,7 +2931,10 @@ void KeepAttributeValueQualified(TypeRef typeRef) && !IsKnownNamespaceRoot(root) ? EscapeNamespace(typeRef.FullName) : ResolvableQualifiedName(typeRef); - ReplaceQualifiedName(typeRef, qualified); + ReplaceQualifiedName( + typeRef, + qualified, + diagnostic: UnresolvableRootDiagnostic(typeRef)); } bool IsKnownNamespaceRoot(string root) => options.AdditionalKnownNamespaces.Any(@namespace => @@ -2923,6 +2959,23 @@ bool IsKnownNamespaceRoot(string root) KeepResolvableQualified(typeRef); continue; } + if (attributeNameFullNames.Contains(typeRef.FullName) + && !typeRef.SimpleName.EndsWith("Attribute", StringComparison.Ordinal)) + { + string suffixedName = $"{typeRef.SimpleName}Attribute"; + bool suffixCanBind = rootShadowingNames.Contains(suffixedName) + || allShadowingNames.Contains(suffixedName) + || collisionEvidence.Any(reference => + reference.FullName != typeRef.FullName + && reference.SimpleName == suffixedName); + if (suffixCanBind) + { + diagnostics.Add( + $"Attribute name '{typeRef.SimpleName}' can bind to '{suffixedName}'; kept '{typeRef.FullName}' qualified."); + KeepResolvableQualified(typeRef); + continue; + } + } var isSameNamespace = !string.IsNullOrWhiteSpace(options.ContainingNamespace) && string.Equals(typeRef.Namespace, options.ContainingNamespace, StringComparison.Ordinal); if (!isSameNamespace && collisions.Contains(typeRef.SimpleName)) @@ -2962,7 +3015,8 @@ bool IsKnownNamespaceRoot(string root) valueFullNames.Contains(typeRef.FullName) ? ResolvableQualifiedName(typeRef) : EscapeNamespace(typeRef.FullName), - EscapeIdentifier(typeRef.SimpleName)); + EscapeIdentifier(typeRef.SimpleName), + UnresolvableRootDiagnostic(typeRef)); if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings && !isSameNamespace) generatedUsings.Add(typeRef.Namespace); } diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index f8e4c6239e..d27724c9da 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -67,7 +67,8 @@ public CSharpTypePrintResult PrintBatch( .Select(type => CSharpFormatter.StripArity(type.Type.Name)) .ToImmutableHashSet(StringComparer.Ordinal); var globalDeclaredTypeNames = preparedTypes - .Where(type => type.Namespace.Length == 0) + .Where(type => type.Namespace.Length == 0 + && type.Type.TypeParameters.Count == 0) .Select(type => CSharpFormatter.StripArity(type.Type.Name)) .ToImmutableHashSet(StringComparer.Ordinal); foreach (string globalTypeName in globalDeclaredTypeNames) From 92dd7633774667bd9ac90543e2d09715ab807f91 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sat, 8 Aug 2026 22:59:27 -0700 Subject: [PATCH 12/18] Harden type planner edge cases Preserve binding across imported nested type paths, unit-wide attribute suffix lookup, raw string literals, and global nested declarations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 243 +++++++++++++++++- .../CSharpDeclarationWriter.cs | 128 +++++---- src/ILInspector.CSharp/CSharpFormatter.cs | 2 + src/ILInspector.CSharp/CSharpTypePrinter.cs | 45 +++- .../corpus/two-row-authored-corpus.jsonl | 2 +- 5 files changed, 362 insertions(+), 58 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index ee523b5718..797087b422 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -1269,6 +1269,86 @@ public void DeclaredNestedPathInOtherNamespaceDoesNotCaptureAttributeValue() StringComparison.Ordinal); } + [Fact] + public void ImportedDeclaredNestedPathKeepsRelativeAttributeValue() + { + var foo = CreateEmptyType("A", "Foo"); + var options = CreateEmptyType("A", "Options"); + var consumer = CreateEmptyType("App", "Consumer"); + consumer.Attributes = ["Ext.Opt(Foo.Options.Fast)"]; + var method = CreateMethod("GetFoo"); + method.SignatureModel!.ReturnType = "A.Foo"; + consumer.Members.Add(method); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + foo, + nestedTypes: [new CSharpTypePrintRequest(options)]), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.Contains("using A;", result.Source, StringComparison.Ordinal); + Assert.Contains("[Ext.Opt(Foo.Options.Fast)]", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("global::Foo.Options.Fast", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void AttributeValueDoesNotDriveImportedDeclaredNestedPathUsing() + { + var foo = CreateEmptyType("A", "Foo"); + var options = CreateEmptyType("A", "Options"); + var consumer = CreateEmptyType("App", "Consumer"); + consumer.Attributes = ["Ext.Opt(Foo.Options.Fast)"]; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + foo, + nestedTypes: [new CSharpTypePrintRequest(options)]), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.DoesNotContain("using A;", result.Source, StringComparison.Ordinal); + Assert.Contains("[Ext.Opt(Foo.Options.Fast)]", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void AmbiguousImportedNestedPathIsNotPreservedAsRelative() + { + var aFoo = CreateEmptyType("A", "Foo"); + var aOptions = CreateEmptyType("A", "Options"); + var bFoo = CreateEmptyType("B", "Foo"); + var bOptions = CreateEmptyType("B", "Options"); + var consumer = CreateEmptyType("App", "Consumer"); + consumer.Attributes = ["Ext.Opt(Foo.Options.Fast)"]; + var getLeft = CreateMethod("GetLeft"); + getLeft.SignatureModel!.ReturnType = "A.Left"; + var getRight = CreateMethod("GetRight"); + getRight.SignatureModel!.ReturnType = "B.Right"; + consumer.Members.Add(getLeft); + consumer.Members.Add(getRight); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + aFoo, + nestedTypes: [new CSharpTypePrintRequest(aOptions)]), + new CSharpTypePrintRequest( + bFoo, + nestedTypes: [new CSharpTypePrintRequest(bOptions)]), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.Contains("using A;", result.Source, StringComparison.Ordinal); + Assert.Contains("using B;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("[Ext.Opt(Foo.Options.Fast)]", result.Source, StringComparison.Ordinal); + Assert.Contains("[Ext.Opt(global::Foo.Options.Fast)]", result.Source, StringComparison.Ordinal); + } + [Fact] public void DeclaredNestedPathDoesNotCaptureKnownNamespaceReference() { @@ -1401,6 +1481,31 @@ public void DottedAttributeValueDoesNotInventShadowingNamespace() Assert.DoesNotContain("shadowed by a namespace", string.Join('\n', result.Diagnostics), StringComparison.Ordinal); } + [Fact] + public void RawStringAttributeValueIsNotRewritten() + { + var type = CreateEmptyType("App", "Consumer"); + var method = CreateMethod("Get"); + method.SignatureModel!.ReturnType = "N.Type"; + method.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "string", + Name = "value", + Attributes = ["Ext.Note(\"\"\"\"N.Type\"\"\"\")"] + }); + type.Members.Add(method); + + var result = _printer.Print( + new CSharpTypePrintRequest(type), + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.Contains("\"\"\"\"N.Type\"\"\"\"", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain( + "[Ext.Note(\"\"\"\"Type\"\"\"\")]", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void SameNamespaceRootShadowRequalifiesDottedAttributeValue() { @@ -2718,6 +2823,121 @@ public void PrimaryConstructorAttributeNameRemainsQualified() Assert.Empty(result.Diagnostics); } + [Fact] + public void UnitWideAttributeSuffixCollisionPreventsUnsafeImports() + { + var host = CreateEmptyType("App", "Host"); + var method = CreateMethod("GetWidget"); + method.SignatureModel!.ReturnType = "External.Widget"; + host.Members.Add(method); + var parameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["External.Marker"] + }; + + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Collision.MarkerAttribute"; + var handler = CreateEmptyType("App", "Handler"); + handler.Kind = "delegate"; + handler.Members.Add(invoke); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + host, + primaryConstructorParameters: [parameter]), + new CSharpTypePrintRequest(handler) + ]); + + Assert.DoesNotContain("using External;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using Collision;", result.Source, StringComparison.Ordinal); + Assert.Contains("[External.Marker] int value", result.Source, StringComparison.Ordinal); + Assert.Contains( + "delegate Collision.MarkerAttribute Handler();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void SameNamespaceAttributeSuffixCollisionPreventsUnsafeImport() + { + var host = CreateEmptyType("App", "Host"); + var method = CreateMethod("GetWidget"); + method.SignatureModel!.ReturnType = "External.Widget"; + host.Members.Add(method); + var parameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["App.Marker"] + }; + + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Collision.MarkerAttribute"; + var handler = CreateEmptyType("App", "Handler"); + handler.Kind = "delegate"; + handler.Members.Add(invoke); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + host, + primaryConstructorParameters: [parameter]), + new CSharpTypePrintRequest(handler) + ]); + + Assert.Contains("using External;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using Collision;", result.Source, StringComparison.Ordinal); + Assert.Contains("[Marker] int value", result.Source, StringComparison.Ordinal); + Assert.Contains( + "delegate Collision.MarkerAttribute Handler();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void UnitWidePrimaryAttributeSuffixCollisionIsSymmetric() + { + var host = CreateEmptyType("App", "Host"); + var getLeft = CreateMethod("GetLeft"); + getLeft.SignatureModel!.ReturnType = "A.Left"; + host.Members.Add(getLeft); + var hostParameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["A.Marker"] + }; + + var worker = CreateEmptyType("App", "Worker"); + var getRight = CreateMethod("GetRight"); + getRight.SignatureModel!.ReturnType = "B.Right"; + worker.Members.Add(getRight); + var workerParameter = new ApiParameter + { + Type = "int", + Name = "value", + Attributes = ["B.MarkerAttribute"] + }; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + host, + primaryConstructorParameters: [hostParameter]), + new CSharpTypePrintRequest( + worker, + primaryConstructorParameters: [workerParameter]) + ]); + + Assert.DoesNotContain("using A;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using B;", result.Source, StringComparison.Ordinal); + Assert.Contains("[A.Marker] int value", result.Source, StringComparison.Ordinal); + Assert.Contains("[B.MarkerAttribute] int value", result.Source, StringComparison.Ordinal); + } + [Fact] public void ExplicitInterfaceDualProvenanceRemainsQualified() { @@ -2860,7 +3080,7 @@ public void ImportedHiddenAttributeNamespacePreventsConflictingSignatureShorteni new CSharpTypePrintRequest(type), new CSharpTypePrintOptions { IncludeCustomAttributes = false }); - Assert.Contains("using A;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("using A;", result.Source, StringComparison.Ordinal); Assert.Contains("using B;", result.Source, StringComparison.Ordinal); Assert.Contains("public A.Foo GetFoo();", result.Source, StringComparison.Ordinal); Assert.Contains("public Other GetOther();", result.Source, StringComparison.Ordinal); @@ -3522,6 +3742,27 @@ public void GenericGlobalTypeDoesNotConflictWithNamespaceRoot() StringComparison.Ordinal)); } + [Fact] + public void GlobalNestedTypeReferenceDoesNotReportNamespaceRootConflict() + { + var host = CreateEmptyType("", "Host"); + var kind = CreateEmptyType("", "Kind"); + var method = CreateMethod("GetKind"); + method.SignatureModel!.ReturnType = "Host.Kind"; + host.Members.Add(method); + + var result = _printer.Print(new CSharpTypePrintRequest( + host, + nestedTypes: [new CSharpTypePrintRequest(kind)])); + + Assert.Contains("public global::Host.Kind GetKind();", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Message.Contains( + "Type name 'Host.Kind' conflicts with global type 'Host'", + StringComparison.Ordinal)); + } + [Fact] public void SourceEscapesAndDeduplicatesUsings() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index f446614099..fade7cb486 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -28,6 +28,7 @@ internal sealed record CSharpDeclarationOptions public IReadOnlyCollection AdditionalRootShadowingNames = []; public IReadOnlyCollection AdditionalUnresolvableRootNames = []; public IReadOnlyCollection AdditionalDeclaredTypeFullNames = []; + public IReadOnlyCollection AdditionalImportedDeclaredTypeFullNames = []; public IReadOnlyCollection AdditionalKnownNamespaces = []; public CSharpNamespaceMode NamespaceMode { get; init; } = CSharpNamespaceMode.Omit; public bool AbbreviateSignature { get; init; } @@ -413,6 +414,14 @@ internal static ( .Select(r => r!) .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); + var primaryAttributeTypeRefs = scopeList + .SelectMany(scope => scope.AdditionalParameters.SelectMany(parameter => + CollectDeclaredAttributeTypeReferences(parameter.Attributes))) + .Select(TypeRef.TryCreate) + .Where(r => r is not null) + .Select(r => r!) + .DistinctBy(r => r.FullName, StringComparer.Ordinal) + .ToList(); var knownNamespaces = typeRefs .Select(typeRef => typeRef.Namespace) @@ -448,7 +457,53 @@ internal static ( } var usings = new SortedSet(StringComparer.Ordinal); + var potentiallyImportedNamespaces = typeRefs + .Select(typeRef => typeRef.Namespace) + .Concat(contextualNamespaces ?? []) + .Concat(scopeList + .Select(scope => scope.Type.Namespace) + .Where(ns => !string.IsNullOrWhiteSpace(ns)) + .Select(ns => ns!)) + .ToHashSet(StringComparer.Ordinal); var collidingSimpleNames = CollidingSimpleNames(typeRefs); + var unsafePrimaryAttributeNamespaces = new HashSet(StringComparer.Ordinal); + foreach (var attributeTypeRef in attributeTypeRefs) + { + if (!potentiallyImportedNamespaces.Contains(attributeTypeRef.Namespace)) + continue; + + var collidingTypeNames = new HashSet(StringComparer.Ordinal) + { + attributeTypeRef.SimpleName + }; + if (!attributeTypeRef.SimpleName.EndsWith("Attribute", StringComparison.Ordinal)) + collidingTypeNames.Add($"{attributeTypeRef.SimpleName}Attribute"); + bool collides = typeRefs.Any(typeRef => + typeRef.FullName != attributeTypeRef.FullName + && collidingTypeNames.Contains(typeRef.SimpleName)); + if (collides) + { + collidingSimpleNames.UnionWith(collidingTypeNames); + if (primaryAttributeTypeRefs.Any(primary => + primary.FullName == attributeTypeRef.FullName)) + { + unsafePrimaryAttributeNamespaces.Add(attributeTypeRef.Namespace); + } + } + } + foreach (var attributeTypeRef in primaryAttributeTypeRefs) + { + if (!potentiallyImportedNamespaces.Contains(attributeTypeRef.Namespace)) + continue; + + var lookupNames = AttributeLookupNames(attributeTypeRef); + bool collides = attributeTypeRefs.Any(other => + other.FullName != attributeTypeRef.FullName + && potentiallyImportedNamespaces.Contains(other.Namespace) + && lookupNames.Overlaps(AttributeLookupNames(other))); + if (collides) + unsafePrimaryAttributeNamespaces.Add(attributeTypeRef.Namespace); + } var unsafeNamespaces = UnsafeNamespaces( typeRefs, shadowingNames, @@ -457,6 +512,7 @@ internal static ( declaredTypeNames, declaredTypeFullNames, typeRefs.Concat(attributeTypeRefs).ToList()); + unsafeNamespaces.UnionWith(unsafePrimaryAttributeNamespaces); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) { @@ -479,6 +535,17 @@ internal static ( .Distinct() .ToList(); return (usings.ToList(), knownNamespaces, referencedTypeNames); + + static HashSet AttributeLookupNames(TypeRef typeRef) + { + var names = new HashSet(StringComparer.Ordinal) + { + typeRef.SimpleName + }; + if (!typeRef.SimpleName.EndsWith("Attribute", StringComparison.Ordinal)) + names.Add($"{typeRef.SimpleName}Attribute"); + return names; + } } static IEnumerable CollectParameterTypeReferences(ApiParameter parameter) @@ -2913,6 +2980,7 @@ string ResolvableQualifiedName(TypeRef typeRef) { string root = NamespaceRoot(typeRef.Namespace); return options.AdditionalUnresolvableRootNames.Contains(root) + && !options.AdditionalDeclaredTypeFullNames.Contains(typeRef.FullName) && IsKnownNamespaceRoot(root) ? $"Type name '{typeRef.FullName}' conflicts with global type '{root}'; emitted the only available global-qualified spelling." : null; @@ -2927,7 +2995,8 @@ void KeepResolvableQualified(TypeRef typeRef) void KeepAttributeValueQualified(TypeRef typeRef) { string root = NamespaceRoot(typeRef.Namespace); - string qualified = options.AdditionalDeclaredTypeFullNames.Contains(typeRef.FullName) + string qualified = (options.AdditionalDeclaredTypeFullNames.Contains(typeRef.FullName) + || options.AdditionalImportedDeclaredTypeFullNames.Contains(typeRef.FullName)) && !IsKnownNamespaceRoot(root) ? EscapeNamespace(typeRef.FullName) : ResolvableQualifiedName(typeRef); @@ -3096,56 +3165,27 @@ static bool IsAttributeValuePrefix(string text, int start, int end) } static bool IsStringLiteralStart(string text, int index) - => text[index] == '"' - || (text[index] == '@' && index + 1 < text.Length && text[index + 1] == '"') - || (text[index] == '$' && index + 1 < text.Length && text[index + 1] == '"') - || (text[index] == '$' && index + 2 < text.Length && text[index + 1] == '@' && text[index + 2] == '"') - || (text[index] == '@' && index + 2 < text.Length && text[index + 1] == '$' && text[index + 2] == '"'); + { + if (text[index] == '"') + return true; + if (text[index] is not ('@' or '$')) + return false; + do + { + index++; + } + while (index < text.Length && text[index] is '@' or '$'); + return index < text.Length && text[index] == '"'; + } static int SkipStringLiteral(string text, int start) { var i = start; - var verbatim = false; - if (text[i] == '$') - { + while (i < text.Length && text[i] is '@' or '$') i++; - if (i < text.Length && text[i] == '@') - { - verbatim = true; - i++; - } - } - else if (text[i] == '@') - { - i++; - if (i < text.Length && text[i] == '$') - i++; - verbatim = true; - } - if (i >= text.Length || text[i] != '"') return start + 1; - - i++; - while (i < text.Length) - { - if (text[i] == '"' && verbatim && i + 1 < text.Length && text[i + 1] == '"') - { - i += 2; - continue; - } - if (text[i] == '"') - return i + 1; - if (text[i] == '\\' && !verbatim && i + 1 < text.Length) - { - i += 2; - continue; - } - - i++; - } - - return text.Length; + return Math.Min(SkipLiteral(text, i) + 1, text.Length); } static int SkipCharLiteral(string text, int start) diff --git a/src/ILInspector.CSharp/CSharpFormatter.cs b/src/ILInspector.CSharp/CSharpFormatter.cs index e14b6e1226..8a09926f04 100644 --- a/src/ILInspector.CSharp/CSharpFormatter.cs +++ b/src/ILInspector.CSharp/CSharpFormatter.cs @@ -37,6 +37,7 @@ public sealed record CSharpFormatOptions internal IReadOnlyCollection AdditionalRootShadowingNames { get; init; } = []; internal IReadOnlyCollection AdditionalUnresolvableRootNames { get; init; } = []; internal IReadOnlyCollection AdditionalDeclaredTypeFullNames { get; init; } = []; + internal IReadOnlyCollection AdditionalImportedDeclaredTypeFullNames { get; init; } = []; internal IReadOnlyCollection AdditionalKnownNamespaces { get; init; } = []; public CSharpNamespacePolicy NamespacePolicy { get; init; } = CSharpNamespacePolicy.Omit; public bool AbbreviateSignature { get; init; } @@ -500,6 +501,7 @@ static CSharpDeclarationOptions ToDeclarationOptions( AdditionalRootShadowingNames = options.AdditionalRootShadowingNames, AdditionalUnresolvableRootNames = options.AdditionalUnresolvableRootNames, AdditionalDeclaredTypeFullNames = options.AdditionalDeclaredTypeFullNames, + AdditionalImportedDeclaredTypeFullNames = options.AdditionalImportedDeclaredTypeFullNames, AdditionalKnownNamespaces = options.AdditionalKnownNamespaces, NamespaceMode = options.NamespacePolicy switch { diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index d27724c9da..ffe95db376 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -62,6 +62,30 @@ public CSharpTypePrintResult PrintBatch( .Concat(derivedUsings) .ToImmutableHashSet(StringComparer.Ordinal) : ImmutableHashSet.Create(StringComparer.Ordinal); + var declaredTypeFullNamesByNamespace = + new Dictionary>(StringComparer.Ordinal); + foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) + { + var declaredTypeFullNames = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); + var pendingTypes = new Stack<(PreparedType Type, string? Parent)>( + group.Select(type => (type, (string?)null))); + while (pendingTypes.TryPop(out var pending)) + { + string name = CSharpFormatter.StripArity(pending.Type.Type.Name); + string fullName = pending.Parent is null ? name : $"{pending.Parent}.{name}"; + declaredTypeFullNames.Add(fullName); + foreach (var nested in pending.Type.NestedTypes) + pendingTypes.Push((nested, fullName)); + } + declaredTypeFullNamesByNamespace.Add(group.Key, declaredTypeFullNames.ToImmutable()); + } + var importedDeclaredTypeFullNames = declaredTypeFullNamesByNamespace + .Where(entry => emittedUsings.Contains(entry.Key)) + .SelectMany(entry => entry.Value.Select(path => (Namespace: entry.Key, Path: path))) + .GroupBy(entry => entry.Path, StringComparer.Ordinal) + .Where(group => group.Select(entry => entry.Namespace).Distinct(StringComparer.Ordinal).Count() == 1) + .Select(group => group.Key) + .ToImmutableHashSet(StringComparer.Ordinal); var importedDeclaredTypeNames = preparedTypes .Where(type => emittedUsings.Contains(type.Namespace)) .Select(type => CSharpFormatter.StripArity(type.Type.Name)) @@ -108,18 +132,7 @@ public CSharpTypePrintResult PrintBatch( foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { var groupedTypes = group.ToList(); - var declaredTypeFullNames = ImmutableHashSet.CreateBuilder(StringComparer.Ordinal); - var pendingTypes = new Stack<(PreparedType Type, string? Parent)>( - groupedTypes.Select(type => (type, (string?)null))); - while (pendingTypes.TryPop(out var pending)) - { - string name = CSharpFormatter.StripArity(pending.Type.Type.Name); - string fullName = pending.Parent is null ? name : $"{pending.Parent}.{name}"; - declaredTypeFullNames.Add(fullName); - foreach (var nested in pending.Type.NestedTypes) - pendingTypes.Push((nested, fullName)); - } - var declaredTypeFullNameSet = declaredTypeFullNames.ToImmutable(); + var declaredTypeFullNameSet = declaredTypeFullNamesByNamespace[group.Key]; var containingNamespace = group.Key.Length == 0 ? null : group.Key; var ancestorTypeNames = preparedTypes .Where(candidate => IsAncestorNamespace(candidate.Namespace, group.Key)) @@ -146,6 +159,7 @@ public CSharpTypePrintResult PrintBatch( .ToImmutableHashSet(StringComparer.Ordinal), globalDeclaredTypeNames, declaredTypeFullNameSet, + importedDeclaredTypeFullNames, typeNameContext.KnownNamespaces, diagnostics))); if (containingNamespace is not null) @@ -389,6 +403,7 @@ static string RenderType( IReadOnlySet inheritedRootShadowingNames, IReadOnlySet unresolvableRootNames, IReadOnlySet declaredTypeFullNames, + IReadOnlySet importedDeclaredTypeFullNames, IReadOnlyCollection knownNamespaces, ImmutableArray.Builder diagnostics) { @@ -405,6 +420,7 @@ static string RenderType( inheritedRootShadowingNames, unresolvableRootNames, declaredTypeFullNames, + importedDeclaredTypeFullNames, knownNamespaces); var diagnosticPass = DeclarationFormatter( prepared.Namespace, @@ -414,6 +430,7 @@ static string RenderType( inheritedRootShadowingNames, unresolvableRootNames, declaredTypeFullNames, + importedDeclaredTypeFullNames, knownNamespaces, terminateMemberDeclaration: true) .FormatTypeUnit( @@ -433,6 +450,7 @@ static string RenderType( inheritedRootShadowingNames, unresolvableRootNames, declaredTypeFullNames, + importedDeclaredTypeFullNames, knownNamespaces, omitPropertyAccessors: true); string pad = new(' ', indent * 4); @@ -468,6 +486,7 @@ static string RenderType( nestedRootShadowingNames, unresolvableRootNames, declaredTypeFullNames, + importedDeclaredTypeFullNames, knownNamespaces, diagnostics)); } @@ -704,6 +723,7 @@ static CSharpFormatter DeclarationFormatter( IReadOnlyCollection additionalRootShadowingNames, IReadOnlyCollection additionalUnresolvableRootNames, IReadOnlyCollection additionalDeclaredTypeFullNames, + IReadOnlyCollection additionalImportedDeclaredTypeFullNames, IReadOnlyCollection additionalKnownNamespaces, bool omitPropertyAccessors = false, bool terminateMemberDeclaration = false) @@ -718,6 +738,7 @@ static CSharpFormatter DeclarationFormatter( AdditionalRootShadowingNames = additionalRootShadowingNames, AdditionalUnresolvableRootNames = additionalUnresolvableRootNames, AdditionalDeclaredTypeFullNames = additionalDeclaredTypeFullNames, + AdditionalImportedDeclaredTypeFullNames = additionalImportedDeclaredTypeFullNames, AdditionalKnownNamespaces = additionalKnownNamespaces, NamespacePolicy = CSharpNamespacePolicy.Omit, IncludeCustomAttributes = options.IncludeCustomAttributes, diff --git a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl index 0389e8cc76..4bd43be0e8 100644 --- a/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl +++ b/tools/DecompilerHarness/corpus/two-row-authored-corpus.jsonl @@ -1,2 +1,2 @@ {"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpDeclarationWriter","method":"RenderMemberUnit","overload":0,"signature":"\u00600(ApiType,ApiMember,CSharpDeclarationOptions,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663442,"parameterCount":4,"ilSize":105,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpDeclarationWriter.cs","checksumAlgorithm":"SHA256","checksum":"97A66F7359EB8307DEBCCC7F0BD0D9B6E3D496206758EF4A061113F9675F27E2","authoredBody":"options ??= new CSharpDeclarationOptions();\n\n var references = CollectMemberTypeReferences(member);\n\n var plan = TypeNamePlan.Create(references, options);\n\n var declaration = RenderMemberDeclarationCore(type, member, options, methodParameters);\n\n declaration = plan.Apply(declaration);\n\n\n if (options.TerminateMemberDeclaration \u0026\u0026 NeedsTerminator(declaration))\n declaration \u002B= \u0022;\u0022;\n\n\n var source = ComposeUnit([declaration], plan.GeneratedUsings, options);\n\n return new CSharpRenderedDeclaration(source, plan.GeneratedUsings, plan.Diagnostics);"} -{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663610,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} +{"assembly":"ILInspector.CSharp","assemblyVersion":"1.0.0.0","tfm":"release","type":"ILInspector.CSharp.CSharpFormatter","method":"FormatMember","overload":0,"signature":"\u00600(ApiType,ApiMember,IReadOnlyList\u003Cstring\u003E)","metadataToken":100663613,"parameterCount":3,"ilSize":37,"sourceUrl":"https://raw.githubusercontent.com/richlander/dotnet-inspect/3818808646ce072da59faee897f83f1966061a44/src/ILInspector.CSharp/CSharpFormatter.cs","checksumAlgorithm":"SHA256","checksum":"1766A183E738F46A36DB8DF4A4502B23B9AEADCB3734638A5779CB8878829E84","authoredBody":"ArgumentNullException.ThrowIfNull(type);\n\n ArgumentNullException.ThrowIfNull(member);\n\n return CSharpDeclarationWriter.RenderMemberDeclaration(\n type,\n member,\n _declarationOptions,\n methodParameters);"} From 38a09b7eb42b451d907ef82264fd3e787ac93325 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Sun, 9 Aug 2026 07:12:49 -0700 Subject: [PATCH 13/18] Keep declared-name imports identity-safe Only exempt a declared simple name when it identifies one exact top-level declaration, preserving qualification across sibling namespaces, generic arity, and nested paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 74 +++++++++++++++++++ .../CSharpDeclarationWriter.cs | 40 +++++++--- src/ILInspector.CSharp/CSharpTypePrinter.cs | 19 +++-- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 797087b422..6ddf1c902e 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2084,6 +2084,80 @@ public void ShortWithUsingsImportsOtherDeclaringNamespace( Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); } + [Fact] + public void DeclaredSimpleNameCollisionKeepsReferencedDeclarationQualified() + { + var user = CreateEmptyType("App", "User"); + user.Members.Add(new ApiMember + { + Name = "Value", + Kind = "field", + ReturnType = "N.Sub.Marker" + }); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest(user), + new CSharpTypePrintRequest(CreateEmptyType("App", "Marker")), + new CSharpTypePrintRequest(CreateEmptyType("N.Sub", "Marker")) + ]); + + Assert.DoesNotContain("using N.Sub;", result.Source, StringComparison.Ordinal); + Assert.Contains("public N.Sub.Marker Value;", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void GenericDeclaredSimpleNameCollisionKeepsReferenceQualified() + { + var user = CreateEmptyType("App", "User"); + var getMarker = CreateMethod("GetMarker"); + getMarker.SignatureModel!.ReturnType = "N.Sub.Marker"; + user.Members.Add(getMarker); + var genericMarker = CreateEmptyType("N.Sub", "Marker`1"); + genericMarker.TypeParameters = [new TypeParameter { Name = "T" }]; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest(user), + new CSharpTypePrintRequest(CreateEmptyType("App", "Marker")), + new CSharpTypePrintRequest(genericMarker) + ]); + + Assert.DoesNotContain("using N.Sub;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public N.Sub.Marker GetMarker();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void NestedDeclarationDoesNotAuthorizeContainingTypeUsing() + { + var user = CreateEmptyType("App", "User"); + user.Members.Add(new ApiMember + { + Name = "Value", + Kind = "field", + ReturnType = "N.Container.Marker" + }); + var container = CreateEmptyType("N", "Container"); + var marker = CreateEmptyType("N", "Marker"); + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest(user), + new CSharpTypePrintRequest( + container, + nestedTypes: [new CSharpTypePrintRequest(marker)]) + ]); + + Assert.DoesNotContain("using N.Container;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public N.Container.Marker Value;", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void QualifiedPolicyPlansTypeAndMemberAttributes() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index fade7cb486..f82b50212c 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -364,7 +364,11 @@ public static IReadOnlyList DeriveContextualUsings(IReadOnlyCollection ( Type: type, Members: (IEnumerable)type.Members, - AdditionalParameters: Enumerable.Empty()))) + AdditionalParameters: Enumerable.Empty(), + DeclaredTypeFullName: string.IsNullOrWhiteSpace(type.Namespace) + ? CSharpFormatter.StripArity(type.Name) + : $"{type.Namespace}.{CSharpFormatter.StripArity(type.Name)}", + CanImportDeclaringNamespace: true))) .SafeUsings; } @@ -375,7 +379,9 @@ internal static ( IEnumerable<( ApiType Type, IEnumerable Members, - IEnumerable AdditionalParameters)> scopes, + IEnumerable AdditionalParameters, + string DeclaredTypeFullName, + bool CanImportDeclaringNamespace)> scopes, IEnumerable? contextualNamespaces = null, IEnumerable? additionalAttributes = null) { @@ -383,7 +389,9 @@ internal static ( .Select(scope => ( scope.Type, Members: scope.Members.ToList(), - AdditionalParameters: scope.AdditionalParameters.ToList())) + AdditionalParameters: scope.AdditionalParameters.ToList(), + scope.DeclaredTypeFullName, + scope.CanImportDeclaringNamespace)) .ToList(); var typeRefs = scopeList .SelectMany(scope => CollectTypeReferences(scope.Type) @@ -433,8 +441,20 @@ internal static ( .Distinct(StringComparer.Ordinal) .ToList(); var declaredTypeNames = new HashSet(StringComparer.Ordinal); - var declaredTypeFullNames = scopeList - .Select(scope => scope.Type.FullName) + var uniquelyImportableDeclaredTypeFullNames = scopeList + .GroupBy( + scope => CSharpFormatter.StripArity(scope.Type.Name), + StringComparer.Ordinal) + .Where(group => + { + var identities = group + .Select(scope => scope.DeclaredTypeFullName) + .Distinct(StringComparer.Ordinal) + .ToList(); + return identities.Count == 1 + && group.All(scope => scope.CanImportDeclaringNamespace); + }) + .Select(group => group.First().DeclaredTypeFullName) .ToHashSet(StringComparer.Ordinal); var shadowingNames = new HashSet(StringComparer.Ordinal); var rootShadowingNames = new HashSet(StringComparer.Ordinal); @@ -510,7 +530,7 @@ internal static ( collidingSimpleNames, rootShadowingNames, declaredTypeNames, - declaredTypeFullNames, + uniquelyImportableDeclaredTypeFullNames, typeRefs.Concat(attributeTypeRefs).ToList()); unsafeNamespaces.UnionWith(unsafePrimaryAttributeNamespaces); @@ -521,7 +541,7 @@ internal static ( if (shadowingNames.Contains(group.Key)) continue; if (declaredTypeNames.Contains(group.Key) - && !declaredTypeFullNames.Contains(group.First().FullName)) + && !uniquelyImportableDeclaredTypeFullNames.Contains(group.First().FullName)) continue; var ns = group.First().Namespace; if (unsafeNamespaces.Contains(ns)) @@ -794,16 +814,16 @@ static HashSet UnsafeNamespaces( IReadOnlySet collidingSimpleNames, IReadOnlySet rootShadowingNames, IReadOnlySet? declaredTypeNames = null, - IReadOnlySet? declaredTypeFullNames = null, + IReadOnlySet? uniquelyImportableDeclaredTypeFullNames = null, IReadOnlyList? bindingEvidence = null) { declaredTypeNames ??= new HashSet(StringComparer.Ordinal); - declaredTypeFullNames ??= new HashSet(StringComparer.Ordinal); + uniquelyImportableDeclaredTypeFullNames ??= new HashSet(StringComparer.Ordinal); var unsafeNamespaces = typeRefs .Where(r => collidingSimpleNames.Contains(r.SimpleName) || shadowingNames.Contains(r.SimpleName) || (declaredTypeNames.Contains(r.SimpleName) - && !declaredTypeFullNames.Contains(r.FullName)) + && !uniquelyImportableDeclaredTypeFullNames.Contains(r.FullName)) || rootShadowingNames.Contains(NamespaceRoot(r.Namespace))) .Select(r => r.Namespace) .ToHashSet(StringComparer.Ordinal); diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index ffe95db376..1ac98b8506 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -200,18 +200,27 @@ public CSharpTypePrintResult PrintBatch( var scopes = new List<( ApiType Type, IEnumerable Members, - IEnumerable AdditionalParameters)>(); - void Flatten(PreparedType prepared) + IEnumerable AdditionalParameters, + string DeclaredTypeFullName, + bool CanImportDeclaringNamespace)>(); + void Flatten(PreparedType prepared, string? parentPath) { + string name = CSharpFormatter.StripArity(prepared.Type.Name); + string path = parentPath is null ? name : $"{parentPath}.{name}"; + string fullName = prepared.Namespace.Length == 0 + ? path + : $"{prepared.Namespace}.{path}"; scopes.Add(( prepared.Type, prepared.Members.Select(member => member.Member), - prepared.PrimaryConstructorParameters)); + prepared.PrimaryConstructorParameters, + fullName, + parentPath is null)); foreach (var nested in prepared.NestedTypes) - Flatten(nested); + Flatten(nested, path); } foreach (var prepared in preparedTypes) - Flatten(prepared); + Flatten(prepared, parentPath: null); return CSharpDeclarationWriter.DeriveTypeNameContext( scopes, From f571997f53ce000443a8e541a98b0ae342f617bc Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Tue, 11 Aug 2026 15:43:14 -0700 Subject: [PATCH 14/18] Close round-twelve type binding gaps Treat unqualified type-position names as collision evidence, preserve ambiguous parenthesized attribute values, and recognize declared paths visible through ancestor namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpFormatterTests.cs | 29 +++++++ .../CSharpTypePrinterTests.cs | 79 +++++++++++++++++++ .../CSharpDeclarationWriter.cs | 53 ++++++++----- src/ILInspector.CSharp/CSharpTypePrinter.cs | 6 +- 4 files changed, 145 insertions(+), 22 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs index 0dac81494f..26faa9e891 100644 --- a/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpFormatterTests.cs @@ -266,6 +266,35 @@ public void ShortWithUsingsDerivesAlongsideCallerImports() Assert.Equal(["System.Windows.Forms"], declaration.Usings); } + [Fact] + public void UnqualifiedTypePreventsCollidingImport() + { + var type = new ApiType { Namespace = "App", Name = "Client", Kind = "class" }; + var member = new ApiMember + { + Name = "Get", + Kind = "method", + SignatureModel = new ApiSignature + { + ReturnType = "Lib.Node", + MemberName = "Get", + Parameters = [new ApiParameter { Type = "Node", Name = "ambient" }] + } + }; + var formatter = new CSharpFormatter(new CSharpFormatOptions + { + TypeNamePolicy = CSharpTypeNamePolicy.ShortWithUsings + }); + + var declaration = formatter.FormatMemberUnit(type, member); + + Assert.Contains( + "public Lib.Node Get(Node ambient)", + declaration.Text, + StringComparison.Ordinal); + Assert.Empty(declaration.Usings); + } + [Theory] [InlineData(CSharpTypeNamePolicy.ShortWithUsings)] [InlineData(CSharpTypeNamePolicy.ContextualShort)] diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index e366e0f510..4b12431777 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2364,6 +2364,30 @@ public void ShortWithUsingsImportsOtherDeclaringNamespace( Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); } + [Fact] + public void GlobalTypeReferencePreventsCollidingDeclaredNamespaceImport() + { + var node = CreateEmptyType("Lib", "Node"); + var client = CreateEmptyType("App", "Client"); + var getNode = CreateMethod("GetNode"); + getNode.SignatureModel!.ReturnType = "Lib.Node"; + getNode.SignatureModel.Parameters = + [ + new ApiParameter { Type = "Node", Name = "ambient" } + ]; + client.Members.Add(getNode); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(node), new CSharpTypePrintRequest(client)]); + + Assert.DoesNotContain("using Lib;", result.Source, StringComparison.Ordinal); + Assert.DoesNotContain("Lib", result.Usings); + Assert.Contains( + "public Lib.Node GetNode(Node ambient);", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void DeclaredSimpleNameCollisionKeepsReferencedDeclarationQualified() { @@ -2511,6 +2535,61 @@ public void QualifiedPolicyPlansTypeBearingAttributeArgumentsAndReturnAttributes StringComparison.Ordinal); } + [Fact] + public void ParenthesizedAttributeValueFollowedByBinaryOperatorIsNotACast() + { + var constants = CreateEmptyType("App", "Constants"); + var consumer = CreateEmptyType("App", "Consumer"); + consumer.Attributes = ["App.Probe((Constants.Value) + 1)"]; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest(constants), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.Contains( + "[App.Probe((Constants.Value) + 1)]", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("global::Constants.Value", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void AttributeValueCanUseTypeDeclaredInAncestorNamespace() + { + var foo = CreateEmptyType("A", "Foo"); + var options = CreateEmptyType("A", "Options"); + options.Kind = "enum"; + options.Members = + [ + new ApiMember + { + Name = "Fast", + Kind = "field", + ReturnType = "A.Foo.Options" + } + ]; + var consumer = CreateEmptyType("A.B", "Consumer"); + consumer.Attributes = ["Marker(Foo.Options.Fast)"]; + + var result = _printer.PrintBatch( + [ + new CSharpTypePrintRequest( + foo, + nestedTypes: [new CSharpTypePrintRequest(options)]), + new CSharpTypePrintRequest(consumer) + ], + new CSharpTypePrintOptions { IncludeCustomAttributes = true }); + + Assert.Contains( + "[Marker(Foo.Options.Fast)]", + result.Source, + StringComparison.Ordinal); + Assert.DoesNotContain("global::Foo.Options.Fast", result.Source, StringComparison.Ordinal); + } + [Fact] public void QualifiedPolicyPlansDelegateReferences() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index d00b76151d..486f01d6cb 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -185,7 +185,7 @@ public static CSharpRenderedDeclaration RenderTypeUnit( var shortenableReferences = CollectTypeReferences(type) .Concat(memberList.SelectMany(member => CollectMemberTypeReferences(member))) .Concat(parameters.SelectMany(parameter => - ExtractQualifiedTypeNames(parameter.Type))) + ExtractTypeNames(parameter.Type))) .ToHashSet(StringComparer.Ordinal); shortenableReferences.ExceptWith(memberList.SelectMany(CollectExplicitInterfaceTypeReferences)); var references = shortenableReferences @@ -303,7 +303,7 @@ public static string RenderTypeDeclaration( .ToHashSet(StringComparer.Ordinal); var shortenableReferences = CollectTypeReferences(type) .Concat(parameters.SelectMany(parameter => - ExtractQualifiedTypeNames(parameter.Type))) + ExtractTypeNames(parameter.Type))) .ToHashSet(StringComparer.Ordinal); var primaryParameterDeclaredAttributeReferences = parameters .SelectMany(parameter => CollectDeclaredAttributeTypeReferences(parameter.Attributes)) @@ -401,7 +401,7 @@ internal static ( member, includeParameterAttributes: scope.Type.Kind != "delegate"))) .Concat(scope.AdditionalParameters.SelectMany(parameter => - ExtractQualifiedTypeNames(parameter.Type)))) + ExtractTypeNames(parameter.Type)))) .Select(TypeRef.TryCreate) .Where(r => r is not null) .Select(r => r!) @@ -545,6 +545,8 @@ internal static ( && !uniquelyImportableDeclaredTypeFullNames.Contains(group.First().FullName)) continue; var ns = group.First().Namespace; + if (ns.Length == 0) + continue; if (unsafeNamespaces.Contains(ns)) continue; usings.Add(ns); @@ -572,10 +574,10 @@ static HashSet AttributeLookupNames(TypeRef typeRef) static IEnumerable CollectParameterTypeReferences(ApiParameter parameter) { if (!string.IsNullOrWhiteSpace(parameter.Type)) - foreach (var reference in ExtractQualifiedTypeNames(parameter.Type)) + foreach (var reference in ExtractTypeNames(parameter.Type)) yield return reference; foreach (var attribute in parameter.Attributes) - foreach (var reference in ExtractQualifiedTypeNames(StripAttributeArguments(attribute))) + foreach (var reference in ExtractTypeNames(StripAttributeArguments(attribute))) yield return reference; } @@ -591,7 +593,7 @@ static IEnumerable CollectDeclaredAttributeTypeReferences( { foreach (var attribute in AttributeTexts(attributes, member)) { - foreach (var reference in ExtractQualifiedTypeNames(StripAttributeArguments(attribute))) + foreach (var reference in ExtractTypeNames(StripAttributeArguments(attribute))) yield return reference; foreach (var reference in CollectAttributeArgumentTypeReferences([attribute])) yield return reference; @@ -633,15 +635,16 @@ static IEnumerable CollectAttributeArgumentTypeReferences( int next = close + 1; while (next < attribute.Length && char.IsWhiteSpace(attribute[next])) next++; + // A following + or - is ambiguous with a parenthesized value + // expression, so preserve it rather than inventing type evidence. if (next >= attribute.Length - || (attribute[next] is not '+' and not '-' - && !char.IsAsciiDigit(attribute[next]))) + || !char.IsAsciiDigit(attribute[next])) { continue; } } - foreach (var reference in ExtractQualifiedTypeNames(attribute[(open + 1)..close])) + foreach (var reference in ExtractTypeNames(attribute[(open + 1)..close])) yield return reference; index = close; } @@ -659,7 +662,7 @@ static IEnumerable CollectAttributeValueTypeReferences( continue; var recognizedReferences = CollectAttributeArgumentTypeReferences([attribute]) .ToHashSet(StringComparer.Ordinal); - foreach (var valueExpression in ExtractQualifiedTypeNames( + foreach (var valueExpression in ExtractTypeNames( attribute[(firstArgumentList + 1)..])) { if (recognizedReferences.Contains(valueExpression)) @@ -1094,19 +1097,19 @@ static IEnumerable CollectTypeReferences(ApiType type) { if (type.BaseType is { Length: > 0 }) { - foreach (var reference in ExtractQualifiedTypeNames(type.BaseType)) + foreach (var reference in ExtractTypeNames(type.BaseType)) yield return reference; } foreach (var iface in type.Interfaces) { - foreach (var reference in ExtractQualifiedTypeNames(iface)) + foreach (var reference in ExtractTypeNames(iface)) yield return reference; } foreach (var typeParameter in type.TypeParameters) { foreach (var constraint in typeParameter.Constraints) { - foreach (var reference in ExtractQualifiedTypeNames(constraint)) + foreach (var reference in ExtractTypeNames(constraint)) yield return reference; } } @@ -1118,7 +1121,7 @@ static IEnumerable CollectMemberTypeReferences( { foreach (var expression in MemberTypeExpressions(member, includeParameterAttributes)) { - foreach (var reference in ExtractQualifiedTypeNames(expression)) + foreach (var reference in ExtractTypeNames(expression)) yield return reference; } } @@ -1131,7 +1134,7 @@ static IEnumerable CollectExplicitInterfaceTypeReferences(ApiMember memb int memberSeparator = member.Name.LastIndexOf('.'); if (memberSeparator > 0) { - foreach (var reference in ExtractQualifiedTypeNames(member.Name[..memberSeparator])) + foreach (var reference in ExtractTypeNames(member.Name[..memberSeparator])) yield return reference; } } @@ -1158,7 +1161,7 @@ static IEnumerable MemberTypeExpressions( } foreach (var typeParameter in signatureModel.TypeParameters) foreach (var constraint in typeParameter.Constraints) - foreach (var reference in ExtractQualifiedTypeNames(constraint)) + foreach (var reference in ExtractTypeNames(constraint)) yield return reference; } @@ -1383,12 +1386,11 @@ static string StripAttributeArguments(string attribute) return paren < 0 ? attribute : attribute[..paren]; } - static IEnumerable ExtractQualifiedTypeNames(string expression) + static IEnumerable ExtractTypeNames(string expression) { foreach (var token in DottedIdentifierTokens(expression)) { - if (!token.Contains('.', StringComparison.Ordinal) - || token.StartsWith("global.", StringComparison.Ordinal) + if (token.StartsWith("global.", StringComparison.Ordinal) || token.StartsWith("global::", StringComparison.Ordinal)) { continue; @@ -2957,7 +2959,8 @@ public static TypeNamePlan Create( if (options.TypeNameMode == CSharpTypeNameMode.ShortWithUsings) potentiallyImportedNamespaces.UnionWith(shorteningTypeRefs.Select(reference => reference.Namespace)); var collisionEvidence = bindingTypeRefs - .Where(reference => potentiallyImportedNamespaces.Contains(reference.Namespace)) + .Where(reference => reference.Namespace.Length == 0 + || potentiallyImportedNamespaces.Contains(reference.Namespace)) .ToList(); var collisions = CollidingSimpleNames(collisionEvidence); var allShadowingNames = lexicalShadowingNames @@ -3033,6 +3036,8 @@ bool IsKnownNamespaceRoot(string root) foreach (var typeRef in typeRefs) { + if (typeRef.Namespace.Length == 0) + continue; if (valueOnlyFullNames.Contains(typeRef.FullName)) { KeepAttributeValueQualified(typeRef); @@ -3228,6 +3233,8 @@ static int SkipCharLiteral(string text, int start) return text.Length; } + // An empty namespace records unqualified type-position evidence. It participates + // in collision analysis but never produces a replacement or using directive. sealed record TypeRef(string FullName, string Namespace, string SimpleName) { public static TypeRef? TryCreate(string value) @@ -3236,7 +3243,11 @@ sealed record TypeRef(string FullName, string Namespace, string SimpleName) if (value.Length == 0) return null; var lastDot = value.LastIndexOf('.'); - if (lastDot <= 0 || lastDot == value.Length - 1) + if (lastDot == value.Length - 1) + return null; + if (lastDot < 0) + return new TypeRef(value, "", StripArity(value)); + if (lastDot == 0) return null; var ns = value[..lastDot]; var simple = StripArity(value[(lastDot + 1)..]); diff --git a/src/ILInspector.CSharp/CSharpTypePrinter.cs b/src/ILInspector.CSharp/CSharpTypePrinter.cs index 0dbe6209d1..dcb34f21f5 100644 --- a/src/ILInspector.CSharp/CSharpTypePrinter.cs +++ b/src/ILInspector.CSharp/CSharpTypePrinter.cs @@ -139,7 +139,11 @@ public CSharpTypePrintResult PrintBatch( foreach (var group in preparedTypes.GroupBy(type => type.Namespace, StringComparer.Ordinal)) { var groupedTypes = group.ToList(); - var declaredTypeFullNameSet = declaredTypeFullNamesByNamespace[group.Key]; + var declaredTypeFullNameSet = declaredTypeFullNamesByNamespace + .Where(entry => string.Equals(entry.Key, group.Key, StringComparison.Ordinal) + || IsAncestorNamespace(entry.Key, group.Key)) + .SelectMany(entry => entry.Value) + .ToImmutableHashSet(StringComparer.Ordinal); var containingNamespace = group.Key.Length == 0 ? null : group.Key; bool useBlockScopedNamespace = containingNamespace is not null && !useFileScopedNamespace; var ancestorTypeNames = preparedTypes From 6d9b0f570532f61c0f6aebaa92453c415e936ab3 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Tue, 11 Aug 2026 17:50:56 -0700 Subject: [PATCH 15/18] Keep declared references safe with existing usings Avoid shortening a type declared in another namespace when any second namespace is imported, since namespace-only using inputs cannot prove that the simple name remains unambiguous. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 45 +++++++++++++++++++ .../CSharpDeclarationWriter.cs | 24 ++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 4b12431777..8da756f7c1 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -2364,6 +2364,51 @@ public void ShortWithUsingsImportsOtherDeclaringNamespace( Assert.Contains("public Thing GetThing();", result.Source, StringComparison.Ordinal); } + [Fact] + public void ConfiguredUsingKeepsOtherDeclaredNamespaceReferenceQualified() + { + var exception = CreateEmptyType("Lib", "Exception"); + var consumer = CreateEmptyType("App", "Consumer"); + var getException = CreateMethod("GetException"); + getException.SignatureModel!.ReturnType = "Lib.Exception"; + consumer.Members.Add(getException); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(exception), new CSharpTypePrintRequest(consumer)], + new CSharpTypePrintOptions { Usings = ["System"] }); + + Assert.Equal(["System"], result.Usings); + Assert.DoesNotContain("using Lib;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public Lib.Exception GetException();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DerivedUsingKeepsOtherDeclaredNamespaceReferenceQualified() + { + var marker = CreateEmptyType("Lib", "Marker"); + var consumer = CreateEmptyType("App", "Consumer"); + var getMarker = CreateMethod("GetMarker"); + getMarker.SignatureModel!.ReturnType = "Lib.Marker"; + getMarker.SignatureModel.Parameters = + [ + new ApiParameter { Type = "Other.Value", Name = "value" } + ]; + consumer.Members.Add(getMarker); + + var result = _printer.PrintBatch( + [new CSharpTypePrintRequest(marker), new CSharpTypePrintRequest(consumer)]); + + Assert.Equal(["Other"], result.Usings); + Assert.DoesNotContain("using Lib;", result.Source, StringComparison.Ordinal); + Assert.Contains( + "public Lib.Marker GetMarker(Value value);", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void GlobalTypeReferencePreventsCollidingDeclaredNamespaceImport() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 486f01d6cb..b1a9cb9eb6 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -386,6 +386,7 @@ internal static ( IEnumerable? contextualNamespaces = null, IEnumerable? additionalAttributes = null) { + var contextualNamespaceList = (contextualNamespaces ?? []).ToList(); var scopeList = scopes .Select(scope => ( scope.Type, @@ -435,7 +436,7 @@ internal static ( var knownNamespaces = typeRefs .Select(typeRef => typeRef.Namespace) .Concat(attributeTypeRefs.Select(typeRef => typeRef.Namespace)) - .Concat(contextualNamespaces ?? []) + .Concat(contextualNamespaceList) .Concat(scopeList.Select(scope => scope.Type.Namespace)) .Where(ns => !string.IsNullOrWhiteSpace(ns)) .Select(ns => ns!) @@ -480,7 +481,7 @@ internal static ( var usings = new SortedSet(StringComparer.Ordinal); var potentiallyImportedNamespaces = typeRefs .Select(typeRef => typeRef.Namespace) - .Concat(contextualNamespaces ?? []) + .Concat(contextualNamespaceList) .Concat(scopeList .Select(scope => scope.Type.Namespace) .Where(ns => !string.IsNullOrWhiteSpace(ns)) @@ -535,13 +536,15 @@ internal static ( typeRefs.Concat(attributeTypeRefs).ToList()); unsafeNamespaces.UnionWith(unsafePrimaryAttributeNamespaces); + var importedDeclaredNamespaces = new HashSet(StringComparer.Ordinal); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) { if (collidingSimpleNames.Contains(group.Key)) continue; if (shadowingNames.Contains(group.Key)) continue; - if (declaredTypeNames.Contains(group.Key) + bool importsDeclaredType = declaredTypeNames.Contains(group.Key); + if (importsDeclaredType && !uniquelyImportableDeclaredTypeFullNames.Contains(group.First().FullName)) continue; var ns = group.First().Namespace; @@ -550,6 +553,21 @@ internal static ( if (unsafeNamespaces.Contains(ns)) continue; usings.Add(ns); + if (importsDeclaredType) + importedDeclaredNamespaces.Add(ns); + } + + var effectiveImportedNamespaces = usings + .Concat(contextualNamespaceList) + .Where(ns => !string.IsNullOrWhiteSpace(ns)) + .ToHashSet(StringComparer.Ordinal); + foreach (var declaredNamespace in importedDeclaredNamespaces) + { + if (effectiveImportedNamespaces.Any(ns => + !string.Equals(ns, declaredNamespace, StringComparison.Ordinal))) + { + usings.Remove(declaredNamespace); + } } var referencedTypeNames = typeRefs From da2541eb34e4219cd716128388f56fd82778a2fd Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Tue, 11 Aug 2026 18:17:51 -0700 Subject: [PATCH 16/18] Record validated main integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc From c91f6a3a7078f0829f3038901b657b279d554a2e Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Tue, 11 Aug 2026 19:29:25 -0700 Subject: [PATCH 17/18] Keep new signature surfaces safe across imports Preserve qualification for delegate and primary-constructor references when a second configured or derived namespace can make their newly shortened names ambiguous. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 64 +++++++++++++++++++ .../CSharpDeclarationWriter.cs | 27 ++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 8da756f7c1..6c8fb0767a 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -985,6 +985,27 @@ public void DerivationIncludesPrimaryConstructorParameters() Assert.Contains("public class Worker(TextWriter writer)", result.Source, StringComparison.Ordinal); } + [Fact] + public void PrimaryConstructorTypeStaysQualifiedWithDistinctConfiguredUsing() + { + var type = CreateEmptyType("Samples", "Worker"); + + var result = _printer.Print( + new CSharpTypePrintRequest( + type, + primaryConstructorParameters: + [ + new ApiParameter { Type = "Lib.Exception", Name = "exception" } + ]), + new CSharpTypePrintOptions { Usings = ["System"] }); + + Assert.Equal(["System"], result.Usings); + Assert.Contains( + "public class Worker(Lib.Exception exception)", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void FullMemberUsesBareTypesBackedByNamespaceSet() { @@ -3771,6 +3792,49 @@ public void DelegateSignatureTypesRespectShorteningPolicy() StringComparison.Ordinal); } + [Fact] + public void DelegateSignatureTypeStaysQualifiedWithDistinctConfiguredUsing() + { + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Lib.Exception"; + var delegateType = CreateEmptyType("Samples", "Callback"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.Print( + new CSharpTypePrintRequest(delegateType), + new CSharpTypePrintOptions { Usings = ["System"] }); + + Assert.Equal(["System"], result.Usings); + Assert.Contains( + "public delegate Lib.Exception Callback();", + result.Source, + StringComparison.Ordinal); + } + + [Fact] + public void DelegateSignatureTypesStayQualifiedAcrossDistinctDerivedUsings() + { + var invoke = CreateMethod("Invoke"); + invoke.SignatureModel!.ReturnType = "Alpha.Result"; + invoke.SignatureModel.Parameters.Add(new ApiParameter + { + Type = "Beta.Input", + Name = "value" + }); + var delegateType = CreateEmptyType("Samples", "Handler"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + + var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); + + Assert.Empty(result.Usings); + Assert.Contains( + "public delegate Alpha.Result Handler(Beta.Input value);", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void DelegateParameterAttributesRemainQualified() { diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index b1a9cb9eb6..889666b21d 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -432,6 +432,18 @@ internal static ( .Select(r => r!) .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); + var exclusiveImportTypeFullNames = scopeList + .SelectMany(scope => + (scope.Type.Kind == "delegate" + ? scope.Members.SelectMany(member => + CollectMemberTypeReferences(member, includeParameterAttributes: false)) + : []) + .Concat(scope.AdditionalParameters.SelectMany(parameter => + ExtractTypeNames(parameter.Type)))) + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal); var knownNamespaces = typeRefs .Select(typeRef => typeRef.Namespace) @@ -536,7 +548,7 @@ internal static ( typeRefs.Concat(attributeTypeRefs).ToList()); unsafeNamespaces.UnionWith(unsafePrimaryAttributeNamespaces); - var importedDeclaredNamespaces = new HashSet(StringComparer.Ordinal); + var exclusiveImportNamespaces = new HashSet(StringComparer.Ordinal); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) { if (collidingSimpleNames.Contains(group.Key)) @@ -553,20 +565,23 @@ internal static ( if (unsafeNamespaces.Contains(ns)) continue; usings.Add(ns); - if (importsDeclaredType) - importedDeclaredNamespaces.Add(ns); + if (importsDeclaredType + || exclusiveImportTypeFullNames.Contains(group.First().FullName)) + { + exclusiveImportNamespaces.Add(ns); + } } var effectiveImportedNamespaces = usings .Concat(contextualNamespaceList) .Where(ns => !string.IsNullOrWhiteSpace(ns)) .ToHashSet(StringComparer.Ordinal); - foreach (var declaredNamespace in importedDeclaredNamespaces) + foreach (var exclusiveNamespace in exclusiveImportNamespaces) { if (effectiveImportedNamespaces.Any(ns => - !string.Equals(ns, declaredNamespace, StringComparison.Ordinal))) + !string.Equals(ns, exclusiveNamespace, StringComparison.Ordinal))) { - usings.Remove(declaredNamespace); + usings.Remove(exclusiveNamespace); } } From fc9e8f8ae61feb8110937254fc11f996ced87244 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Tue, 11 Aug 2026 20:54:22 -0700 Subject: [PATCH 18/18] Require namespace evidence for new signature surfaces Keep delegate constraints and primary-constructor types qualified unless configured, declared, or existing unit evidence establishes their dotted prefix as a namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3787daf2-0222-4f9a-ab24-90df5356dccc --- .../CSharpTypePrinterTests.cs | 72 ++++++++++++++++--- .../CSharpDeclarationWriter.cs | 43 ++++++++++- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs index 6c8fb0767a..bfc4641441 100644 --- a/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs +++ b/src/ILInspector.CSharp.Tests/CSharpTypePrinterTests.cs @@ -970,7 +970,25 @@ public void DerivationUsesOnlySelectedMembers() } [Fact] - public void DerivationIncludesPrimaryConstructorParameters() + public void ConfiguredNamespaceShortensPrimaryConstructorParameter() + { + var type = CreateEmptyType("Samples", "Worker"); + + var result = _printer.Print( + new CSharpTypePrintRequest( + type, + primaryConstructorParameters: + [ + new ApiParameter { Type = "System.IO.TextWriter", Name = "writer" } + ]), + new CSharpTypePrintOptions { Usings = ["System.IO"] }); + + Assert.Equal(["System.IO"], result.Usings); + Assert.Contains("public class Worker(TextWriter writer)", result.Source, StringComparison.Ordinal); + } + + [Fact] + public void PrimaryConstructorNestedTypeDoesNotInventNamespace() { var type = CreateEmptyType("Samples", "Worker"); @@ -978,11 +996,18 @@ public void DerivationIncludesPrimaryConstructorParameters() type, primaryConstructorParameters: [ - new ApiParameter { Type = "System.IO.TextWriter", Name = "writer" } + new ApiParameter + { + Type = "System.Environment.SpecialFolder", + Name = "folder" + } ])); - Assert.Equal(["System.IO"], result.Usings); - Assert.Contains("public class Worker(TextWriter writer)", result.Source, StringComparison.Ordinal); + Assert.Empty(result.Usings); + Assert.Contains( + "public class Worker(System.Environment.SpecialFolder folder)", + result.Source, + StringComparison.Ordinal); } [Fact] @@ -3740,7 +3765,7 @@ public void EnumAndDelegateRequestsUseTheirLanguageDeclarations() Assert.Contains(" public enum Choice\n {\n One = 1\n }", result.Units[0].Source, StringComparison.Ordinal); Assert.Contains( - " internal delegate int Converter(string value) where @event : IEquatable<@event>;", + " internal delegate int Converter(string value) where @event : System.IEquatable<@event>;", result.Units[0].Source, StringComparison.Ordinal); } @@ -3770,7 +3795,7 @@ public void DelegateReturnAttributesAreRenderedAndPlanned() } [Fact] - public void DelegateSignatureTypesRespectShorteningPolicy() + public void ConfiguredNamespaceShortensDelegateSignatureTypes() { var invoke = CreateMethod("Invoke"); invoke.SignatureModel!.ReturnType = "External.Result"; @@ -3783,7 +3808,9 @@ public void DelegateSignatureTypesRespectShorteningPolicy() delegateType.Kind = "delegate"; delegateType.Members.Add(invoke); - var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); + var result = _printer.Print( + new CSharpTypePrintRequest(delegateType), + new CSharpTypePrintOptions { Usings = ["External"] }); Assert.Contains("using External;", result.Source, StringComparison.Ordinal); Assert.Contains( @@ -3792,6 +3819,33 @@ public void DelegateSignatureTypesRespectShorteningPolicy() StringComparison.Ordinal); } + [Fact] + public void DelegateConstraintStaysQualifiedWithDistinctConfiguredUsing() + { + var invoke = CreateMethod("Invoke"); + var delegateType = CreateEmptyType("Samples", "Handler`1"); + delegateType.Kind = "delegate"; + delegateType.Members.Add(invoke); + delegateType.TypeParameters = + [ + new TypeParameter + { + Name = "T", + Constraints = ["Lib.Exception"] + } + ]; + + var result = _printer.Print( + new CSharpTypePrintRequest(delegateType), + new CSharpTypePrintOptions { Usings = ["System"] }); + + Assert.Equal(["System"], result.Usings); + Assert.Contains( + "public delegate void Handler() where T : Lib.Exception;", + result.Source, + StringComparison.Ordinal); + } + [Fact] public void DelegateSignatureTypeStaysQualifiedWithDistinctConfiguredUsing() { @@ -3852,9 +3906,9 @@ public void DelegateParameterAttributesRemainQualified() var result = _printer.Print(new CSharpTypePrintRequest(delegateType)); - Assert.Contains("using Contracts;", result.Source, StringComparison.Ordinal); + Assert.Empty(result.Usings); Assert.Contains( - "public delegate Marker Handler([Attributes.Marker] string value);", + "public delegate Contracts.Marker Handler([Attributes.Marker] string value);", result.Source, StringComparison.Ordinal); } diff --git a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs index 889666b21d..37c49c08fb 100644 --- a/src/ILInspector.CSharp/CSharpDeclarationWriter.cs +++ b/src/ILInspector.CSharp/CSharpDeclarationWriter.cs @@ -432,11 +432,30 @@ internal static ( .Select(r => r!) .DistinctBy(r => r.FullName, StringComparer.Ordinal) .ToList(); + // Dotted signature text does not distinguish namespaces from enclosing types. + // Newly planned delegate and primary-constructor surfaces need independent + // namespace evidence before they can introduce a using. + var existingSurfaceTypeFullNames = scopeList + .SelectMany(scope => + scope.Type.Kind == "delegate" + ? [] + : CollectTypeReferences(scope.Type) + .Concat(scope.Members.SelectMany(member => + CollectMemberTypeReferences( + member, + includeParameterAttributes: true)))) + .Select(TypeRef.TryCreate) + .Where(reference => reference is not null) + .Select(reference => reference!.FullName) + .ToHashSet(StringComparer.Ordinal); var exclusiveImportTypeFullNames = scopeList .SelectMany(scope => (scope.Type.Kind == "delegate" - ? scope.Members.SelectMany(member => - CollectMemberTypeReferences(member, includeParameterAttributes: false)) + ? CollectTypeReferences(scope.Type) + .Concat(scope.Members.SelectMany(member => + CollectMemberTypeReferences( + member, + includeParameterAttributes: false))) : []) .Concat(scope.AdditionalParameters.SelectMany(parameter => ExtractTypeNames(parameter.Type)))) @@ -444,6 +463,7 @@ internal static ( .Where(reference => reference is not null) .Select(reference => reference!.FullName) .ToHashSet(StringComparer.Ordinal); + exclusiveImportTypeFullNames.ExceptWith(existingSurfaceTypeFullNames); var knownNamespaces = typeRefs .Select(typeRef => typeRef.Namespace) @@ -547,6 +567,16 @@ internal static ( uniquelyImportableDeclaredTypeFullNames, typeRefs.Concat(attributeTypeRefs).ToList()); unsafeNamespaces.UnionWith(unsafePrimaryAttributeNamespaces); + var establishedNamespaces = contextualNamespaceList + .Concat(scopeList + .Select(scope => scope.Type.Namespace) + .Where(ns => !string.IsNullOrWhiteSpace(ns)) + .Select(ns => ns!)) + .Concat(typeRefs + .Where(typeRef => + !exclusiveImportTypeFullNames.Contains(typeRef.FullName)) + .Select(typeRef => typeRef.Namespace)) + .ToHashSet(StringComparer.Ordinal); var exclusiveImportNamespaces = new HashSet(StringComparer.Ordinal); foreach (var group in typeRefs.GroupBy(r => r.SimpleName, StringComparer.Ordinal)) @@ -564,9 +594,16 @@ internal static ( continue; if (unsafeNamespaces.Contains(ns)) continue; + bool requiresEstablishedNamespace = + exclusiveImportTypeFullNames.Contains(group.First().FullName); + if (requiresEstablishedNamespace + && !establishedNamespaces.Contains(ns)) + { + continue; + } usings.Add(ns); if (importsDeclaredType - || exclusiveImportTypeFullNames.Contains(group.First().FullName)) + || requiresEstablishedNamespace) { exclusiveImportNamespaces.Add(ns); }