diff --git a/ILSpy.Tests/Search/SearchPrefixParsingTests.cs b/ILSpy.Tests/Search/SearchPrefixParsingTests.cs
new file mode 100644
index 000000000..0d4f29b83
--- /dev/null
+++ b/ILSpy.Tests/Search/SearchPrefixParsingTests.cs
@@ -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;
+
+///
+/// Unit tests for the prefix-DSL parser exposed by .
+/// The parser is the data-input side of the search pipeline — it converts the user's
+/// freeform query string into a whose Mode / MemberSearchKind /
+/// InAssembly / InNamespace / Keywords / RegEx / FullNameSearch / OmitGenerics drive the
+/// strategy. Tests pin the prefixes advertised by the SearchPane watermark.
+///
+[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");
+ 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");
+ }
+}
diff --git a/ILSpy/Search/RunningSearch.cs b/ILSpy/Search/RunningSearch.cs
index f3d445d61..641d9031f 100644
--- a/ILSpy/Search/RunningSearch.cs
+++ b/ILSpy/Search/RunningSearch.cs
@@ -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
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(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;
+ }
+
+ ///
+ /// Parses the user's freeform query into a . Recognises
+ /// the prefix DSL surfaced in the watermark:
+ ///
+ /// - t:, tm:, m:, md:, f:, p:, e:,
+ /// c:, n:, r: — set .
+ /// - a:, af:, an: — assembly mode + .
+ /// - @ (single character) — metadata-token mode.
+ /// - inassembly:, innamespace: — scope filters.
+ /// - /.../ — regex match via .
+ /// - Double-quoted tokens — survive whitespace split, quotes stripped from the term.
+ ///
+ /// Auto-sets when any keyword has a dot,
+ /// and when any keyword lacks generic angles
+ /// and IL-arity backtick. The match operators = / + / - / ~
+ /// are NOT consumed here — they live on as keyword prefixes for
+ /// to interpret per term.
+ ///
+ internal static SearchRequest ParseInput(string input, SearchMode defaultMode)
+ {
+ var request = new SearchRequest { Mode = defaultMode };
+ var keywords = new List();
+ 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;
+ }
+ }
+
+ ///
+ /// Splits the input on whitespace, treating double-quoted spans as a single token
+ /// (so "hello world" stays together). Quotes inside the token are kept so the
+ /// prefix-detection step can still tell a colon-inside-quotes (t:"a:b") from
+ /// a prefix-opening colon.
+ ///
+ static IEnumerable 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
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,