From fcdf0bf5cd39c1b1481f1ab7e327ebad3bbcff86 Mon Sep 17 00:00:00 2001 From: Keegan Carruthers-Smith Date: Wed, 24 Jun 2026 10:16:45 +0200 Subject: [PATCH 1/2] fix/query: preserve fields in parenthesized atoms Users reasonably expect wrapping a field query in parentheses to preserve its semantics, but the parser's regex/grouping ambiguity caused no-space forms like (sym:foo) to be treated as plain content text. Recognize field-looking parenthesized atoms as grouping so generated and hand-written field queries behave consistently while leaving bare regex grouping such as (foo) and (foo|bar) alone. Amp-Thread-ID: https://ampcode.com/threads/T-019ef884-75bf-736d-b3b7-e19a7855d882 Co-authored-by: Amp --- doc/design.md | 6 ++++++ doc/query_syntax.md | 3 +++ query/parse.go | 20 ++++++++++++++++++++ query/parse_test.go | 6 ++++++ 4 files changed, 35 insertions(+) diff --git a/doc/design.md b/doc/design.md index b4d09d778..c6f1a279d 100644 --- a/doc/design.md +++ b/doc/design.md @@ -244,6 +244,12 @@ interpreted as regular expressions, otherwise they are used for grouping (abc def) => and[substring:"abc" substring:"def"] (abc\ def) => regexp:"(abc def)" +Parenthesized field expressions are treated as grouping even without spaces, +so field modifiers still apply: + + (sym:foo) => Symbol:"foo" + (file:java) => Substring_file:"java" + there is an implicit "AND" between elements of a parenthesized list. There is an "OR" operator, which has lower priority than the implicit "AND": diff --git a/doc/query_syntax.md b/doc/query_syntax.md index 0a5d8d667..c11ee4b24 100644 --- a/doc/query_syntax.md +++ b/doc/query_syntax.md @@ -59,6 +59,9 @@ Negate an expression using the `-` symbol. ### 3. **Grouping** Group queries using parentheses `()` to create complex logic. +Parentheses around field expressions preserve the field semantics, so +`(sym:MyFunction)` is equivalent to `sym:MyFunction` rather than a content +search for the literal text `sym:MyFunction`. #### Examples: - Match either of two repositories: diff --git a/query/parse.go b/query/parse.go index 279506ec6..59bc692b8 100644 --- a/query/parse.go +++ b/query/parse.go @@ -109,6 +109,19 @@ func isSpace(c byte) bool { return c == ' ' || c == '\t' } +func startsQuerySyntax(in []byte) bool { + if len(in) > 0 && in[0] == '-' { + in = in[1:] + } + + for pref := range prefixes { + if bytes.HasPrefix(in, []byte(pref)) { + return true + } + } + return false +} + // Parse parses a string into a query. func Parse(qStr string) (Q, error) { b := []byte(qStr) @@ -565,6 +578,13 @@ func nextToken(in []byte) (*token, error) { Input: in[:1], }, nil } + if left[0] == '(' && startsQuerySyntax(left[1:]) { + return &token{ + Type: tokParenOpen, + Text: []byte{'('}, + Input: in[:1], + }, nil + } foundSpace := false diff --git a/query/parse_test.go b/query/parse_test.go index 2b71ccca3..878e182b1 100644 --- a/query/parse_test.go +++ b/query/parse_test.go @@ -95,9 +95,13 @@ func TestParseQuery(t *testing.T) { {"lang:c++", &Language{"C++"}}, {"lang:cpp", &Language{"C++"}}, {"sym:pqr", &Symbol{&Substring{Pattern: "pqr"}}}, + {"(sym:pqr)", &Symbol{&Substring{Pattern: "pqr"}}}, {"sym:Pqr", &Symbol{&Substring{Pattern: "Pqr", CaseSensitive: true}}}, {"sym:.*", &Symbol{&Regexp{Regexp: mustParseRE(".*")}}}, {"sym:a(b|d)e", &Symbol{&Regexp{Regexp: mustParseRE("a[bd]e")}}}, + {"(file:helpers\\.go)", &Substring{Pattern: "helpers.go", FileName: true}}, + {"(repo:go)", &Repo{regexp.MustCompile("go")}}, + {"-(file:helpers\\.go)", &Not{&Substring{Pattern: "helpers.go", FileName: true}}}, // case {"abc case:yes", &Substring{Pattern: "abc", CaseSensitive: true}}, @@ -192,6 +196,8 @@ func TestTokenize(t *testing.T) { {"file:bla", tokFile, "bla"}, {"file:bla ", tokFile, "bla"}, {"f:bla ", tokFile, "bla"}, + {"(file:bla)", tokParenOpen, "("}, + {"(sym:bla)", tokParenOpen, "("}, {"(abc def) ", tokParenOpen, "("}, {"(abcdef)", tokText, "(abcdef)"}, {"(abc)(de)", tokText, "(abc)(de)"}, From b67b3bd5aed02bfaa3cb6d847d99e395c4e3ff5f Mon Sep 17 00:00:00 2001 From: Keegan Smith Date: Wed, 26 Aug 2026 08:00:35 +0000 Subject: [PATCH 2/2] fix/query: preserve nested field grouping Parenthesized query syntax should not revert to content matching simply because callers add another grouping layer. Skip grouping and negation markers before checking field prefixes so nested and negated forms follow the same parser path. Amp-Thread-ID: https://ampcode.com/threads/T-01a03d0c-8a63-778e-8329-58af0957888e --- query/parse.go | 3 ++- query/parse_test.go | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/query/parse.go b/query/parse.go index 59bc692b8..ade99f832 100644 --- a/query/parse.go +++ b/query/parse.go @@ -110,7 +110,8 @@ func isSpace(c byte) bool { } func startsQuerySyntax(in []byte) bool { - if len(in) > 0 && in[0] == '-' { + // Skip grouping and negation tokens before the first atom. + for len(in) > 0 && (in[0] == '(' || in[0] == '-') { in = in[1:] } diff --git a/query/parse_test.go b/query/parse_test.go index 878e182b1..d4a446d10 100644 --- a/query/parse_test.go +++ b/query/parse_test.go @@ -96,6 +96,8 @@ func TestParseQuery(t *testing.T) { {"lang:cpp", &Language{"C++"}}, {"sym:pqr", &Symbol{&Substring{Pattern: "pqr"}}}, {"(sym:pqr)", &Symbol{&Substring{Pattern: "pqr"}}}, + {"((sym:pqr))", &Symbol{&Substring{Pattern: "pqr"}}}, + {"(-sym:pqr)", &Not{&Symbol{&Substring{Pattern: "pqr"}}}}, {"sym:Pqr", &Symbol{&Substring{Pattern: "Pqr", CaseSensitive: true}}}, {"sym:.*", &Symbol{&Regexp{Regexp: mustParseRE(".*")}}}, {"sym:a(b|d)e", &Symbol{&Regexp{Regexp: mustParseRE("a[bd]e")}}},