mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
477 lines
16 KiB
477 lines
16 KiB
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team |
|
// |
|
// 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; |
|
using System.Collections.Concurrent; |
|
using System.Collections.Generic; |
|
using System.Collections.ObjectModel; |
|
using System.Diagnostics; |
|
using System.Text.RegularExpressions; |
|
using System.Threading; |
|
using System.Threading.Tasks; |
|
|
|
using Avalonia.Threading; |
|
|
|
using ICSharpCode.Decompiler; |
|
using ICSharpCode.Decompiler.Metadata; |
|
using ICSharpCode.ILSpyX; |
|
using ICSharpCode.ILSpyX.Extensions; |
|
using ICSharpCode.ILSpyX.Search; |
|
|
|
using ILSpy.Languages; |
|
|
|
namespace ILSpy.Search |
|
{ |
|
/// <summary> |
|
/// Orchestrates one search across the loaded <see cref="AssemblyList"/>. Walks |
|
/// the assemblies serially on a background <see cref="Task"/>, pushes results into |
|
/// a thread-safe queue, and the UI thread drains that queue once per render frame |
|
/// with a wall-clock budget — the same shape as the WPF SearchPane. |
|
/// </summary> |
|
internal sealed class RunningSearch |
|
{ |
|
const int MaxResults = 1000; |
|
// 60 fps tick. Matches the cadence WPF's CompositionTarget.Rendering gives. |
|
const int RefreshIntervalMs = 16; |
|
// Hard cap on time spent inserting per tick. Keeps the UI responsive even |
|
// when the queue is jammed with thousands of late-arriving hits. |
|
const int RefreshTimeBudgetMs = 10; |
|
|
|
readonly IReadOnlyList<LoadedAssembly> assemblies; |
|
readonly SearchMode mode; |
|
readonly string searchTerm; |
|
readonly Language language; |
|
readonly ApiVisibility apiVisibility; |
|
readonly ISearchResultFactory resultFactory; |
|
readonly ObservableCollection<SearchResult> sink; |
|
readonly ConcurrentQueue<SearchResult> queue = new(); |
|
readonly CancellationTokenSource cts = new(); |
|
DispatcherTimer? drainTimer; |
|
Task? runTask; |
|
volatile bool externalCancel; |
|
bool capReached; |
|
bool completedRaised; |
|
|
|
public RunningSearch( |
|
IReadOnlyList<LoadedAssembly> assemblies, |
|
string searchTerm, |
|
SearchMode mode, |
|
Language language, |
|
ApiVisibility apiVisibility, |
|
ISearchResultFactory resultFactory, |
|
ObservableCollection<SearchResult> sink) |
|
{ |
|
this.assemblies = assemblies; |
|
this.searchTerm = searchTerm; |
|
this.mode = mode; |
|
this.language = language; |
|
this.apiVisibility = apiVisibility; |
|
this.resultFactory = resultFactory; |
|
this.sink = sink; |
|
} |
|
|
|
public bool IsCompleted => runTask is { IsCompleted: true }; |
|
|
|
/// <summary> |
|
/// Fires once on the UI thread when the run finishes (success, error, cap, or |
|
/// external cancel). The pane VM uses it to flip its IsSearching flag back. |
|
/// </summary> |
|
public event Action<RunningSearch>? Completed; |
|
|
|
public void Start() |
|
{ |
|
var token = cts.Token; |
|
runTask = Task.Run(() => RunSearch(token), token); |
|
// Per-frame UI-thread drain. Render priority puts it alongside layout/render |
|
// work rather than ahead of input. The timer ticks on the UI thread, so the |
|
// drain body never needs Dispatcher.UIThread.Post — one less hop per batch. |
|
drainTimer = new DispatcherTimer( |
|
TimeSpan.FromMilliseconds(RefreshIntervalMs), |
|
DispatcherPriority.Render, |
|
OnDrainTick); |
|
drainTimer.Start(); |
|
} |
|
|
|
public void Cancel() |
|
{ |
|
// externalCancel distinguishes "user typed a new char" from "cap was hit |
|
// internally". External cancel bails the drain immediately so we don't keep |
|
// inserting into a sink the VM has handed off to a fresh RunningSearch. |
|
externalCancel = true; |
|
cts.Cancel(); |
|
drainTimer?.Stop(); |
|
drainTimer = null; |
|
RaiseCompletedIfFirst(); |
|
} |
|
|
|
void RunSearch(CancellationToken ct) |
|
{ |
|
try |
|
{ |
|
var request = BuildRequest(); |
|
var strategy = GetStrategy(request); |
|
if (strategy == null) |
|
return; |
|
// Serial walk: per-assembly metadata walk is allocation-dominated, and 4 |
|
// parallel producers fighting for the ConcurrentQueue + the resulting UI |
|
// batching jitter end up slower than serial in practice. |
|
foreach (var assembly in assemblies) |
|
{ |
|
if (ct.IsCancellationRequested) |
|
break; |
|
MetadataFile? module; |
|
try |
|
{ |
|
// Block here — we're already on a worker thread (Task.Run) and |
|
// this matches the WPF call shape. ConfigureAwait in an async |
|
// state machine would just add overhead for the same effect. |
|
module = assembly.GetMetadataFileAsync().GetAwaiter().GetResult(); |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
throw; |
|
} |
|
catch |
|
{ |
|
// Load failure (broken DLL, missing reference, IO problem). Already |
|
// surfaced in the assembly tree via the AssemblyWarning icon — skip |
|
// this assembly and continue with the next. |
|
continue; |
|
} |
|
if (module == null) |
|
continue; |
|
try |
|
{ |
|
strategy.Search(module, ct); |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
throw; |
|
} |
|
catch |
|
{ |
|
// One bad assembly's strategy walker (malformed metadata, missing |
|
// dependency, internal assert) — keep the run going instead of |
|
// faulting the whole search. |
|
} |
|
} |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
// Expected on user-driven term/mode change, or when the cap fires. |
|
} |
|
catch |
|
{ |
|
// Run-wide failure (parser, strategy construction). Treat as a no-result run. |
|
} |
|
} |
|
|
|
void OnDrainTick(object? sender, EventArgs e) |
|
{ |
|
// External cancel: the VM has cleared and re-used 'sink' for a new run. |
|
// Inserting now would contaminate someone else's results — bail. |
|
if (externalCancel) |
|
{ |
|
drainTimer?.Stop(); |
|
drainTimer = null; |
|
return; |
|
} |
|
|
|
// 10ms wall-clock budget per tick. While there's still budget left and |
|
// the visible cap hasn't been hit, drain as much of the queue as we can. |
|
// Stopwatch is high-resolution; Environment.TickCount has ~15ms grain |
|
// on Windows which is too coarse for a 10ms budget. |
|
var sw = Stopwatch.StartNew(); |
|
while (sink.Count < MaxResults && sw.ElapsedMilliseconds < RefreshTimeBudgetMs) |
|
{ |
|
if (!queue.TryDequeue(out var result)) |
|
break; |
|
// Sorted insert: results land in fitness order so the most relevant hit |
|
// rises to the top while later, less relevant matches are still streaming |
|
// in. ObservableCollection<T> implements IList<T>, so the InsertSorted |
|
// extension binary-searches and Inserts at the right index — O(log n) |
|
// compare + O(n) shift. |
|
sink.InsertSorted(result, SearchResult.ComparerByFitness); |
|
} |
|
|
|
if (sink.Count >= MaxResults) |
|
{ |
|
if (!capReached) |
|
{ |
|
capReached = true; |
|
// Stop the producer — anything it pushes from here would just be |
|
// discarded, so why burn CPU on it. |
|
cts.Cancel(); |
|
} |
|
// Drain-discard any items still queued: without this the termination |
|
// condition (runTask done + queue empty) never fires and the timer |
|
// spins forever after the cap is hit. |
|
while (queue.TryDequeue(out _)) |
|
{ } |
|
} |
|
|
|
if (runTask is { IsCompleted: true } && queue.IsEmpty) |
|
{ |
|
drainTimer?.Stop(); |
|
drainTimer = null; |
|
RaiseCompletedIfFirst(); |
|
} |
|
} |
|
|
|
void RaiseCompletedIfFirst() |
|
{ |
|
if (completedRaised) |
|
return; |
|
completedRaised = true; |
|
Completed?.Invoke(this); |
|
} |
|
|
|
SearchRequest BuildRequest() |
|
{ |
|
var request = ParseInput(searchTerm ?? string.Empty, mode); |
|
request.SearchResultFactory = resultFactory; |
|
// MemberSearchStrategy.Search resolves a type system via |
|
// module.GetTypeSystemWithDecompilerSettingsOrNull(request.DecompilerSettings); |
|
// passing null short-circuits to zero results. Default settings are fine for |
|
// search — we only need the type system to materialise. |
|
request.DecompilerSettings = new DecompilerSettings(); |
|
return request; |
|
} |
|
|
|
/// <summary> |
|
/// Parses the user's freeform query into a <see cref="SearchRequest"/>. Recognises |
|
/// the prefix DSL surfaced in the watermark: |
|
/// <list type="bullet"> |
|
/// <item><c>t:</c>, <c>tm:</c>, <c>m:</c>, <c>md:</c>, <c>f:</c>, <c>p:</c>, <c>e:</c>, |
|
/// <c>c:</c>, <c>n:</c>, <c>r:</c> — set <see cref="SearchRequest.Mode"/>.</item> |
|
/// <item><c>a:</c>, <c>af:</c>, <c>an:</c> — assembly mode + <see cref="AssemblySearchKind"/>.</item> |
|
/// <item><c>@</c> (single character) — metadata-token mode.</item> |
|
/// <item><c>inassembly:</c>, <c>innamespace:</c> — scope filters.</item> |
|
/// <item><c>/.../</c> — regex match via <see cref="SearchRequest.RegEx"/>.</item> |
|
/// <item>Double-quoted tokens — survive whitespace split, quotes stripped from the term.</item> |
|
/// </list> |
|
/// Auto-sets <see cref="SearchRequest.FullNameSearch"/> when any keyword has a dot, |
|
/// and <see cref="SearchRequest.OmitGenerics"/> when any keyword lacks generic angles |
|
/// and IL-arity backtick. The match operators <c>=</c> / <c>+</c> / <c>-</c> / <c>~</c> |
|
/// are NOT consumed here — they live on as keyword prefixes for |
|
/// <see cref="AbstractSearchStrategy.IsMatch"/> to interpret per term. |
|
/// </summary> |
|
internal static SearchRequest ParseInput(string input, SearchMode defaultMode) |
|
{ |
|
var request = new SearchRequest { Mode = defaultMode }; |
|
var keywords = new List<string>(); |
|
Regex? regex = null; |
|
foreach (var part in TokenizeQuoted(input)) |
|
{ |
|
string? prefix; |
|
string term; |
|
if (part.StartsWith("@", StringComparison.Ordinal)) |
|
{ |
|
prefix = "@"; |
|
term = part.Substring(1); |
|
} |
|
else |
|
{ |
|
// Find the colon that opens a prefix. It has to come before any quote |
|
// or regex slash — otherwise it's a colon inside the term itself. |
|
int quoteOrSlash = part.IndexOfAny(new[] { '"', '/' }); |
|
int searchEnd = quoteOrSlash < 0 ? part.Length : quoteOrSlash; |
|
int colon = part.IndexOf(':', 0, searchEnd); |
|
if (colon > 0) |
|
{ |
|
prefix = part.Substring(0, colon); |
|
term = part.Substring(colon + 1); |
|
} |
|
else |
|
{ |
|
prefix = null; |
|
term = part; |
|
} |
|
} |
|
|
|
// Strip surrounding double quotes from the term, if present. |
|
if (term.Length >= 2 && term[0] == '"' && term[^1] == '"') |
|
term = term.Substring(1, term.Length - 2); |
|
|
|
// If the prefix was followed by nothing useful, drop it and treat the whole |
|
// part as a keyword instead. |
|
if (prefix != null && term.Length == 0) |
|
{ |
|
prefix = null; |
|
term = part; |
|
} |
|
|
|
// Term goes into keywords/regex regardless of prefix — the prefix only flips |
|
// mode/scope, the keyword is still what gets matched. |
|
if (regex == null && term.StartsWith("/", StringComparison.Ordinal) && term.Length > 1) |
|
{ |
|
// Wrapped /regex/ form. Closing slash is optional; if present, strip it. |
|
var innerEnd = term.EndsWith("/", StringComparison.Ordinal) && term.Length > 2 |
|
? term.Length - 1 |
|
: term.Length; |
|
var inner = term.Substring(1, innerEnd - 1); |
|
if (inner.Contains("\\.")) |
|
request.FullNameSearch = true; |
|
regex = TryCreateRegex(inner); |
|
} |
|
else |
|
{ |
|
if (term.Contains('.')) |
|
request.FullNameSearch = true; |
|
keywords.Add(term); |
|
} |
|
if (!(term.Contains('<') || term.Contains('`'))) |
|
request.OmitGenerics = true; |
|
// Angle brackets are characteristic of compiler-generated names |
|
// (`<X>d__N`, `<>c__DisplayClass`, etc.) and rare in everyday API |
|
// names — treat them as the user opting in to private / |
|
// compiler-generated entities even when the global visibility |
|
// setting is PublicOnly. |
|
if (term.Contains('<') || term.Contains('>')) |
|
request.IncludePrivateApi = true; |
|
|
|
switch (prefix?.ToUpperInvariant()) |
|
{ |
|
case "@": |
|
request.Mode = SearchMode.Token; |
|
break; |
|
case "INNAMESPACE": |
|
if (request.InNamespace == null) |
|
request.InNamespace = term; |
|
break; |
|
case "INASSEMBLY": |
|
if (request.InAssembly == null) |
|
request.InAssembly = term; |
|
break; |
|
case "A": |
|
request.AssemblySearchKind = AssemblySearchKind.NameOrFileName; |
|
request.Mode = SearchMode.Assembly; |
|
break; |
|
case "AF": |
|
request.AssemblySearchKind = AssemblySearchKind.FilePath; |
|
request.Mode = SearchMode.Assembly; |
|
break; |
|
case "AN": |
|
request.AssemblySearchKind = AssemblySearchKind.FullName; |
|
request.Mode = SearchMode.Assembly; |
|
break; |
|
case "N": |
|
request.Mode = SearchMode.Namespace; |
|
break; |
|
case "TM": |
|
request.Mode = SearchMode.TypeAndMember; |
|
break; |
|
case "T": |
|
request.Mode = SearchMode.Type; |
|
break; |
|
case "M": |
|
request.Mode = SearchMode.Member; |
|
break; |
|
case "MD": |
|
request.Mode = SearchMode.Method; |
|
break; |
|
case "F": |
|
request.Mode = SearchMode.Field; |
|
break; |
|
case "P": |
|
request.Mode = SearchMode.Property; |
|
break; |
|
case "E": |
|
request.Mode = SearchMode.Event; |
|
break; |
|
case "C": |
|
request.Mode = SearchMode.Literal; |
|
break; |
|
case "R": |
|
request.Mode = SearchMode.Resource; |
|
break; |
|
} |
|
} |
|
request.Keywords = keywords.ToArray(); |
|
request.RegEx = regex; |
|
return request; |
|
} |
|
|
|
static Regex? TryCreateRegex(string pattern) |
|
{ |
|
try |
|
{ |
|
return new Regex(pattern, RegexOptions.Compiled); |
|
} |
|
catch (ArgumentException) |
|
{ |
|
return null; |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Splits the input on whitespace, treating double-quoted spans as a single token |
|
/// (so <c>"hello world"</c> stays together). Quotes inside the token are kept so the |
|
/// prefix-detection step can still tell a colon-inside-quotes (<c>t:"a:b"</c>) from |
|
/// a prefix-opening colon. |
|
/// </summary> |
|
static IEnumerable<string> TokenizeQuoted(string input) |
|
{ |
|
var sb = new System.Text.StringBuilder(); |
|
bool inQuotes = false; |
|
foreach (var c in input) |
|
{ |
|
if (c == '"') |
|
{ |
|
inQuotes = !inQuotes; |
|
sb.Append(c); |
|
} |
|
else if (!inQuotes && char.IsWhiteSpace(c)) |
|
{ |
|
if (sb.Length > 0) |
|
{ |
|
yield return sb.ToString(); |
|
sb.Clear(); |
|
} |
|
} |
|
else |
|
{ |
|
sb.Append(c); |
|
} |
|
} |
|
if (sb.Length > 0) |
|
yield return sb.ToString(); |
|
} |
|
|
|
AbstractSearchStrategy? GetStrategy(SearchRequest request) |
|
{ |
|
if (request.Keywords.Length == 0 && request.RegEx is null) |
|
return null; |
|
return request.Mode switch { |
|
SearchMode.TypeAndMember => new MemberSearchStrategy(language, apiVisibility, request, queue), |
|
SearchMode.Type => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Type), |
|
SearchMode.Member => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Member), |
|
SearchMode.Method => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Method), |
|
SearchMode.Field => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Field), |
|
SearchMode.Property => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Property), |
|
SearchMode.Event => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Event), |
|
SearchMode.Literal => new LiteralSearchStrategy(language, apiVisibility, request, queue), |
|
SearchMode.Token => new MetadataTokenSearchStrategy(language, apiVisibility, request, queue), |
|
SearchMode.Assembly => new AssemblySearchStrategy(request, queue, request.AssemblySearchKind), |
|
SearchMode.Namespace => new NamespaceSearchStrategy(request, queue), |
|
// Resource search needs ITreeNodeFactory infrastructure — deferred to a follow-up. |
|
_ => null, |
|
}; |
|
} |
|
} |
|
}
|
|
|