From 3fdfbeba41073317711182977ce4567aae321058 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:16:55 +0200 Subject: [PATCH 1/8] Parse the digit suffix inline in AssignVariableNames.SplitName The loop preceding the parse has already proven that the tail consists solely of ASCII digits, so the Substring+int.TryParse pair only re-validated them at the cost of a throwaway string allocation. SplitName runs per variable and per reserved-name registration for every decompiled method, making this one of the hottest Substring call sites in the decompiler. Accumulating the digits inline keeps the TryParse overflow semantics (fall back to number=1 and the unchanged name) without allocating. Assisted-by: Claude:claude-fable-5:Claude Code --- .../IL/Transforms/AssignVariableNames.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index 7aba498bce..ad2df209c3 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs @@ -853,8 +853,18 @@ static string SplitName(string name, out int number) pos--; if (pos < name.Length) { - if (int.TryParse(name.Substring(pos), out number)) + // The loop above guarantees name[pos..] is all ASCII digits; + // accumulate the value inline, giving up on int overflow. + long value = 0; + for (int i = pos; i < name.Length; i++) { + value = value * 10 + (name[i] - '0'); + if (value > int.MaxValue) + break; + } + if (value <= int.MaxValue) + { + number = (int)value; return name.Substring(0, pos); } } From 964ffc72cab9871a2236e4099427c65d8bc8951a Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:23:00 +0200 Subject: [PATCH 2/8] Prepare search terms once per run instead of per candidate name AbstractSearchStrategy.IsMatch runs for every metadata row of every loaded module on each keystroke, yet it re-stripped the operator prefix per name and, for fuzzy terms, additionally lowercased both the name and the term - the largest allocation source in interactive search. The terms are invariant for the lifetime of a strategy (each keystroke builds a new request and strategy), so the stripping and lowercasing now happen once in the constructor, and the noncontiguous matcher compares spans with per-char ToLowerInvariant. This switches fuzzy matching from culture-sensitive to invariant lowercasing, which is the appropriate semantic for matching metadata names. The new IsMatchTests pin the +, -, =, ~ operator semantics (including the pre-existing quirk that =Name compares against the backtick- suffixed name of generic types) as observed before the change. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Search/AbstractSearchStrategy.cs | 103 ++++++++++---- ILSpy.Tests/Search/IsMatchTests.cs | 134 ++++++++++++++++++ 2 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 ILSpy.Tests/Search/IsMatchTests.cs diff --git a/ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs b/ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs index c225d0ab3c..69be629aa5 100644 --- a/ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs +++ b/ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading; @@ -69,12 +70,40 @@ public struct SearchRequest public abstract class AbstractSearchStrategy { + enum TermOperator + { + Contains, + NotContains, + Exact, + Fuzzy + } + + readonly struct PreparedTerm + { + // For Exact this is the unstripped term (including the '=' prefix), because the + // comparison below works with an offset of 1 and the full term length; for Fuzzy + // it is the stripped term lowered once with ToLowerInvariant; for the others it + // is the term with any '+'/'-' prefix stripped. + public readonly string Text; + public readonly TermOperator Operator; + + public PreparedTerm(TermOperator op, string text) + { + this.Operator = op; + this.Text = text; + } + } + protected readonly string[] searchTerm; protected readonly Regex? regex; protected readonly bool fullNameSearch; protected readonly bool omitGenerics; protected readonly SearchRequest searchRequest; private readonly IProducerConsumerCollection resultQueue; + // The search terms are invariant for the lifetime of a strategy (each keystroke + // creates a new request + strategy), so prefix stripping and lowercasing are done + // once here instead of per candidate name in IsMatch. + private readonly PreparedTerm[] preparedTerms; protected AbstractSearchStrategy(SearchRequest request, IProducerConsumerCollection resultQueue) { @@ -84,6 +113,39 @@ protected AbstractSearchStrategy(SearchRequest request, IProducerConsumerCollect this.searchRequest = request; this.fullNameSearch = request.FullNameSearch; this.omitGenerics = request.OmitGenerics; + this.preparedTerms = PrepareTerms(request.Keywords); + } + + static PreparedTerm[] PrepareTerms(string[] keywords) + { + var result = new List(keywords.Length); + foreach (string term in keywords) + { + if (string.IsNullOrEmpty(term)) + continue; + switch (term[0]) + { + case '+': // must contain + result.Add(new PreparedTerm(TermOperator.Contains, term.Substring(1))); + break; + case '-': // should not contain + if (term.Length > 1) + result.Add(new PreparedTerm(TermOperator.NotContains, term.Substring(1))); + break; + case '=': // exact match + if (term.Length > 1) + result.Add(new PreparedTerm(TermOperator.Exact, term)); + break; + case '~': + if (term.Length > 1) + result.Add(new PreparedTerm(TermOperator.Fuzzy, term.Substring(1).ToLowerInvariant())); + break; + default: + result.Add(new PreparedTerm(TermOperator.Contains, term)); + break; + } + } + return result.ToArray(); } public abstract void Search(MetadataFile module, CancellationToken cancellationToken); @@ -95,39 +157,32 @@ protected virtual bool IsMatch(string name) return regex.IsMatch(name); } - for (int i = 0; i < searchTerm.Length; ++i) + foreach (var term in preparedTerms) { // How to handle overlapping matches? - var term = searchTerm[i]; - if (string.IsNullOrEmpty(term)) - continue; - string text = name; - switch (term[0]) + switch (term.Operator) { - case '+': // must contain - term = term.Substring(1); - goto default; - case '-': // should not contain - if (term.Length > 1 && text.IndexOf(term.Substring(1), StringComparison.OrdinalIgnoreCase) >= 0) + case TermOperator.NotContains: + if (name.IndexOf(term.Text, StringComparison.OrdinalIgnoreCase) >= 0) return false; break; - case '=': // exact match + case TermOperator.Exact: { - var equalCompareLength = text.IndexOf('`'); + var equalCompareLength = name.IndexOf('`'); if (equalCompareLength == -1) - equalCompareLength = text.Length; + equalCompareLength = name.Length; - if (term.Length > 1 && String.Compare(term, 1, text, 0, Math.Max(term.Length, equalCompareLength), + if (String.Compare(term.Text, 1, name, 0, Math.Max(term.Text.Length, equalCompareLength), StringComparison.OrdinalIgnoreCase) != 0) return false; } break; - case '~': - if (term.Length > 1 && !IsNoncontiguousMatch(text.ToLower(), term.Substring(1).ToLower())) + case TermOperator.Fuzzy: + if (!IsNoncontiguousMatch(name, term.Text)) return false; break; default: - if (text.IndexOf(term, StringComparison.OrdinalIgnoreCase) < 0) + if (name.IndexOf(term.Text, StringComparison.OrdinalIgnoreCase) < 0) return false; break; } @@ -135,26 +190,26 @@ protected virtual bool IsMatch(string name) return true; } - bool IsNoncontiguousMatch(string text, string searchTerm) + static bool IsNoncontiguousMatch(ReadOnlySpan text, ReadOnlySpan loweredSearchTerm) { - if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(searchTerm)) + if (text.IsEmpty || loweredSearchTerm.IsEmpty) { return false; } var textLength = text.Length; - if (searchTerm.Length > textLength) + if (loweredSearchTerm.Length > textLength) { return false; } var i = 0; - for (int searchIndex = 0; searchIndex < searchTerm.Length;) + for (int searchIndex = 0; searchIndex < loweredSearchTerm.Length;) { while (i != textLength) { - if (text[i] == searchTerm[searchIndex]) + if (char.ToLowerInvariant(text[i]) == loweredSearchTerm[searchIndex]) { // Check if all characters in searchTerm have been matched - if (searchTerm.Length == ++searchIndex) + if (loweredSearchTerm.Length == ++searchIndex) return true; i++; break; diff --git a/ILSpy.Tests/Search/IsMatchTests.cs b/ILSpy.Tests/Search/IsMatchTests.cs new file mode 100644 index 0000000000..2b3dcbe72f --- /dev/null +++ b/ILSpy.Tests/Search/IsMatchTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Concurrent; +using System.Threading; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpyX.Search; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Search; + +/// +/// Pins the term-matching semantics of : +/// plain containment, the +/-/=/~ operators, multi-term conjunction, and the +/// handling of degenerate terms. +/// +[TestFixture] +public class IsMatchTests +{ + sealed class ExposingSearchStrategy : AbstractSearchStrategy + { + public ExposingSearchStrategy(params string[] keywords) + : base(new SearchRequest { Keywords = keywords }, new ConcurrentQueue()) + { + } + + public bool Match(string name) => IsMatch(name); + + public override void Search(MetadataFile module, CancellationToken cancellationToken) + { + } + } + + static bool IsMatch(string name, params string[] keywords) + => new ExposingSearchStrategy(keywords).Match(name); + + [Test] + public void Plain_Term_Matches_Substring_Ignoring_Case() + { + Assert.That(IsMatch("StringBuilder", "builder"), Is.True); + Assert.That(IsMatch("StringBuilder", "STRING"), Is.True); + Assert.That(IsMatch("StringBuilder", "Comparer"), Is.False); + } + + [Test] + public void Plus_Operator_Requires_The_Term_To_Be_Contained() + { + Assert.That(IsMatch("Enumerable", "+Enum"), Is.True); + Assert.That(IsMatch("Enumerable", "+enumera"), Is.True); + Assert.That(IsMatch("List", "+Enum"), Is.False); + } + + [Test] + public void Minus_Operator_Excludes_Names_Containing_The_Term() + { + Assert.That(IsMatch("StringBuilder", "-Builder"), Is.False); + Assert.That(IsMatch("StringComparer", "-Builder"), Is.True); + } + + [Test] + public void Equals_Operator_Requires_Exact_Name_Match() + { + Assert.That(IsMatch("String", "=String"), Is.True); + Assert.That(IsMatch("String", "=string"), Is.True); + Assert.That(IsMatch("StringBuilder", "=String"), Is.False); + } + + [Test] + public void Equals_Operator_Compares_Against_The_Backtick_Suffixed_Name() + { + // The compare window is max(term length incl. '=', chars before '`'), so a + // generic type only matches when the term spells out the arity suffix too. + Assert.That(IsMatch("List`1", "=List`1"), Is.True); + Assert.That(IsMatch("List`1", "=List"), Is.False); + Assert.That(IsMatch("List`1", "=Dictionary"), Is.False); + } + + [Test] + public void Fuzzy_Operator_Matches_Noncontiguous_Character_Sequences() + { + Assert.That(IsMatch("StringBuilder", "~sb"), Is.True); + Assert.That(IsMatch("StringBuilder", "~strbld"), Is.True); + Assert.That(IsMatch("StringBuilder", "~xyz"), Is.False); + // Characters must appear in order: 'b' never precedes 's'. + Assert.That(IsMatch("StringBuilder", "~bs"), Is.False); + } + + [Test] + public void Fuzzy_Operator_Ignores_Case_On_Both_Sides() + { + Assert.That(IsMatch("StringBuilder", "~SB"), Is.True); + Assert.That(IsMatch("stringbuilder", "~STRB"), Is.True); + } + + [Test] + public void Fuzzy_Term_Longer_Than_The_Name_Never_Matches() + { + Assert.That(IsMatch("Ab", "~abc"), Is.False); + } + + [Test] + public void Multiple_Terms_Are_A_Conjunction() + { + Assert.That(IsMatch("StringBuilder", "String", "Builder"), Is.True); + Assert.That(IsMatch("StringBuilder", "String", "-Builder"), Is.False); + Assert.That(IsMatch("StringComparer", "String", "-Builder"), Is.True); + } + + [Test] + public void Degenerate_Terms_Match_Everything() + { + // An empty term is skipped; a bare operator has no payload to test. + Assert.That(IsMatch("Anything", ""), Is.True); + Assert.That(IsMatch("Anything", "~"), Is.True); + Assert.That(IsMatch("Anything", "-"), Is.True); + } +} From 55fbd563e20feb39a50b4fde0887e2ea9a48b904 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:28:42 +0200 Subject: [PATCH 3/8] Fuse the reflection-name and arity parse to cut each string once SplitTypeParameterCountFromReflectionName allocated the digits after the backtick only to feed int.TryParse and discard them, and the TopLevelTypeName constructor cut the name part twice for generic types (once at the dot, again at the backtick), dropping the first cut. Both run for every generic reflection name parsed, e.g. for typeof-valued attribute arguments and string-switch metadata. The arity is now parsed in place with a digit loop (netstandard2.0 has no span int.TryParse) and each final string is cut exactly once. The digit loop only accepts plain ASCII digits, so suffixes like `+1 that int.TryParse tolerated are now rejected; such names are not legal reflection names. New unit tests pin the parse edge cases. Assisted-by: Claude:claude-fable-5:Claude Code --- .../TypeSystem/ReflectionHelperTests.cs | 78 +++++++++++++++++++ .../TypeSystem/ReflectionHelper.cs | 37 ++++++--- .../TypeSystem/TopLevelTypeName.cs | 12 +-- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs b/ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs index 66b24d03e1..fa5ec206b3 100644 --- a/ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs +++ b/ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs @@ -266,6 +266,84 @@ public void ParseInvalidReflectionName12() Assert.Throws(() => ReflectionHelper.ParseReflectionName("System.Action`1[[System.Int32]a]", context)); } + [Test] + public void SplitTypeParameterCountFromName() + { + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("List`1", out int tpc), Is.EqualTo("List")); + Assert.That(tpc, Is.EqualTo(1)); + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Dictionary`2", out tpc), Is.EqualTo("Dictionary")); + Assert.That(tpc, Is.EqualTo(2)); + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`12", out tpc), Is.EqualTo("Foo")); + Assert.That(tpc, Is.EqualTo(12)); + } + + [Test] + public void SplitTypeParameterCountWithoutBacktick() + { + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("String", out int tpc), Is.EqualTo("String")); + Assert.That(tpc, Is.EqualTo(0)); + } + + [Test] + public void SplitTypeParameterCountUsesTheLastBacktick() + { + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Outer`1+Inner`2", out int tpc), Is.EqualTo("Outer`1+Inner")); + Assert.That(tpc, Is.EqualTo(2)); + } + + [Test] + public void SplitTypeParameterCountKeepsNameWhenSuffixIsNotAPlainNumber() + { + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`", out int tpc), Is.EqualTo("Foo`")); + Assert.That(tpc, Is.EqualTo(0)); + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`x", out tpc), Is.EqualTo("Foo`x")); + Assert.That(tpc, Is.EqualTo(0)); + // Only plain digits form an arity: a signed suffix is not a legal reflection name. + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`+1", out tpc), Is.EqualTo("Foo`+1")); + Assert.That(tpc, Is.EqualTo(0)); + // An arity beyond int.MaxValue is rejected, not truncated. + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`2147483648", out tpc), Is.EqualTo("Foo`2147483648")); + Assert.That(tpc, Is.EqualTo(0)); + Assert.That(ReflectionHelper.SplitTypeParameterCountFromReflectionName("Foo`99999999999999999999", out tpc), Is.EqualTo("Foo`99999999999999999999")); + Assert.That(tpc, Is.EqualTo(0)); + } + + [Test] + public void TopLevelTypeNameParsesNamespaceNameAndArity() + { + var t = new TopLevelTypeName("System.Collections.Generic.List`1"); + Assert.That(t.Namespace, Is.EqualTo("System.Collections.Generic")); + Assert.That(t.Name, Is.EqualTo("List")); + Assert.That(t.TypeParameterCount, Is.EqualTo(1)); + } + + [Test] + public void TopLevelTypeNameWithoutNamespace() + { + var t = new TopLevelTypeName("List`1"); + Assert.That(t.Namespace, Is.EqualTo(string.Empty)); + Assert.That(t.Name, Is.EqualTo("List")); + Assert.That(t.TypeParameterCount, Is.EqualTo(1)); + } + + [Test] + public void TopLevelTypeNameWithoutArity() + { + var t = new TopLevelTypeName("System.String"); + Assert.That(t.Namespace, Is.EqualTo("System")); + Assert.That(t.Name, Is.EqualTo("String")); + Assert.That(t.TypeParameterCount, Is.EqualTo(0)); + } + + [Test] + public void TopLevelTypeNameIgnoresBacktickInsideTheNamespace() + { + var t = new TopLevelTypeName("A`1.B"); + Assert.That(t.Namespace, Is.EqualTo("A`1")); + Assert.That(t.Name, Is.EqualTo("B")); + Assert.That(t.TypeParameterCount, Is.EqualTo(0)); + } + [Test] public void ParseInvalidReflectionName13() { diff --git a/ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs b/ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs index edc2dfc30b..7530bb5cd8 100644 --- a/ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs +++ b/ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs @@ -83,19 +83,38 @@ public static string SplitTypeParameterCountFromReflectionName(string reflection public static string SplitTypeParameterCountFromReflectionName(string reflectionName, out int typeParameterCount) { int pos = reflectionName.LastIndexOf('`'); - if (pos < 0) + if (pos >= 0 && TryParseTypeParameterCount(reflectionName, pos + 1, out typeParameterCount)) { - typeParameterCount = 0; - return reflectionName; + return reflectionName.Substring(0, pos); } - else + typeParameterCount = 0; + return reflectionName; + } + + /// + /// Parses a type parameter count that starts at and extends to + /// the end of . Only plain ASCII digits are accepted + /// (no sign or whitespace), because that is all a legal reflection name can contain. + /// netstandard2.0 has no span-based int.TryParse, so the digits are accumulated manually + /// to avoid allocating a throwaway substring. + /// + internal static bool TryParseTypeParameterCount(string reflectionName, int start, out int typeParameterCount) + { + typeParameterCount = 0; + if (start >= reflectionName.Length) + return false; + long value = 0; + for (int i = start; i < reflectionName.Length; i++) { - string typeCount = reflectionName.Substring(pos + 1); - if (int.TryParse(typeCount, out typeParameterCount)) - return reflectionName.Substring(0, pos); - else - return reflectionName; + char c = reflectionName[i]; + if (c < '0' || c > '9') + return false; + value = value * 10 + (c - '0'); + if (value > int.MaxValue) + return false; } + typeParameterCount = (int)value; + return true; } #endregion diff --git a/ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs b/ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs index e975d62ac5..c6f77f163c 100644 --- a/ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs +++ b/ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs @@ -46,18 +46,20 @@ public TopLevelTypeName(string namespaceName, string name, int typeParameterCoun public TopLevelTypeName(string reflectionName) { + // Locate both separators up front so that namespaceName and name are each cut + // exactly once, without an intermediate string still carrying the arity suffix. int pos = reflectionName.LastIndexOf('.'); - if (pos < 0) + int tick = reflectionName.LastIndexOf('`'); + if (tick > pos && ReflectionHelper.TryParseTypeParameterCount(reflectionName, tick + 1, out typeParameterCount)) { - namespaceName = string.Empty; - name = reflectionName; + name = reflectionName.Substring(pos + 1, tick - pos - 1); } else { - namespaceName = reflectionName.Substring(0, pos); + typeParameterCount = 0; name = reflectionName.Substring(pos + 1); } - name = ReflectionHelper.SplitTypeParameterCountFromReflectionName(name, out typeParameterCount); + namespaceName = pos < 0 ? string.Empty : reflectionName.Substring(0, pos); } public string Namespace { From fed643eba047911f1da996b04ae3faa26112f54b Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:31:22 +0200 Subject: [PATCH 4/8] Render shortcut-form IL opcodes from fixed lookup tables WriteOpCode allocated two strings per rendered ldarg.N/ldloc.N/stloc.N instruction (the digit cut off the mnemonic plus the concatenated local-reference key), even though the shortcut forms only ever produce the indices 0-3. The digit text and the param_/loc_ reference keys now come from static tables indexed by opcode arithmetic. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Disassembler/MethodBodyDisassembler.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs b/ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs index 5099f5e9e2..90ed66a742 100644 --- a/ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs +++ b/ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs @@ -593,10 +593,17 @@ void WriteRVA(BlobReader blob, int offset, ILOpCode opCode) } } + // The shortcut-form opcodes cover exactly the indices 0-3, so the digit text and the + // local-reference keys can come from fixed tables instead of being allocated per + // rendered instruction. + static readonly string[] shortcutIndexes = { "0", "1", "2", "3" }; + static readonly string[] shortcutParamReferences = { "param_0", "param_1", "param_2", "param_3" }; + static readonly string[] shortcutLocReferences = { "loc_0", "loc_1", "loc_2", "loc_3" }; + private void WriteOpCode(ILOpCode opCode) { var opCodeInfo = new OpCodeInfo(opCode, opCode.GetDisplayName()); - string index; + int index; switch (opCode) { case ILOpCode.Ldarg_0: @@ -604,8 +611,8 @@ private void WriteOpCode(ILOpCode opCode) case ILOpCode.Ldarg_2: case ILOpCode.Ldarg_3: output.WriteReference(opCodeInfo, omitSuffix: true); - index = opCodeInfo.Name.Substring(6); - output.WriteLocalReference(index, "param_" + index); + index = opCode - ILOpCode.Ldarg_0; + output.WriteLocalReference(shortcutIndexes[index], shortcutParamReferences[index]); break; case ILOpCode.Ldloc_0: case ILOpCode.Ldloc_1: @@ -616,8 +623,8 @@ private void WriteOpCode(ILOpCode opCode) case ILOpCode.Stloc_2: case ILOpCode.Stloc_3: output.WriteReference(opCodeInfo, omitSuffix: true); - index = opCodeInfo.Name.Substring(6); - output.WriteLocalReference(index, "loc_" + index); + index = opCode <= ILOpCode.Ldloc_3 ? opCode - ILOpCode.Ldloc_0 : opCode - ILOpCode.Stloc_0; + output.WriteLocalReference(shortcutIndexes[index], shortcutLocReferences[index]); break; default: output.WriteReference(opCodeInfo); From c9a9fa34866c2f8881114ddbc556af396b753475 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:38:08 +0200 Subject: [PATCH 5/8] Skip the StringBuilder in EscapeIdentifier when nothing needs escaping EscapeIdentifier runs for every identifier token emitted, yet it built a StringBuilder plus a fresh result string even for the overwhelmingly common case of an identifier with no escapable characters. A pre-scan now returns the original instance untouched in that case, and the surrogate-pair copy appends the two chars directly instead of cutting a two-char substring. New unit tests pin the escaping behavior and the identity fast path. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Output/EscapeIdentifierTests.cs | 69 +++++++++++++++++++ .../OutputVisitor/TextWriterTokenWriter.cs | 19 ++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs diff --git a/ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs b/ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs new file mode 100644 index 0000000000..47dd6b1544 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Christoph Wille +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using ICSharpCode.Decompiler.CSharp.OutputVisitor; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests.Output +{ + [TestFixture] + public class EscapeIdentifierTests + { + [Test] + public void PlainIdentifierIsReturnedAsTheSameInstance() + { + // The overwhelmingly common case must not allocate at all. + string identifier = "MyIdentifier_42"; + Assert.That(TextWriterTokenWriter.EscapeIdentifier(identifier), Is.SameAs(identifier)); + } + + [Test] + public void EmptyAndNullAreReturnedUnchanged() + { + Assert.That(TextWriterTokenWriter.EscapeIdentifier(""), Is.EqualTo("")); + Assert.That(TextWriterTokenWriter.EscapeIdentifier(null), Is.Null); + } + + [Test] + public void ControlCharIsEscaped() + { + Assert.That(TextWriterTokenWriter.EscapeIdentifier("a\u0001b"), Is.EqualTo(@"a\u0001b")); + } + + [Test] + public void BackslashIsEscaped() + { + Assert.That(TextWriterTokenWriter.EscapeIdentifier("a\\b"), Is.EqualTo(@"a\u005cb")); + } + + [Test] + public void PrintableSurrogatePairPassesThroughUnchanged() + { + // U+1D49C (MATHEMATICAL SCRIPT CAPITAL A) is a letter, i.e. printable. + Assert.That(TextWriterTokenWriter.EscapeIdentifier("a\U0001D49Cb"), Is.EqualTo("a\U0001D49Cb")); + } + + [Test] + public void NonPrintableSurrogatePairIsEscapedAsUtf32() + { + // U+1D173 (MUSICAL SYMBOL BEGIN BEAM) is a format char, i.e. non-printable. + Assert.That(TextWriterTokenWriter.EscapeIdentifier("a\U0001D173b"), Is.EqualTo(@"a\U0001d173b")); + } + } +} diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs index 0236ee83be..1e058fa9cd 100644 --- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs +++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs @@ -514,14 +514,17 @@ public static string EscapeIdentifier(string identifier) { if (string.IsNullOrEmpty(identifier)) return identifier; - StringBuilder sb = new StringBuilder(); + if (!NeedsEscaping(identifier)) + return identifier; + StringBuilder sb = new StringBuilder(identifier.Length); for (int i = 0; i < identifier.Length; i++) { if (IsPrintableIdentifierChar(identifier, i)) { if (char.IsSurrogatePair(identifier, i)) { - sb.Append(identifier.Substring(i, 2)); + sb.Append(identifier[i]); + sb.Append(identifier[i + 1]); i++; } else @@ -545,6 +548,18 @@ public static string EscapeIdentifier(string identifier) return sb.ToString(); } + static bool NeedsEscaping(string identifier) + { + for (int i = 0; i < identifier.Length; i++) + { + if (!IsPrintableIdentifierChar(identifier, i)) + return true; + if (char.IsSurrogatePair(identifier, i)) + i++; + } + return false; + } + public static bool ContainsNonPrintableIdentifierChar(string identifier) { if (string.IsNullOrEmpty(identifier)) From fe82cd359ae91cc4916041085a7d02123f30a674 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:46:11 +0200 Subject: [PATCH 6/8] Thread spans through the CleanUpVariableName chain CleanUpVariableName sits on the per-variable naming path of every decompiled method and allocated up to three intermediates (backtick cut, m_/_ prefix strip, lowercase-first concat) before producing its result, and its callers added further throwaway substrings when stripping get_/set_/Get/Set and interface-I prefixes. The cuts are now slices over the original name: ContainsNonPrintableIdentifierChar and IsValidName gained span overloads, and only the final lowered name is materialized, in a single allocation via char[] (netstandard2.0 has no string(span) constructor). IsKeyword keeps its string parameter - it is called on that final string anyway, and the keyword HashSet has no span lookup on netstandard2.0. Assisted-by: Claude:claude-fable-5:Claude Code --- .../OutputVisitor/TextWriterTokenWriter.cs | 24 ++++++++-- .../IL/Transforms/AssignVariableNames.cs | 44 +++++++++++++------ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs index 1e058fa9cd..90e5c164f9 100644 --- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs +++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs @@ -562,9 +562,11 @@ static bool NeedsEscaping(string identifier) public static bool ContainsNonPrintableIdentifierChar(string identifier) { - if (string.IsNullOrEmpty(identifier)) - return false; + return !string.IsNullOrEmpty(identifier) && ContainsNonPrintableIdentifierChar(identifier.AsSpan()); + } + public static bool ContainsNonPrintableIdentifierChar(ReadOnlySpan identifier) + { for (int i = 0; i < identifier.Length; i++) { if (char.IsWhiteSpace(identifier[i])) @@ -577,6 +579,11 @@ public static bool ContainsNonPrintableIdentifierChar(string identifier) } static bool IsPrintableIdentifierChar(string identifier, int index) + { + return IsPrintableIdentifierChar(identifier.AsSpan(), index); + } + + static bool IsPrintableIdentifierChar(ReadOnlySpan identifier, int index) { switch (identifier[index]) { @@ -588,7 +595,18 @@ static bool IsPrintableIdentifierChar(string identifier, int index) case '^': return true; } - switch (char.GetUnicodeCategory(identifier, index)) + UnicodeCategory category; + if (index + 1 < identifier.Length && char.IsSurrogatePair(identifier[index], identifier[index + 1])) + { + // netstandard2.0 has no code-point-based GetUnicodeCategory, so the rare + // astral-plane case pays for a two-char string to categorize the pair. + category = char.GetUnicodeCategory(new string(new[] { identifier[index], identifier[index + 1] }), 0); + } + else + { + category = char.GetUnicodeCategory(identifier[index]); + } + switch (category) { case UnicodeCategory.NonSpacingMark: case UnicodeCategory.SpacingCombiningMark: diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index ad2df209c3..8fb9fbd97c 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs @@ -676,7 +676,12 @@ internal static bool IsSupportedInstruction(object arg) internal static bool IsValidName(string varName) { - if (string.IsNullOrWhiteSpace(varName)) + return varName != null && IsValidName(varName.AsSpan()); + } + + static bool IsValidName(ReadOnlySpan varName) + { + if (varName.IsEmpty || varName.IsWhiteSpace()) return false; if (!(char.IsLetter(varName[0]) || varName[0] == '_')) return false; @@ -711,12 +716,12 @@ static string GetNameFromInstruction(ILInstruction inst) if (m.Name.StartsWith("get_", StringComparison.OrdinalIgnoreCase) && m.Parameters.Count == 0) { // use name from properties, but not from indexers - return CleanUpVariableName(m.Name.Substring(4)); + return CleanUpVariableName(m.Name.AsSpan(4)); } else if (m.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase) && m.Name.Length >= 4 && char.IsUpper(m.Name[3])) { // use name from Get-methods - return CleanUpVariableName(m.Name.Substring(3)); + return CleanUpVariableName(m.Name.AsSpan(3)); } break; case DynamicInvokeMemberInstruction dynInvokeMember: @@ -724,7 +729,7 @@ static string GetNameFromInstruction(ILInstruction inst) && dynInvokeMember.Name.Length >= 4 && char.IsUpper(dynInvokeMember.Name[3])) { // use name from Get-methods - return CleanUpVariableName(dynInvokeMember.Name.Substring(3)); + return CleanUpVariableName(dynInvokeMember.Name.AsSpan(3)); } break; } @@ -753,11 +758,11 @@ static string GetNameForArgument(ILInstruction parent, int i) // argument might be value of a setter if (m.Name.StartsWith("set_", StringComparison.OrdinalIgnoreCase)) { - return CleanUpVariableName(m.Name.Substring(4)); + return CleanUpVariableName(m.Name.AsSpan(4)); } else if (m.Name.StartsWith("Set", StringComparison.OrdinalIgnoreCase) && m.Name.Length >= 4 && char.IsUpper(m.Name[3])) { - return CleanUpVariableName(m.Name.Substring(3)); + return CleanUpVariableName(m.Name.AsSpan(3)); } } var p = call.GetParameter(i); @@ -823,9 +828,10 @@ static string GetNameByType(IType type) _ => type.Name }; // remove the 'I' for interfaces - if (name.Length >= 3 && name[0] == 'I' && char.IsUpper(name[1]) && char.IsLower(name[2])) - name = name.Substring(1); - name = CleanUpVariableName(name) ?? "obj"; + ReadOnlySpan nameSpan = name.AsSpan(); + if (nameSpan.Length >= 3 && nameSpan[0] == 'I' && char.IsUpper(nameSpan[1]) && char.IsLower(nameSpan[2])) + nameSpan = nameSpan.Slice(1); + name = CleanUpVariableName(nameSpan) ?? "obj"; } return name; } @@ -873,17 +879,22 @@ static string SplitName(string name, out int number) } static string CleanUpVariableName(string name) + { + return CleanUpVariableName(name.AsSpan()); + } + + static string CleanUpVariableName(ReadOnlySpan name) { // remove the backtick (generics) int pos = name.IndexOf('`'); if (pos >= 0) - name = name.Substring(0, pos); + name = name.Slice(0, pos); // remove field prefix: - if (name.Length > 2 && name.StartsWith("m_", StringComparison.Ordinal)) - name = name.Substring(2); + if (name.Length > 2 && name.StartsWith("m_".AsSpan(), StringComparison.Ordinal)) + name = name.Slice(2); else if (name.Length > 1 && name[0] == '_' && (char.IsLetter(name[1]) || name[1] == '_')) - name = name.Substring(1); + name = name.Slice(1); if (TextWriterTokenWriter.ContainsNonPrintableIdentifierChar(name)) { @@ -898,7 +909,12 @@ static string CleanUpVariableName(string name) // separates the parts of its generated names with '$'. return null; } - string lowerCaseName = char.ToLower(name[0]) + name.Substring(1); + // lowercase the first char, materializing the result in a single allocation + // (netstandard2.0 has no string(ReadOnlySpan) constructor) + char[] chars = new char[name.Length]; + chars[0] = char.ToLower(name[0]); + name.Slice(1).CopyTo(chars.AsSpan(1)); + string lowerCaseName = new string(chars); if (CSharp.OutputVisitor.CSharpOutputVisitor.IsKeyword(lowerCaseName)) return null; return lowerCaseName; From 0ff17e5b8a6473e66f21b92d73d081c510dd146f Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:48:38 +0200 Subject: [PATCH 7/8] Write IL-with-C# comment slices as spans The "IL with C#" view cut up to four substrings (prefix, trimmed prefix, highlighted range, suffix) out of every source line emitted alongside an IL instruction. ISmartTextOutput now accepts a ReadOnlySpan - as a default interface method falling back to Write(text.ToString()) so existing implementers keep working - and AvaloniaEditTextOutput appends the span straight into its StringBuilder. The overload lives on ISmartTextOutput rather than ITextOutput because the latter is netstandard2.0 (no default interface methods there), where a new member would break every external implementer of the decompiler library; the highlighted-comment path is typed against ISmartTextOutput already. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy/Languages/CSharpILMixedLanguage.cs | 8 ++++---- ILSpy/TextView/AvaloniaEditTextOutput.cs | 7 +++++++ ILSpy/TextView/ISmartTextOutput.cs | 7 +++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/ILSpy/Languages/CSharpILMixedLanguage.cs b/ILSpy/Languages/CSharpILMixedLanguage.cs index 1aec1b9a33..6386d63dc1 100644 --- a/ILSpy/Languages/CSharpILMixedLanguage.cs +++ b/ILSpy/Languages/CSharpILMixedLanguage.cs @@ -181,13 +181,13 @@ void WriteHighlightedCommentLine(ISmartTextOutput output, string text, int start output.Write("// "); output.BeginSpan(gray); if (isSingleLine) - output.Write(text.Substring(0, startColumn).TrimStart()); + output.Write(text.AsSpan(0, startColumn).TrimStart()); else - output.Write(text.Substring(0, startColumn)); + output.Write(text.AsSpan(0, startColumn)); output.EndSpan(); - output.Write(text.Substring(startColumn, endColumn - startColumn)); + output.Write(text.AsSpan(startColumn, endColumn - startColumn)); output.BeginSpan(gray); - output.Write(text.Substring(endColumn)); + output.Write(text.AsSpan(endColumn)); output.EndSpan(); output.WriteLine(); } diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index 71e0766515..58132a54f1 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -186,6 +186,13 @@ public void Write(string text) CheckLength(); } + public void Write(ReadOnlySpan text) + { + WriteIndentIfNeeded(); + builder.Append(text); + CheckLength(); + } + public void WriteLine() { if (IgnoreNewLineAndIndent) diff --git a/ILSpy/TextView/ISmartTextOutput.cs b/ILSpy/TextView/ISmartTextOutput.cs index f69acbb8cd..a84ebded91 100644 --- a/ILSpy/TextView/ISmartTextOutput.cs +++ b/ILSpy/TextView/ISmartTextOutput.cs @@ -48,6 +48,13 @@ public interface ISmartTextOutput : ITextOutput void BeginSpan(HighlightingColor highlightingColor); void EndSpan(); + /// + /// Writes a slice of text without requiring the caller to allocate an intermediate + /// string. Implementations that buffer internally should override the default, + /// which falls back to . + /// + void Write(ReadOnlySpan text) => Write(text.ToString()); + /// /// Title displayed in the document tab's header. /// From b71d47f586e3dc3309d028d2acd29771d65e8619 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 7 Aug 2026 14:50:42 +0200 Subject: [PATCH 8/8] Gate the explicit-impl operator check before allocating the short name The MetadataMethod constructor cut the post-dot short name for every static non-generic method whose name contains a dot, only to test it for an op_ prefix that almost never matches. The prefix is now checked on a span slice first, so the substring (still required by OperatorDeclaration.GetOperatorType) is allocated only for actual explicit-interface operator implementations. Assisted-by: Claude:claude-fable-5:Claude Code --- .../TypeSystem/Implementation/MetadataMethod.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs index 1f69799d07..056bd3f438 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs @@ -102,12 +102,13 @@ internal MetadataMethod(MetadataModule module, MethodDefinitionHandle handle) // with MethodAttributes.SpecialName or MethodAttributes.RTSpecialName string name = this.Name; int index = name.LastIndexOf('.'); - if (index > 0) + // Test the op_ prefix on a slice first: this branch runs for every static + // non-generic method, and only operator names warrant the substring. + if (index > 0 && name.AsSpan(index + 1).StartsWith("op_".AsSpan(), StringComparison.Ordinal)) { name = name.Substring(index + 1); - if (name.StartsWith("op_", StringComparison.Ordinal) - && CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) != null) + if (CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) != null) { this.symbolKind = SymbolKind.Operator; }