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,