Browse Source

Stream search results through ILSpyX strategies

Wire the search pane to the shared ICSharpCode.ILSpyX.Search engine.
Setting SearchTerm or SelectedSearchMode now cancels any in-flight
run and starts a fresh one; cleared term tears the run down.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
3df99f31be
  1. 9
      ILSpy.Tests/Analyzers/Library/MemberImplementsInterfaceAnalyzerTests.cs
  2. 126
      ILSpy.Tests/Search/SearchPaneStreamingTests.cs
  3. 136
      ILSpy/Search/AvaloniaSearchResultFactory.cs
  4. 222
      ILSpy/Search/RunningSearch.cs
  5. 81
      ILSpy/Search/SearchPaneModel.cs

9
ILSpy.Tests/Analyzers/Library/MemberImplementsInterfaceAnalyzerTests.cs

@ -62,7 +62,10 @@ public class MemberImplementsInterfaceAnalyzerTests @@ -62,7 +62,10 @@ public class MemberImplementsInterfaceAnalyzerTests
public void VerifyDoesNotShowForNoSymbol()
{
var analyzer = new MemberImplementsInterfaceAnalyzer();
var shouldShow = analyzer.Show(symbol: null);
// IAnalyzer.Show declares its parameter non-nullable; the contract this test
// pins is "the implementation guards against null callers even though the
// signature says it can't happen". null-forgiving silences the CS8625.
var shouldShow = analyzer.Show(symbol: null!);
Assert.That(!shouldShow, "The analyzer will be unexpectedly shown for no symbol");
}
@ -141,6 +144,10 @@ public class MemberImplementsInterfaceAnalyzerTests @@ -141,6 +144,10 @@ public class MemberImplementsInterfaceAnalyzerTests
{
var memberMock = Substitute.For<IMember>();
memberMock.SymbolKind.Returns(symbolKind);
// NSubstitute's fluent path setup: Returns reads memberMock.DeclaringTypeDefinition
// as if it were a real reference, building the expectation in place. The compiler
// flags it as a possibly-null deref; the suppression is required for the mock to
// configure the chained property.
memberMock.DeclaringTypeDefinition!.Kind.Returns(typeKind);
memberMock.IsStatic.Returns(isStatic);
return memberMock;

126
ILSpy.Tests/Search/SearchPaneStreamingTests.cs

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
// 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.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.Search;
using ILSpy.AppEnv;
using ILSpy.Search;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
[TestFixture]
public class SearchPaneStreamingTests
{
[AvaloniaTest]
public async Task Diagnostic_Direct_Strategy_Invocation_Surfaces_Type_Results()
{
// Diagnostic: drive a MemberSearchStrategy directly to confirm the shared search
// logic works for the fixture. If this fails, the bug is in the strategy / fixture;
// if it passes but the orchestrator test below fails, the bug is in our wiring.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var coreLibName = typeof(object).Assembly.GetName().Name!;
var assemblyNode = vm.AssemblyTreeModel.FindNode<global::ILSpy.TreeNodes.AssemblyTreeNode>(coreLibName);
var module = await assemblyNode.LoadedAssembly.GetMetadataFileAsync();
var queue = new System.Collections.Concurrent.ConcurrentQueue<ICSharpCode.ILSpyX.Search.SearchResult>();
var language = AppComposition.Current.GetExport<global::ILSpy.Languages.LanguageService>().CurrentLanguage;
var request = new ICSharpCode.ILSpyX.Search.SearchRequest {
Mode = SearchMode.Type,
Keywords = new[] { "Enumerable" },
SearchResultFactory = new global::ILSpy.Search.AvaloniaSearchResultFactory(language),
DecompilerSettings = new ICSharpCode.Decompiler.DecompilerSettings(),
FullNameSearch = false,
OmitGenerics = false,
InNamespace = null!,
InAssembly = null!,
};
var strategy = new ICSharpCode.ILSpyX.Search.MemberSearchStrategy(
language, ICSharpCode.ILSpyX.ApiVisibility.All, request, queue,
ICSharpCode.ILSpyX.Search.MemberSearchKind.Type);
strategy.Search(module, default);
queue.Count.Should().BeGreaterThan(0,
"if the strategy can't find Enumerable in the fixture, the orchestrator certainly won't either");
}
[AvaloniaTest]
public async Task Typing_A_Term_Surfaces_Matching_Type_Results_From_The_Loaded_AssemblyList()
{
// End-to-end: set SearchTerm to "Enumerable", let the orchestrator run a TypeAndMember
// search across the loaded fixture assemblies, and wait for at least one matching
// result to land in the Results collection.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
search.Results.Clear();
search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type);
search.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(
() => search.Results.Any(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30));
search.Results.Should().NotBeEmpty(
"the Type search strategy must have surfaced at least one match within the timeout");
search.Results.Should().Contain(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase));
}
[AvaloniaTest]
public async Task Clearing_The_Search_Term_Empties_The_Result_List()
{
// Switching to an empty term tears down the running search and clears the visible
// results. The user shouldn't see stale rows after they've explicitly cleared the box.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
search.Results.Clear();
search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type);
search.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => search.Results.Count > 0, timeout: TimeSpan.FromSeconds(30));
search.SearchTerm = string.Empty;
await Waiters.WaitForAsync(() => search.Results.Count == 0, timeout: TimeSpan.FromSeconds(5));
search.Results.Should().BeEmpty("an empty search term must clear stale results");
}
}

136
ILSpy/Search/AvaloniaSearchResultFactory.cs

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
// 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 ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX.Abstractions;
using ICSharpCode.ILSpyX.Search;
using ILSpy.Languages;
namespace ILSpy.Search
{
/// <summary>
/// Builds the <see cref="SearchResult"/> objects the search strategies stream into the
/// pane's result queue. Mirrors WPF's <c>SearchResultFactory</c>: fitness ranking
/// privileges short names (shorter == higher fitness == higher rank); compiler-generated
/// names (those starting with <c>&lt;</c>) get fitness 0 so they sink to the bottom.
/// </summary>
internal sealed class AvaloniaSearchResultFactory : ISearchResultFactory
{
readonly Language language;
public AvaloniaSearchResultFactory(Language language)
{
this.language = language;
}
public MemberSearchResult Create(IEntity entity)
{
var declaringType = entity.DeclaringTypeDefinition;
return new MemberSearchResult {
Member = entity,
Fitness = CalculateFitness(entity),
Name = GetLanguageSpecificName(entity),
Location = declaringType != null
? language.TypeToString(declaringType, ConversionFlags.UseFullyQualifiedEntityNames | ConversionFlags.UseFullyQualifiedTypeNames)
: entity.Namespace ?? string.Empty,
Assembly = entity.ParentModule?.FullAssemblyName ?? string.Empty,
ToolTip = entity.ParentModule?.MetadataFile?.FileName,
Image = GetIcon(entity),
LocationImage = declaringType != null ? Images.Images.Class : Images.Images.Namespace,
AssemblyImage = Images.Images.Assembly,
};
}
public ResourceSearchResult Create(MetadataFile module, Resource resource, ITreeNode node, ITreeNode parent)
{
return new ResourceSearchResult {
Resource = resource,
Fitness = 1.0f / Math.Max(1, resource.Name.Length),
Image = Images.Images.Library,
Name = resource.Name,
LocationImage = Images.Images.Library,
Location = (parent.Text as string) ?? string.Empty,
Assembly = module.FullName,
ToolTip = module.FileName,
AssemblyImage = Images.Images.Assembly,
};
}
public AssemblySearchResult Create(MetadataFile module)
{
return new AssemblySearchResult {
Module = module,
Fitness = 1.0f / Math.Max(1, module.Name.Length),
Name = module.Name,
Location = module.FileName,
Assembly = module.FullName,
ToolTip = module.FileName,
Image = Images.Images.Assembly,
LocationImage = Images.Images.Library,
AssemblyImage = Images.Images.Assembly,
};
}
public NamespaceSearchResult Create(MetadataFile module, INamespace ns)
{
var name = ns.FullName.Length == 0 ? "-" : ns.FullName;
return new NamespaceSearchResult {
Namespace = ns,
Name = name,
Fitness = 1.0f / Math.Max(1, name.Length),
Location = module.Name,
Assembly = module.FullName,
Image = Images.Images.Namespace,
LocationImage = Images.Images.Assembly,
AssemblyImage = Images.Images.Assembly,
};
}
static float CalculateFitness(IEntity member)
{
var text = member.Name;
if (text.StartsWith('<'))
return 0;
if (member.SymbolKind is SymbolKind.Constructor or SymbolKind.Destructor)
text = member.DeclaringType?.Name ?? text;
text = ReflectionHelper.SplitTypeParameterCountFromReflectionName(text);
return 1.0f / Math.Max(1, text.Length);
}
string GetLanguageSpecificName(IEntity member) => member switch {
ITypeDefinition t => language.TypeToString(t, ConversionFlags.None),
IField or IProperty or IMethod or IEvent => language.EntityToString(member, ConversionFlags.ShowDeclaringType),
_ => member.Name,
};
static object GetIcon(IEntity member) => member switch {
ITypeDefinition => Images.Images.Class,
IField => Images.Images.Field,
IProperty => Images.Images.Property,
IMethod => Images.Images.Method,
IEvent => Images.Images.Event,
_ => Images.Images.Library,
};
}
}

222
ILSpy/Search/RunningSearch.cs

@ -0,0 +1,222 @@ @@ -0,0 +1,222 @@
// 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.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.Search;
using ILSpy.Languages;
namespace ILSpy.Search
{
/// <summary>
/// Orchestrates one search across the loaded <see cref="AssemblyList"/>. Builds a
/// <see cref="SearchRequest"/>, picks the matching <see cref="AbstractSearchStrategy"/>,
/// runs it on a background task, and streams each emitted <see cref="SearchResult"/>
/// into the supplied UI-thread <see cref="ObservableCollection{T}"/> via
/// <see cref="Dispatcher.UIThread"/>.
/// </summary>
internal sealed class RunningSearch
{
const int MaxResults = 1000;
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();
Task? runTask;
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 };
public void Start()
{
var token = cts.Token;
// Task.Run with an async lambda wraps the inner Task so runTask only completes
// after RunSearchAsync actually finishes — without the wrapping, runTask would
// complete the moment Task.Run returned the inner Task to the caller, and the
// drain loop would exit before any results landed.
runTask = Task.Run(async () => await RunSearchAsync(token).ConfigureAwait(false), token);
_ = DrainQueueAsync(token);
}
public void Cancel()
{
cts.Cancel();
}
async Task RunSearchAsync(CancellationToken ct)
{
try
{
var request = BuildRequest();
var strategy = GetStrategy(request);
if (strategy == null)
return;
foreach (var assembly in assemblies)
{
ct.ThrowIfCancellationRequested();
// Force the load so search hits the metadata even for assemblies the user
// hasn't expanded in the tree yet. Failed loads are already surfaced in
// the assembly tree with the AssemblyWarning icon — don't double-report
// here, just skip so the search keeps streaming results from the
// healthy assemblies.
MetadataFile? module;
try
{
module = await assembly.GetMetadataFileAsync().ConfigureAwait(false);
}
catch (Exception ex) when (IsExpectedLoadFailure(ex))
{
continue;
}
if (module == null)
continue;
strategy.Search(module, ct);
}
}
catch (OperationCanceledException)
{
// Expected on user-driven term/mode change.
}
}
static bool IsExpectedLoadFailure(Exception ex) => ex is
BadImageFormatException
or IOException
or InvalidOperationException
or UnauthorizedAccessException
or NotSupportedException
or ReflectionTypeLoadException;
SearchRequest BuildRequest()
{
// Minimal keyword parser: split on whitespace. The WPF pane supports a richer
// prefix DSL (inassembly:, t:, /regex/, =exact, ~fuzzy, …) — that lands as a
// follow-up. Plain-keyword search covers the common case end-to-end.
var keywords = (searchTerm ?? string.Empty)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new SearchRequest {
Mode = mode,
Keywords = keywords,
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, not specific
// decompiler behaviour.
DecompilerSettings = new DecompilerSettings(),
FullNameSearch = false,
OmitGenerics = false,
// IsInNamespaceOrAssembly treats null as "no filter, accept everything";
// the EMPTY STRING would restrict to the global namespace (Namespace.Length
// == 0), which matches almost nothing. The DSL prefixes that flip these on
// (innamespace:, inassembly:) are out of scope for the minimal parser.
InNamespace = null!,
InAssembly = null!,
};
}
AbstractSearchStrategy? GetStrategy(SearchRequest request)
{
if (request.Keywords.Length == 0 && request.RegEx is null)
return null;
return 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, AssemblySearchKind.NameOrFileName),
SearchMode.Namespace => new NamespaceSearchStrategy(request, queue),
// Resource search needs ITreeNodeFactory infrastructure — deferred to a follow-up.
_ => null,
};
}
async Task DrainQueueAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var batch = new List<SearchResult>();
while (queue.TryDequeue(out var result) && batch.Count < 100)
batch.Add(result);
if (batch.Count > 0)
{
Dispatcher.UIThread.Post(() => {
foreach (var r in batch)
{
if (sink.Count >= MaxResults)
break;
sink.Add(r);
}
});
}
if (runTask is { IsCompleted: true } && queue.IsEmpty)
return;
await Task.Delay(50, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Expected when the run is replaced or cleared.
}
}
}
}

81
ILSpy/Search/SearchPaneModel.cs

@ -21,9 +21,13 @@ using System.Composition; @@ -21,9 +21,13 @@ using System.Composition;
using CommunityToolkit.Mvvm.ComponentModel;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.Search;
using ILSpy.AppEnv;
using ILSpy.AssemblyTree;
using ILSpy.Commands;
using ILSpy.Languages;
using ILSpy.ViewModels;
namespace ILSpy.Search
@ -51,6 +55,13 @@ namespace ILSpy.Search @@ -51,6 +55,13 @@ namespace ILSpy.Search
Id = PaneContentId;
Title = "Search";
SelectedSearchMode = SearchModes[0];
PropertyChanged += OnPropertyChangedDispatch;
}
void OnPropertyChangedDispatch(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SearchTerm) or nameof(SelectedSearchMode))
RestartSearch();
}
/// <summary>
@ -94,5 +105,75 @@ namespace ILSpy.Search @@ -94,5 +105,75 @@ namespace ILSpy.Search
/// <c>Dispatcher.UIThread.Post</c> as the strategies emit them.
/// </summary>
public ObservableCollection<SearchResult> Results { get; } = new();
RunningSearch? currentSearch;
void RestartSearch()
{
currentSearch?.Cancel();
currentSearch = null;
Results.Clear();
var term = SearchTerm;
if (string.IsNullOrWhiteSpace(term))
return;
var assemblyTreeModel = TryGetAssemblyTreeModel();
var assemblyList = assemblyTreeModel?.AssemblyList;
if (assemblyList == null)
return;
var language = TryGetLanguage();
if (language == null)
return;
var apiVisibility = TryGetSettings()?.SessionSettings?.LanguageSettings?.ShowApiLevel
?? ApiVisibility.PublicOnly;
var factory = new AvaloniaSearchResultFactory(language);
currentSearch = new RunningSearch(
assemblyList.GetAssemblies(),
term,
SelectedSearchMode.Mode,
language,
apiVisibility,
factory,
Results);
currentSearch.Start();
}
static AssemblyTreeModel? TryGetAssemblyTreeModel()
{
try
{
return AppComposition.Current.GetExport<AssemblyTreeModel>();
}
catch
{
return null;
}
}
static Language? TryGetLanguage()
{
try
{
return AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
}
catch
{
return null;
}
}
static SettingsService? TryGetSettings()
{
try
{
return AppComposition.Current.GetExport<SettingsService>();
}
catch
{
return null;
}
}
}
}

Loading…
Cancel
Save