Browse Source

Search-prefix DSL: t:, m:, /regex/, @token, quoted terms

The pane's watermark advertises a prefix DSL that the parser didn't
implement — only inassembly: and innamespace: were honoured. Typing
"t:String" treated the whole thing as a literal keyword and matched
nothing useful.
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
99a956c10c
  1. 226
      ILSpy.Tests/Search/SearchPrefixParsingTests.cs
  2. 236
      ILSpy/Search/RunningSearch.cs

226
ILSpy.Tests/Search/SearchPrefixParsingTests.cs

@ -0,0 +1,226 @@ @@ -0,0 +1,226 @@
// 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 AwesomeAssertions;
using ICSharpCode.ILSpyX.Search;
using ILSpy.Search;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
/// <summary>
/// Unit tests for the prefix-DSL parser exposed by <see cref="RunningSearch.ParseInput"/>.
/// The parser is the data-input side of the search pipeline — it converts the user's
/// freeform query string into a <see cref="SearchRequest"/> whose Mode / MemberSearchKind /
/// InAssembly / InNamespace / Keywords / RegEx / FullNameSearch / OmitGenerics drive the
/// strategy. Tests pin the prefixes advertised by the SearchPane watermark.
/// </summary>
[TestFixture]
public class SearchPrefixParsingTests
{
static SearchRequest Parse(string input, SearchMode defaultMode = SearchMode.TypeAndMember)
=> RunningSearch.ParseInput(input, defaultMode);
[Test]
public void Plain_Term_Falls_Through_To_The_Picker_Mode()
{
var r = Parse("Enumerable");
r.Mode.Should().Be(SearchMode.TypeAndMember);
r.Keywords.Should().BeEquivalentTo("Enumerable");
}
[Test]
public void T_Prefix_Switches_To_Type_Mode()
{
var r = Parse("t:String");
r.Mode.Should().Be(SearchMode.Type);
r.Keywords.Should().BeEquivalentTo("String");
}
[Test]
public void M_Prefix_Switches_To_Member_Mode()
{
var r = Parse("m:ToString");
r.Mode.Should().Be(SearchMode.Member);
r.Keywords.Should().BeEquivalentTo("ToString");
}
[Test]
public void Md_Prefix_Switches_To_Method_Mode()
{
var r = Parse("md:Run");
r.Mode.Should().Be(SearchMode.Method);
r.Keywords.Should().BeEquivalentTo("Run");
}
[Test]
public void F_Prefix_Switches_To_Field_Mode()
{
var r = Parse("f:value");
r.Mode.Should().Be(SearchMode.Field);
r.Keywords.Should().BeEquivalentTo("value");
}
[Test]
public void P_Prefix_Switches_To_Property_Mode()
{
var r = Parse("p:Length");
r.Mode.Should().Be(SearchMode.Property);
r.Keywords.Should().BeEquivalentTo("Length");
}
[Test]
public void E_Prefix_Switches_To_Event_Mode()
{
var r = Parse("e:Click");
r.Mode.Should().Be(SearchMode.Event);
r.Keywords.Should().BeEquivalentTo("Click");
}
[Test]
public void C_Prefix_Switches_To_Literal_Mode()
{
var r = Parse("c:42");
r.Mode.Should().Be(SearchMode.Literal);
r.Keywords.Should().BeEquivalentTo("42");
}
[Test]
public void N_Prefix_Switches_To_Namespace_Mode()
{
var r = Parse("n:System");
r.Mode.Should().Be(SearchMode.Namespace);
r.Keywords.Should().BeEquivalentTo("System");
}
[Test]
public void A_Prefix_Switches_To_Assembly_Mode_With_NameOrFileName_Kind()
{
var r = Parse("a:mscorlib");
r.Mode.Should().Be(SearchMode.Assembly);
r.AssemblySearchKind.Should().Be(AssemblySearchKind.NameOrFileName);
r.Keywords.Should().BeEquivalentTo("mscorlib");
}
[Test]
public void Af_Prefix_Switches_To_Assembly_Mode_With_FilePath_Kind()
{
var r = Parse("af:foo.dll");
r.Mode.Should().Be(SearchMode.Assembly);
r.AssemblySearchKind.Should().Be(AssemblySearchKind.FilePath);
r.Keywords.Should().BeEquivalentTo("foo.dll");
}
[Test]
public void An_Prefix_Switches_To_Assembly_Mode_With_FullName_Kind()
{
var r = Parse("an:System.Linq");
r.Mode.Should().Be(SearchMode.Assembly);
r.AssemblySearchKind.Should().Be(AssemblySearchKind.FullName);
r.Keywords.Should().BeEquivalentTo("System.Linq");
}
[Test]
public void At_Prefix_Switches_To_Token_Mode()
{
// @ token prefix is special: single character, no colon.
var r = Parse("@0x06000001");
r.Mode.Should().Be(SearchMode.Token);
r.Keywords.Should().BeEquivalentTo("0x06000001");
}
[Test]
public void InAssembly_Prefix_Sets_The_Filter()
{
var r = Parse("inassembly:mscorlib Foo");
r.InAssembly.Should().Be("mscorlib");
r.Keywords.Should().BeEquivalentTo("mscorlib", "Foo");
}
[Test]
public void InNamespace_Prefix_Sets_The_Filter()
{
var r = Parse("innamespace:System.Linq Enumerable");
r.InNamespace.Should().Be("System.Linq");
r.Keywords.Should().BeEquivalentTo("System.Linq", "Enumerable");
}
[Test]
public void Regex_Term_Wrapped_In_Slashes_Populates_RegEx()
{
var r = Parse("/^Enumerable$/");
r.RegEx.Should().NotBeNull();
r.RegEx!.IsMatch("Enumerable").Should().BeTrue();
r.RegEx.IsMatch("EnumerableExtensions").Should().BeFalse();
}
[Test]
public void FullNameSearch_Enabled_When_Term_Contains_Dot()
{
// A term like System.Linq.Enumerable signals the user wants a full-name match.
var r = Parse("System.Linq.Enumerable");
r.FullNameSearch.Should().BeTrue();
}
[Test]
public void FullNameSearch_Off_For_Plain_Identifier()
{
var r = Parse("Enumerable");
r.FullNameSearch.Should().BeFalse();
}
[Test]
public void OmitGenerics_True_When_Term_Has_No_Angle_Or_Backtick()
{
var r = Parse("List");
r.OmitGenerics.Should().BeTrue();
}
[Test]
public void OmitGenerics_False_When_Term_Has_Generic_Angle()
{
var r = Parse("List<int>");
r.OmitGenerics.Should().BeFalse();
}
[Test]
public void Empty_Input_Yields_Zero_Keywords()
{
var r = Parse(" ");
r.Keywords.Should().BeEmpty();
}
[Test]
public void Quoted_Term_Survives_Whitespace_Split()
{
var r = Parse("\"hello world\"");
r.Keywords.Should().BeEquivalentTo("hello world");
}
[Test]
public void Match_Operators_Are_Passed_Through_To_Keywords()
{
// =, +, -, ~ are per-keyword prefixes interpreted by AbstractSearchStrategy.IsMatch
// — the parser only has to deliver them in the keyword string untouched.
var r = Parse("=Foo +Bar -Baz");
r.Keywords.Should().BeEquivalentTo("=Foo", "+Bar", "-Baz");
}
}

236
ILSpy/Search/RunningSearch.cs

@ -21,6 +21,7 @@ using System.Collections.Concurrent; @@ -21,6 +21,7 @@ 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;
@ -242,49 +243,214 @@ namespace ILSpy.Search @@ -242,49 +243,214 @@ namespace ILSpy.Search
SearchRequest BuildRequest()
{
// Lightweight parser: split on whitespace, pull off the two scope prefixes
// (inassembly: / innamespace:) when they appear. The full WPF prefix DSL
// (/regex/, =exact, ~fuzzy, t: / m:, @token, …) is out of scope; plain
// keyword + scope-to context-menu integration covers the common cases.
var tokens = (searchTerm ?? string.Empty)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var keywords = new List<string>(tokens.Length);
string? inAssembly = null;
string? inNamespace = null;
foreach (var token in tokens)
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;
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)
{
if (token.StartsWith("inassembly:", StringComparison.OrdinalIgnoreCase))
inAssembly = token.Substring("inassembly:".Length).Trim('"');
else if (token.StartsWith("innamespace:", StringComparison.OrdinalIgnoreCase))
inNamespace = token.Substring("innamespace:".Length).Trim('"');
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
keywords.Add(token);
{
sb.Append(c);
}
}
return new SearchRequest {
Mode = mode,
Keywords = keywords.ToArray(),
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.
DecompilerSettings = new DecompilerSettings(),
FullNameSearch = false,
OmitGenerics = false,
// IsInNamespaceOrAssembly: null means "no filter, accept everything";
// the EMPTY STRING would restrict to the global namespace, matching almost
// nothing. The scope-to context-menu entries set these via the DSL prefixes
// parsed above.
InNamespace = inNamespace!,
InAssembly = inAssembly!,
};
if (sb.Length > 0)
yield return sb.ToString();
}
AbstractSearchStrategy? GetStrategy(SearchRequest request)
{
if (request.Keywords.Length == 0 && request.RegEx is null)
return null;
return mode switch {
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),
@ -294,7 +460,7 @@ namespace ILSpy.Search @@ -294,7 +460,7 @@ namespace ILSpy.Search
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.Assembly => new AssemblySearchStrategy(request, queue, request.AssemblySearchKind),
SearchMode.Namespace => new NamespaceSearchStrategy(request, queue),
// Resource search needs ITreeNodeFactory infrastructure — deferred to a follow-up.
_ => null,

Loading…
Cancel
Save