From 53498c5691acef497cb90bb8cc46bcbb3d3a4534 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 22 Jun 2026 18:49:26 +0200 Subject: [PATCH] Fix #3797: resolve ilspycmd -t type names with fuzzy matching --list-* printed generic types without their `n arity suffix, yet -t only accepted the exact reflection name, so a name copied straight from the listing threw "Could not find type definition". The listing now prints the reflection name, and -t resolves through a ladder of progressively looser, uniqueness- checked rules: the engine's exact lookup, an arity- and nesting-separator- insensitive FullName match, a case-insensitive match, a namespace-less simple name, and a trailing segment path ("Dictionary.KeyCollection"). The input is parsed with System.Reflection.Metadata.TypeName and reduced to its underlying definition first, so assembly qualification, generic arguments, and array/pointer/byref decorations cannot corrupt the comparison key. An ambiguous name reports its candidates instead of guessing, and a miss prints name suggestions and returns EX_DATAERR rather than dumping a stack trace. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs | 225 +++++++++++++++++++++++- 1 file changed, 221 insertions(+), 4 deletions(-) diff --git a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index fe770ef8d..9b90979d5 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -458,7 +458,7 @@ Examples: { if (!kinds.Contains(type.Kind)) continue; - output.WriteLine($"{type.Kind} {type.FullName}"); + output.WriteLine($"{type.Kind} {type.FullTypeName.ReflectionName}"); } return 0; } @@ -588,15 +588,232 @@ Examples: if (typeName == null) { output.Write(decompiler.DecompileWholeModuleAsString()); + return 0; } - else + + if (!TryResolveType(decompiler.TypeSystem, typeName, out ITypeDefinition typeDefinition, out string error)) { - var name = new FullTypeName(typeName); - output.Write(decompiler.DecompileTypeAsString(name)); + Console.Error.WriteLine(error); + return ProgramExitCodes.EX_DATAERR; } + + output.Write(decompiler.DecompileTypeAsString(typeDefinition.FullTypeName)); return 0; } + /// + /// Resolves a type name supplied on the command line to a single type definition. + /// + /// Matching is a ladder of progressively looser rules, each requiring a unique hit: + /// the engine's exact reflection-name lookup ("Ns.List`1"); then, against the input + /// reduced to FullName shape (parsed via the reflection grammar, then arity- and + /// nesting-separator-stripped), an exact FullName match ("Ns.List", "Ns.A`1+B`2"), + /// a case-insensitive FullName match, a namespace-less simple-name match ("List"), + /// and finally a trailing-segment-path match ("Dictionary.KeyCollection"). + /// + /// The first rule that matches exactly one type wins; a rule matching more than one + /// stops the ladder and reports the ambiguity (the candidates) rather than guessing; + /// no match at all yields a not-found message with name suggestions. + /// + static bool TryResolveType(IDecompilerTypeSystem typeSystem, string typeName, out ITypeDefinition typeDefinition, out string error) + { + typeDefinition = null; + error = null; + + // Exact match on the reflection name. This is the canonical form printed by + // --list-* (e.g. "Ns.List`1") and the only form that resolves generic types directly. + var exact = typeSystem.FindType(new FullTypeName(typeName)).GetDefinition(); + if (exact != null) + { + typeDefinition = exact; + return true; + } + + var allTypes = typeSystem.MainModule.TypeDefinitions.ToList(); + + // Reduce whatever spelling the user gave to the shape of ITypeDefinition.FullName + // ('.'-separated, no `n arity). The grammar parser drops assembly qualification, + // generic arguments and array/pointer/byref decorations so they cannot leak into the + // comparison key; if the input is not a well-formed type name we match against it as-is. + string normalized = TryNormalizeTypeName(typeName, out string parsed) ? parsed : typeName; + + // Arity- and separator-insensitive: "Ns.CachedPsiValue" finds "Ns.CachedPsiValue`1", + // and any spelling of a nested generic ("Ns.A.B", "Ns.A`1+B`2") finds it. + if (TrySingleMatch(allTypes, t => t.FullName == normalized, typeName, out typeDefinition, out error)) + return true; + if (error != null) + return false; + + // Case-insensitive variant of the same. + if (TrySingleMatch(allTypes, t => string.Equals(t.FullName, normalized, StringComparison.OrdinalIgnoreCase), + typeName, out typeDefinition, out error)) + return true; + if (error != null) + return false; + + // Simple name only, with the namespace omitted (e.g. "CachedPsiValue"). + if (TrySingleMatch(allTypes, t => string.Equals(t.Name, normalized, StringComparison.OrdinalIgnoreCase), + typeName, out typeDefinition, out error)) + return true; + if (error != null) + return false; + + // Trailing segment path: "Dictionary.KeyCollection" finds + // "System.Collections.Generic.Dictionary`2+KeyCollection". Matching whole '.'-separated + // segments keeps "ReadOnlyDictionary.KeyCollection" from being treated as a match. + string[] suffixSegments = normalized.Split('.'); + if (TrySingleMatch(allTypes, t => IsTrailingSegmentPath(t.FullName, suffixSegments), + typeName, out typeDefinition, out error)) + return true; + if (error != null) + return false; + + error = FormatNotFound(typeName, allTypes); + return false; + } + + /// + /// Returns true if equals the trailing run of + /// '.'-separated segments of . The comparison is on whole + /// segments, so "Dictionary.KeyCollection" matches "...Generic.Dictionary.KeyCollection" + /// but not "...ObjectModel.ReadOnlyDictionary.KeyCollection". + /// + static bool IsTrailingSegmentPath(string fullName, string[] suffixSegments) + { + string[] segments = fullName.Split('.'); + if (suffixSegments.Length > segments.Length) + return false; + int offset = segments.Length - suffixSegments.Length; + for (int i = 0; i < suffixSegments.Length; i++) + { + if (!string.Equals(segments[offset + i], suffixSegments[i], StringComparison.Ordinal)) + return false; + } + return true; + } + + /// + /// Parses a type name in any reflection-grammar spelling and reduces it to the shape of + /// . Assembly qualification, generic arguments and + /// array/pointer/byref decorations are dropped by the parser; the remaining arity + /// suffixes (`n) are then removed and the nested-type separator + is + /// replaced with .. Returns false when the input is not a well-formed type name. + /// + static bool TryNormalizeTypeName(string typeName, out string normalized) + { + normalized = null; + // Fully qualified: the ILSpyCmdProgram.TypeName option property shadows the type name. + if (!System.Reflection.Metadata.TypeName.TryParse(typeName, out System.Reflection.Metadata.TypeName parsed)) + return false; + + // Peel decorations down to the underlying type definition: arrays, pointers and + // byrefs expose an element type, and a constructed generic exposes its open definition. + while (parsed.IsArray || parsed.IsPointer || parsed.IsByRef) + parsed = parsed.GetElementType(); + if (parsed.IsConstructedGenericType) + parsed = parsed.GetGenericTypeDefinition(); + + // parsed.FullName is now free of assembly and generic-argument noise; only the arity + // suffixes and '+' separators still differ from ITypeDefinition.FullName. + var sb = new System.Text.StringBuilder(parsed.FullName.Length); + string reflectionName = parsed.FullName; + int i = 0; + while (i < reflectionName.Length) + { + char c = reflectionName[i]; + if (c == '`') + { + i++; + while (i < reflectionName.Length && char.IsDigit(reflectionName[i])) + i++; + } + else + { + sb.Append(c == '+' ? '.' : c); + i++; + } + } + normalized = sb.ToString(); + return true; + } + + /// + /// Selects the single type definition matching . Sets + /// when the predicate matches more than one type so the caller + /// can report the ambiguity instead of silently picking one. + /// + static bool TrySingleMatch(IReadOnlyList allTypes, Func predicate, string typeName, out ITypeDefinition typeDefinition, out string error) + { + typeDefinition = null; + error = null; + var matches = allTypes.Where(predicate).ToList(); + if (matches.Count == 1) + { + typeDefinition = matches[0]; + return true; + } + if (matches.Count > 1) + { + error = $"The type name '{typeName}' is ambiguous between:{Environment.NewLine}" + + string.Join(Environment.NewLine, matches.Select(t => " " + t.FullTypeName.ReflectionName)) + + $"{Environment.NewLine}Specify the full reflection name (including the `n generic-arity suffix)."; + } + return false; + } + + static string FormatNotFound(string typeName, IReadOnlyList allTypes) + { + var message = $"Could not find a type named '{typeName}'."; + + // Suggest types whose name contains the requested simple name (case-insensitive). + int lastDot = typeName.LastIndexOf('.'); + string simpleName = lastDot >= 0 ? typeName.Substring(lastDot + 1) : typeName; + int backtick = simpleName.IndexOf('`'); + if (backtick >= 0) + simpleName = simpleName.Substring(0, backtick); + + // Prefer types whose simple name contains the requested name; if a typo means + // nothing contains it, fall back to a subsequence match (the requested letters + // appearing in order), which still catches names with a dropped character. + var suggestions = SelectSuggestions(allTypes, + t => t.Name.IndexOf(simpleName, StringComparison.OrdinalIgnoreCase) >= 0); + if (suggestions.Count == 0) + suggestions = SelectSuggestions(allTypes, t => IsSubsequence(simpleName, t.Name)); + + if (suggestions.Count > 0) + { + message += $"{Environment.NewLine}Did you mean one of the following?{Environment.NewLine}" + + string.Join(Environment.NewLine, suggestions.Select(n => " " + n)); + } + return message; + } + + static List SelectSuggestions(IReadOnlyList allTypes, Func predicate) + { + return allTypes + .Where(predicate) + .Select(t => t.FullTypeName.ReflectionName) + .Distinct() + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(); + } + + /// + /// Returns true if every character of appears in + /// in order (case-insensitively), allowing gaps. + /// + static bool IsSubsequence(string value, string text) + { + int i = 0; + foreach (char c in text) + { + if (i < value.Length && char.ToUpperInvariant(c) == char.ToUpperInvariant(value[i])) + i++; + } + return i == value.Length; + } + int GeneratePdbForAssembly(string assemblyFileName, string pdbFileName, CommandLineApplication app) { var module = new PEFile(assemblyFileName,