Merge pull request #3963 from icsharpcode/substring-optimizations

Substring optimizations: eliminate hot-path allocations (audit top 8)
This commit is contained in:
Christoph Wille
2026-10-11 12:48:01 +02:00
committed by GitHub
21 changed files with 632 additions and 90 deletions
@@ -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"));
}
}
}
@@ -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()
{
@@ -240,8 +240,8 @@ namespace ICSharpCode.Decompiler.CSharp
/// Gets or sets whether the pipeline records its steps into <see cref="Stepper"/>, so that one
/// step tree spans all of it: the IL transforms of every member, the ILAst-to-C# conversion,
/// and the C# AST transforms.
/// Off by default, and off means the whole pipeline records nothing: every phase keeps the
/// stepper its context creates for itself, which nothing reads. Turning it on is what makes the
/// Off by default, and off means the whole pipeline records nothing: contexts do not get a
/// stepper unless this is enabled. Turning it on is what makes the
/// steps <i>retained</i>, and each retained step pins the ILAst it captured, including the
/// instructions its transform removed. That is affordable for the single type a step view
/// displays and not for a whole-module decompile, so only callers that show the steps opt in.
@@ -910,8 +910,7 @@ namespace ICSharpCode.Decompiler.CSharp
var context = new TransformContext(typeSystem, decompileRun, decompilationContext, typeSystemAstBuilder);
// Off means off for the whole pipeline: leaving the AST half recording would give the same
// pipeline two numbering bases, and an index recorded under one of them means a different
// step under the other. Without this the context keeps a stepper of its own, which nothing
// reads.
// step under the other. Without this the context does not record steps.
if (RecordSteps)
context.Stepper = Stepper;
// The tree handed to the pipeline must already be well-formed; check it once up front so a
@@ -3784,7 +3784,7 @@ namespace ICSharpCode.Decompiler.CSharp
{
var property = (IProperty)lastElement.Member;
Debug.Assert(property.Parameters.Count == indices);
Debug.Assert(property.Setter != null, $"Indexer property {property} has no setter");
DecompilerAssert.That(property.Setter != null, $"Indexer property {property} has no setter");
elementsStack.Peek().Add(
new CallBuilder(this, typeSystem, settings)
.BuildDictionaryInitializerExpression(lastElement.OpCode, property.Setter, initObjRR, GetIndices(lastElement.Indices, indexVariables).ToList(), info.Values!.Single())
@@ -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
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
}
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
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:
@@ -35,6 +35,7 @@ using System.Linq;
using ICSharpCode.Decompiler.CSharp.OutputVisitor;
using ICSharpCode.Decompiler.CSharp.Syntax.PatternMatching;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.Decompiler.Util;
namespace ICSharpCode.Decompiler.CSharp.Syntax
{
@@ -577,7 +578,7 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax
{
// A single slot reads null only when empty; that is valid only if the slot is optional.
// (Collection slots never yield a null index, so this branch is always a single slot.)
Debug.Assert(slot.IsOptional, $"required slot '{slot.Name}' on {GetType().Name} must not be empty");
DecompilerAssert.That(slot.IsOptional, $"required slot '{slot.Name}' on {GetType().Name} must not be empty");
continue;
}
Debug.Assert(child.parent == this, "child's Parent must point back to this node");
@@ -33,6 +33,7 @@ using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.Semantics;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.Decompiler.TypeSystem.Implementation;
using ICSharpCode.Decompiler.Util;
namespace ICSharpCode.Decompiler.CSharp.Transforms
{
@@ -61,7 +62,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
.ThenByDescending(n => n);
foreach (string ns in sortedImports)
{
Debug.Assert(context.RequiredNamespacesSuperset.Contains(ns), $"Should not insert using declaration for namespace that is missing from the superset: {ns}");
DecompilerAssert.That(context.RequiredNamespacesSuperset.Contains(ns), $"Should not insert using declaration for namespace that is missing from the superset: {ns}");
// we go backwards (OrderByDescending) through the list of namespaces because we insert them backwards
// (always inserting at the start of the list)
string[] parts = ns.Split('.');
@@ -39,7 +39,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
public readonly TypeSystemAstBuilder TypeSystemAstBuilder;
public readonly DecompilerSettings Settings;
internal readonly DecompileRun DecompileRun;
public Stepper Stepper { get; set; }
public Stepper? Stepper { get; set; }
readonly ITypeResolveContext decompilationContext;
@@ -71,7 +71,6 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
this.TypeSystemAstBuilder = typeSystemAstBuilder;
this.CancellationToken = decompileRun.CancellationToken;
this.Settings = decompileRun.Settings;
this.Stepper = new Stepper();
}
/// <summary>
@@ -82,20 +81,20 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
[DebuggerStepThrough]
internal void Step(string description, AstNode? near = null)
{
Stepper.Step(description, CreateNodeInfo(near));
Stepper?.Step(description, CreateNodeInfo(near));
}
[Conditional("STEP")]
[DebuggerStepThrough]
internal void StepStartGroup(string description, AstNode? near = null)
{
Stepper.StartGroup(description, CreateNodeInfo(near));
Stepper?.StartGroup(description, CreateNodeInfo(near));
}
[Conditional("STEP")]
internal void StepEndGroup(bool keepIfEmpty = false)
{
Stepper.EndGroup(keepIfEmpty);
Stepper?.EndGroup(keepIfEmpty);
}
/// <summary>
@@ -106,7 +105,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
[Conditional("STEP")]
internal void EndStep(AstNode? modifiedNode)
{
if (Stepper.LastStep != null && modifiedNode != null)
if (Stepper?.LastStep != null && modifiedNode != null)
{
var marker = new DebugStepMarker();
modifiedNode.AddAnnotation(marker);
@@ -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
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
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);
@@ -710,7 +710,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;
@@ -745,12 +750,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:
@@ -758,7 +763,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;
}
@@ -787,11 +792,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);
@@ -857,9 +862,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;
}
@@ -887,8 +893,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);
}
}
@@ -897,17 +913,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))
{
@@ -922,7 +943,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;
@@ -91,7 +91,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms
continue;
if (!callsites.TryGetValue(cacheField, out var callsite))
continue;
context.Stepper.Step("Transform callsite for " + callsite.MemberName);
context.Step("Transform callsite for " + callsite.MemberName, invokeCall);
var deadArguments = new List<ILInstruction>();
ILInstruction replacement = MakeDynamicInstruction(callsite, invokeCall, deadArguments);
if (replacement == null)
@@ -50,7 +50,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms
public IDebugInfoProvider? DebugInfo { get; }
public DecompilerSettings Settings { get; }
public CancellationToken CancellationToken { get; set; }
public Stepper Stepper { get; set; }
public Stepper? Stepper { get; set; }
public Metadata.MetadataFile PEFile => TypeSystem.MainModule.MetadataFile;
internal DecompileRun? DecompileRun { get; set; }
@@ -73,7 +73,6 @@ namespace ICSharpCode.Decompiler.IL.Transforms
this.TypeSystem = typeSystem ?? throw new ArgumentNullException(nameof(typeSystem));
this.Settings = settings ?? new DecompilerSettings();
this.DebugInfo = debugInfo;
Stepper = new Stepper();
}
public ILTransformContext(ILTransformContext context, ILFunction? function = null)
@@ -107,20 +106,20 @@ namespace ICSharpCode.Decompiler.IL.Transforms
[DebuggerStepThrough]
internal void Step(string description, ILInstruction? near)
{
Stepper.Step(description, CreateNodeInfo(near));
Stepper?.Step(description, CreateNodeInfo(near));
}
[Conditional("STEP")]
[DebuggerStepThrough]
internal void StepStartGroup(string description, ILInstruction? near = null)
{
Stepper.StartGroup(description, CreateNodeInfo(near));
Stepper?.StartGroup(description, CreateNodeInfo(near));
}
[Conditional("STEP")]
internal void StepEndGroup(bool keepIfEmpty = false)
{
Stepper.EndGroup(keepIfEmpty);
Stepper?.EndGroup(keepIfEmpty);
}
/// <summary>
@@ -134,7 +133,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms
[Conditional("STEP")]
internal void EndStep(ILInstruction? modifiedNode)
{
if (Stepper.LastStep is { } step && modifiedNode != null)
if (Stepper?.LastStep is { } step && modifiedNode != null)
{
step.ModifiedNode = modifiedNode;
step.RecordModifiedNode(modifiedNode, insertFirst: true);
@@ -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;
}
@@ -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
@@ -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 {
@@ -0,0 +1,105 @@
// Copyright (c) 2026 Siegfried Pammer
//
// 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.
#nullable enable
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text;
namespace ICSharpCode.Decompiler.Util
{
static class DecompilerAssert
{
[Conditional("DEBUG")]
public static void That([DoesNotReturnIf(false)] bool condition)
{
#if DEBUG
if (!condition)
Debug.Fail("Assertion failed.");
#endif
}
[Conditional("DEBUG")]
public static void That([DoesNotReturnIf(false)] bool condition,
[InterpolatedStringHandlerArgument(nameof(condition))] ref AssertInterpolatedStringHandler message)
{
#if DEBUG
if (!condition)
Debug.Fail(message.ToString());
#endif
}
}
[InterpolatedStringHandler]
ref struct AssertInterpolatedStringHandler
{
readonly bool enabled;
StringBuilder? builder;
public AssertInterpolatedStringHandler(int literalLength, int formattedCount, bool condition, out bool shouldAppend)
{
enabled = !condition;
shouldAppend = enabled;
builder = enabled ? new StringBuilder(literalLength) : null;
}
public void AppendLiteral(string value)
{
if (enabled)
builder!.Append(value);
}
public void AppendFormatted<T>(T value)
{
if (enabled)
builder!.Append(value);
}
public override string ToString()
{
return builder?.ToString() ?? string.Empty;
}
}
}
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
sealed class InterpolatedStringHandlerAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
{
public InterpolatedStringHandlerArgumentAttribute(string argument)
{
Arguments = new[] { argument };
}
public InterpolatedStringHandlerArgumentAttribute(params string[] arguments)
{
Arguments = arguments;
}
public string[] Arguments { get; }
}
}
@@ -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
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
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
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
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
View File
@@ -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);
}
}
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>