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.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/CSharp/OutputVisitor/TextWriterTokenWriter.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs index 0236ee83be..90e5c164f9 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,11 +548,25 @@ 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)) - 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])) @@ -562,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]) { @@ -573,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/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); diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index 7aba498bce..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; } @@ -853,8 +859,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); } } @@ -863,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)) { @@ -888,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; 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; } 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 { 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); + } +} 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. ///