Browse Source

Merge b71d47f586 into c1abc29765

pull/3963/merge
Christoph Wille 2 days ago committed by GitHub
parent
commit
c214437c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 69
      ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs
  2. 78
      ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs
  3. 43
      ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs
  4. 17
      ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs
  5. 56
      ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs
  6. 7
      ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs
  7. 37
      ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs
  8. 12
      ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs
  9. 103
      ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs
  10. 134
      ILSpy.Tests/Search/IsMatchTests.cs
  11. 8
      ILSpy/Languages/CSharpILMixedLanguage.cs
  12. 7
      ILSpy/TextView/AvaloniaEditTextOutput.cs
  13. 7
      ILSpy/TextView/ISmartTextOutput.cs

69
ICSharpCode.Decompiler.Tests/Output/EscapeIdentifierTests.cs

@ -0,0 +1,69 @@ @@ -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"));
}
}
}

78
ICSharpCode.Decompiler.Tests/TypeSystem/ReflectionHelperTests.cs

@ -266,6 +266,84 @@ namespace ICSharpCode.Decompiler.Tests.TypeSystem @@ -266,6 +266,84 @@ namespace ICSharpCode.Decompiler.Tests.TypeSystem
Assert.Throws<ReflectionNameParseException>(() => 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()
{

43
ICSharpCode.Decompiler/CSharp/OutputVisitor/TextWriterTokenWriter.cs

@ -514,14 +514,17 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor @@ -514,14 +514,17 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor
{
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 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor @@ -545,11 +548,25 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor
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<char> identifier)
{
for (int i = 0; i < identifier.Length; i++)
{
if (char.IsWhiteSpace(identifier[i]))
@ -562,6 +579,11 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor @@ -562,6 +579,11 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor
}
static bool IsPrintableIdentifierChar(string identifier, int index)
{
return IsPrintableIdentifierChar(identifier.AsSpan(), index);
}
static bool IsPrintableIdentifierChar(ReadOnlySpan<char> identifier, int index)
{
switch (identifier[index])
{
@ -573,7 +595,18 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor @@ -573,7 +595,18 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor
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:

17
ICSharpCode.Decompiler/Disassembler/MethodBodyDisassembler.cs

@ -605,10 +605,17 @@ namespace ICSharpCode.Decompiler.Disassembler @@ -605,10 +605,17 @@ namespace ICSharpCode.Decompiler.Disassembler
}
}
// 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:
@ -616,8 +623,8 @@ namespace ICSharpCode.Decompiler.Disassembler @@ -616,8 +623,8 @@ namespace ICSharpCode.Decompiler.Disassembler
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:
@ -628,8 +635,8 @@ namespace ICSharpCode.Decompiler.Disassembler @@ -628,8 +635,8 @@ namespace ICSharpCode.Decompiler.Disassembler
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);

56
ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs

@ -699,7 +699,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -699,7 +699,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms
internal static bool IsValidName(string varName)
{
if (string.IsNullOrWhiteSpace(varName))
return varName != null && IsValidName(varName.AsSpan());
}
static bool IsValidName(ReadOnlySpan<char> varName)
{
if (varName.IsEmpty || varName.IsWhiteSpace())
return false;
if (!(char.IsLetter(varName[0]) || varName[0] == '_'))
return false;
@ -734,12 +739,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -734,12 +739,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms
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:
@ -747,7 +752,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -747,7 +752,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms
&& 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;
}
@ -776,11 +781,11 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -776,11 +781,11 @@ namespace ICSharpCode.Decompiler.IL.Transforms
// 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);
@ -846,9 +851,10 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -846,9 +851,10 @@ namespace ICSharpCode.Decompiler.IL.Transforms
_ => 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<char> 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;
}
@ -876,8 +882,18 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -876,8 +882,18 @@ namespace ICSharpCode.Decompiler.IL.Transforms
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);
}
}
@ -886,17 +902,22 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -886,17 +902,22 @@ namespace ICSharpCode.Decompiler.IL.Transforms
}
static string CleanUpVariableName(string name)
{
return CleanUpVariableName(name.AsSpan());
}
static string CleanUpVariableName(ReadOnlySpan<char> 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))
{
@ -911,7 +932,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms @@ -911,7 +932,12 @@ namespace ICSharpCode.Decompiler.IL.Transforms
// 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<char>) 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;

7
ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs

@ -102,12 +102,13 @@ namespace ICSharpCode.Decompiler.TypeSystem.Implementation @@ -102,12 +102,13 @@ namespace ICSharpCode.Decompiler.TypeSystem.Implementation
// 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;
}

37
ICSharpCode.Decompiler/TypeSystem/ReflectionHelper.cs

@ -83,19 +83,38 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -83,19 +83,38 @@ namespace ICSharpCode.Decompiler.TypeSystem
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;
}
/// <summary>
/// Parses a type parameter count that starts at <paramref name="start"/> and extends to
/// the end of <paramref name="reflectionName"/>. 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.
/// </summary>
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

12
ICSharpCode.Decompiler/TypeSystem/TopLevelTypeName.cs

@ -46,18 +46,20 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -46,18 +46,20 @@ namespace ICSharpCode.Decompiler.TypeSystem
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 {

103
ICSharpCode.ILSpyX/Search/AbstractSearchStrategy.cs

@ -18,6 +18,7 @@ @@ -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 @@ namespace ICSharpCode.ILSpyX.Search @@ -69,12 +70,40 @@ namespace ICSharpCode.ILSpyX.Search
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<SearchResult> 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<SearchResult> resultQueue)
{
@ -84,6 +113,39 @@ namespace ICSharpCode.ILSpyX.Search @@ -84,6 +113,39 @@ namespace ICSharpCode.ILSpyX.Search
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<PreparedTerm>(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 @@ namespace ICSharpCode.ILSpyX.Search @@ -95,39 +157,32 @@ namespace ICSharpCode.ILSpyX.Search
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 @@ namespace ICSharpCode.ILSpyX.Search @@ -135,26 +190,26 @@ namespace ICSharpCode.ILSpyX.Search
return true;
}
bool IsNoncontiguousMatch(string text, string searchTerm)
static bool IsNoncontiguousMatch(ReadOnlySpan<char> text, ReadOnlySpan<char> 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;

134
ILSpy.Tests/Search/IsMatchTests.cs

@ -0,0 +1,134 @@ @@ -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;
/// <summary>
/// Pins the term-matching semantics of <see cref="AbstractSearchStrategy.IsMatch"/>:
/// plain containment, the +/-/=/~ operators, multi-term conjunction, and the
/// handling of degenerate terms.
/// </summary>
[TestFixture]
public class IsMatchTests
{
sealed class ExposingSearchStrategy : AbstractSearchStrategy
{
public ExposingSearchStrategy(params string[] keywords)
: base(new SearchRequest { Keywords = keywords }, new ConcurrentQueue<SearchResult>())
{
}
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);
}
}

8
ILSpy/Languages/CSharpILMixedLanguage.cs

@ -181,13 +181,13 @@ namespace ICSharpCode.ILSpy.Languages @@ -181,13 +181,13 @@ namespace ICSharpCode.ILSpy.Languages
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();
}

7
ILSpy/TextView/AvaloniaEditTextOutput.cs

@ -186,6 +186,13 @@ namespace ICSharpCode.ILSpy.TextView @@ -186,6 +186,13 @@ namespace ICSharpCode.ILSpy.TextView
CheckLength();
}
public void Write(ReadOnlySpan<char> text)
{
WriteIndentIfNeeded();
builder.Append(text);
CheckLength();
}
public void WriteLine()
{
if (IgnoreNewLineAndIndent)

7
ILSpy/TextView/ISmartTextOutput.cs

@ -48,6 +48,13 @@ namespace ICSharpCode.ILSpy.TextView @@ -48,6 +48,13 @@ namespace ICSharpCode.ILSpy.TextView
void BeginSpan(HighlightingColor highlightingColor);
void EndSpan();
/// <summary>
/// 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 <see cref="ITextOutput.Write(string)"/>.
/// </summary>
void Write(ReadOnlySpan<char> text) => Write(text.ToString());
/// <summary>
/// Title displayed in the document tab's header.
/// </summary>

Loading…
Cancel
Save