All findings · Lucene finding · LFV-747 · flexible-interval-term-literals-keep-escape-backslashes

StandardQueryParser keeps escape backslashes in fn: interval term literals

StandardQueryParser reads a\:b as the term a:b, but inside an interval function it keeps the backslash: fn:or(a\:b) searches for the text a\:b. The query silently misses the a:b document and can hit an a\:b document instead, in every release since 9.1.0.

Summary

Priority
Low, rank 380Needs an escaped character inside a term literal of the flexible parser's niche fn: interval syntax.
Impact
silent wrong results
Since
9.1.0 (LUCENE-10223, #429)introduced 2021-11-11 · about 4 years
Affected
9.1.0 to 10.5.2 and main
Status
documented
Found by
semi-formal: an exhaustive reference for fn: parsing

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The flexible parser removes escape backslashes from every term, except from the term literals of fn: interval functions.

Summary diagram in four panels. 1, the example: parser.parse("fn:or(a\\:b)", "body") with a whitespace analyzer; index d0 = a:b c, d1 = a\:b c; expected body:a:b, finding d0; actual body:a\:b, finding d1: it misses the a:b document and matches the backslash text. 2, inside the parser: the lexer makes one TERM token a \ : b, because \: is an escaped character (jj line 130); outside fn: the Term rule (line 804) calls discardEscapeChar and gives a:b; inside fn: the IntervalText rule (line 685) calls new AnalyzedText(token.image) and keeps the backslash; AnalyzedText hands a\:b to the analyzer, and fn:fuzzyTerm (line 668) takes its term the same way. 3, the same bug in other fn: forms, executed on main: fn:ordered(a\:b c) gives ORDERED(a\:b,c), also when nested; fn:fuzzyTerm(a\:b 0) gives MultiTerm(a\:b~0); fn:or("a\"b") gives a\"b, so no fn: term literal can hold a double quote; with StandardAnalyzer fn:or(a\:b) gives BLOCK(a,b) and finds a b c. Not affected: quoted literals without escapes, fn:wildcard, and terms outside fn:. 4, fix, scope, impact: discardEscapeChar on the 4 token images of IntervalFuzzyTerm and IntervalText (lines 668-669, 684-685), as Term and QuotedTerm do; every release from 9.1.0 to 10.5.2 and main, executed on 20 releases and main; no error, silently wrong hits; also reached by escaped user text: QueryParserUtil.escape("a:b") returns a\:b.
Left: the same escaped word finds the a:b document outside fn: and the a\:b document inside it. Right: the lexer makes one token a\:b; the term rule removes the backslash, the fn: literal rule keeps it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index two documents with a whitespace analyzer: doc 0 holds a:b c, doc 1 holds a\:b c (with a real backslash).
  2. Search a\:b with StandardQueryParser. The backslash escapes the colon, as the parser documents ("any character can be quoted with a backslash"): the query is body:a:b and it finds doc 0.
  3. Put the same word inside an interval function: fn:or(a\:b). You expect the same term a:b and doc 0.
  4. Instead the query is body:a\:b and finds doc 1. The two grammar rules that read fn: literals pass the raw token on and never remove the backslash, unlike Term, QuotedTerm and the other rules that produce plain term text.
  5. No error. All 10 fn: forms tried behave the same (fn:ordered, nested functions, fn:fuzzyTerm); with StandardAnalyzer, fn:or(a\:b) becomes the phrase a b. Quoting (fn:or("a:b")) avoids it, but no fn: term literal can hold a double quote.

Who is affected

Applications that let users, or the helper QueryParserUtil.escape, put an escaped character into a term inside fn:. No configuration needed: the fn: syntax is always on. With whitespace or keyword analyzers every escaped character gives wrong hits; with StandardAnalyzer mainly an escaped colon. One query is enough, and nothing reports an error.

Mechanism, terms and numbers481 words

The flexible StandardQueryParser documents that "any character can be quoted with a backslash", and outside interval functions it removes the backslash: a\:b is the term a:b. Inside fn: it does not. The two grammar productions that read an fn: term literal, IntervalText (every literal under fn:or, fn:ordered and the other composite functions, at any depth) and IntervalFuzzyTerm (fn:fuzzyTerm), pass the raw token text to AnalyzedText and FuzzyTerm without discardEscapeChar, which every other production that yields plain term text calls. So fn:or(a\:b) searches for the text a\:b: with a whitespace analyzer it misses the document a:b c and finds a\:b c; with StandardAnalyzer it becomes the phrase BLOCK(a,b) and finds a b c. Nothing throws. The lexer already treats the backslash as an escape inside fn:; the parser just never removes it. Quoting is a workaround except for a double quote, which no fn: term literal can express (only fn:wildcard(a\"b) reaches it, as one unanalyzed term). Every release since 9.1.0, when fn: arrived, is affected; four discardEscapeChar calls fix it.

Terms

  • Escaping: in StandardQueryParser query text, a backslash makes the next character literal: a\:b is the term a:b, not field a and term b (StandardQueryParser.java:175-186).
  • Interval functions: fn:or(...), fn:ordered(...), fn:fuzzyTerm(...) and 18 more build an IntervalQuery; their leaves are term literals, "converted into (analyzed) text intervals" (intervalfn/package-info.java:147-152).

The bug

  1. The lexer treats \x as an escape everywhere (StandardSyntaxParser.jj:129-136, :159-161); after fn: the ( switches it back to the default state (:147, :167-168), so a\:b inside fn: is one term token with the image a\:b. The escape protects the structure: fn:or(x\)) parses, fn:or(x)) does not.
  2. Every other production that yields plain term text removes the backslash with discardEscapeChar: Term (:803-804), QuotedTerm (:828-831), FuzzyOp (:727), the range productions and field names. The only others that keep it, Term's /regexp/ branch (:798-801) and fn:wildcard (:649-650), pass it to RegExp and WildcardQuery, which read it as an escape.
  3. IntervalFuzzyTerm (:668-669) and IntervalText (:684-685) do not: they hand token.image (or the quoted image minus its quotes) to FuzzyTerm and AnalyzedText (generated parser :980, :986, :1022, :1046).
  4. AnalyzedText passes the text to Intervals.analyzedText and FuzzyTerm to Intervals.fuzzyTerm (AnalyzedText.java:34-41, FuzzyTerm.java:43-50). Neither knows query escapes, so the backslash becomes part of the searched text.
  5. IntervalFun (:383-409) is recursive and every term-literal leaf ends in one of the two productions, so every literal under every composite function is affected. fn:wildcard, the one other leaf, keeps backslashes too, but there WildcardQuery reads them as escapes, which is consistent for glob escapes such as \* (its handling of \uXXXX escapes was not tested); it is not part of this bug.
Exposure and likelihood340 words
  • Who. Applications that pass query text to the flexible StandardQueryParser and use its fn: interval functions, when a term literal inside fn: contains a backslash-escaped character: typed by a user, or produced by the documented helper QueryParserUtil.escape (escape("a:b") is a\:b), for example when an application wraps escaped user words in fn:ordered(...).
  • Effect. Silently wrong hits for that literal: it misses the documents with the intended text and can match documents with the backslash text, or, with StandardAnalyzer, the phrase of the split pieces. No exception; the index is not touched.
  • Default vs opt-in. Default parser settings, IndexWriterConfig, codec and searcher. The fn: syntax is part of the standard syntax; the trigger is query text that uses it.
  • Depends on the analyzer. With whitespace or keyword-style analyzers (codes, identifiers, paths) every escaped character changes the result. With StandardAnalyzer the backslash is usually split off and dropped, so the visible cases are mainly an escaped colon (a:b is one token, a\:b gives a, b), escapes a user types before other in-word punctuation (a\.b is body:a.b, but fn:or(a\.b) is BLOCK(a,b)) and \uXXXX escapes.
  • Workaround. Quote without escapes: fn:or("a:b") works. A double quote is the exception: no fn: term literal can express it, since fn:or("a\"b"), fn:or(a\"b), fn:or(a\u0022b) and fn:fuzzyTerm("a\"b" 0) all keep the escape sequence. The only escape hatch is fn:wildcard(a\"b): it finds the a"b document because WildcardQuery reads \" as an escape, but it matches one indexed term without analysis, so it cannot stand in for an analyzed literal or a phrase.
  • Scale. None: one document and one query, deterministic, every time.
  • Frequency. Not measured, and likely low: it needs the fn: interval syntax plus an escaped character inside an fn: term literal, and quoting usually avoids it. The registry rates the likelihood rare, and no user report was found in the searched sources since 9.1.0.
  • Not affected. Escaped terms outside fn:; quoted fn: literals without backslashes; fn:wildcard; the classic QueryParser, which has no fn: syntax.

Minimal repro

The test shows that the flexible StandardQueryParser keeps the escape backslash in term literals inside fn: interval functions: fn:or(a\:b) is parsed as body:a\:b, not as the term a:b.

TestIntervalFunctionEscapedTerm.java (JUnit test)50 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.queryparser.flexible.standard;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIntervalFunctionEscapedTerm extends LuceneTestCase {

  public void testEscapedColonInsideFnOr() throws Exception {
    Analyzer analyzer = new WhitespaceAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        Document doc = new Document();
        doc.add(new TextField("body", "a:b", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q = new StandardQueryParser(analyzer).parse("fn:or(a\\:b)", "body");
        // expected: the escaped literal a\:b is the term a:b and matches the one document
        assertEquals("fn:or(a\\:b) parsed as " + q, 1, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-747/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:$LUCENE/lucene/queryparser/build/libs/lucene-queryparser-$V.jar
CP=$CP:$LUCENE/lucene/sandbox/build/libs/lucene-sandbox-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.queryparser.flexible.standard.TestIntervalFunctionEscapedTerm
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: the query matches the one document whose body holds the term a:b (count 1). Actual: count 0 (expected:<1> but was:<0>).

All files of this repro: repro/LFV-747/

More files, environment, full output37 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: fn:or(a\:b) parsed as body:a\:b expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.queryparser.flexible.standard.TestIntervalFunctionEscapedTerm.testEscapedColonInsideFnOr(TestIntervalFunctionEscapedTerm.java:46)

Why it looks unintended

  • The parser says "any character can be quoted with a backslash", with no fn: exception; its own example \:\(quoted\+term\)\: fails inside fn:or(...).
  • The lexer already treats \x as an escape inside fn:; only the removal is missing (the plain-term rules do it).
  • As raw text, no fn: term literal could hold a double quote; nothing upstream calls fn: literals raw.
The full argument249 words
  • The contract. StandardQueryParser.java:175-186, "Special character escaping": "If the search term contains the quote character (or cannot be quoted for some reason), any character can be quoted with a backslash", with \:\(quoted\+term\)\: and ":(quoted+term):" as two spellings of one term. The fn: section (:204-223) adds no exception. intervalfn/package-info.java:149: term literals are "converted into (analyzed) text intervals". FuzzyTerm.java:26: "An interval function equivalent to FuzzyQuery", and a\:b~1 outside fn: is unescaped.
  • The javadoc example fails inside fn:. fn:or(\:\(quoted\+term\)\:) finds the document with the backslash text; the documented quoted form fn:or(":(quoted+term):") finds the right one (executed by the independent reproducer).
  • The grammar disagrees with itself. The lexer honours the escape inside fn:, and Term, QuotedTerm, FuzzyOp, the range productions and field names all call discardEscapeChar. The only other rules that keep the raw image, Term's /regexp/ branch and fn:wildcard, hand it to RegExp and WildcardQuery, which read backslashes as escapes; IntervalText and IntervalFuzzyTerm hand it to the analyzer and the fuzzy automaton, which do not.
  • History. Both productions skipped it from their first version (LUCENE-10223, #429, 2021; LUCENE-10414, #668, 2022). The escaping javadoc and the term-literal docs came in the same commit as IntervalText, so code and docs have disagreed from the start. Neither PR discusses escaping.
  • Tests. No queryparser test uses a backslash inside fn:; none asserts either behaviour. In #11800 (2022) the fn: author advised escaping (am\@zing) or quoting, with no fn: exception.
The devil's-advocate check176 words

The strongest works-as-designed case, built by the intended-behaviour verifier: fn: literals are a raw sub-language. The interval docs never mention escaping, every fn: leaf takes the raw token the same way, fn:wildcard keeps backslashes, and AnalyzedText.toString prints the stored text back unescaped.

Why it fails: the escaping rule is stated for the whole parser and the lexer applies it inside fn:; fn:wildcard and Term's /regexp/ branch keep backslashes only because WildcardQuery and RegExp read them as escapes, while the analyzer and the fuzzy automaton do not; under the raw reading no fn: term literal could contain a double quote (only fn:wildcard, one unanalyzed term, can reach one), exactly the case the javadoc says needs backslash escaping; toString does not round-trip anyway (LFV-748); and no test, commit message, CHANGES or MIGRATE entry, JIRA or PR discussion says fn: literals are raw. All the reviews by a model from another family rated the argument weak. The weakest point: the contract is a general rule, not a sentence about fn:. The double quote supports them.

Suggested fix

Recommended Unescape the four token images in the grammar

Remove the escapes in the two fn: literal rules with discardEscapeChar, as Term and QuotedTerm do, and regenerate the parser. Validated: the repro passes, as do all 544 lucene/queryparser tests and the module checks. Land it with the printing fix of LFV-748 (option B), or printed fn: queries stop parsing back.

// StandardSyntaxParser.jj, IntervalFuzzyTerm (:668-669)
  (<TERM> | <NUMBER>) { term = discardEscapeChar(token.image).toString(); }
| <QUOTED>            { term = discardEscapeChar(token.image.substring(1, token.image.length() - 1)).toString(); }
// IntervalText (:684-685)
  (<QUOTED>)          { return new AnalyzedText(discardEscapeChar(token.image.substring(1, token.image.length() - 1)).toString()); }
| (<TERM> | <NUMBER>) { return new AnalyzedText(discardEscapeChar(token.image).toString()); }
// then: ./gradlew :lucene:queryparser:regenerateFlexibleStandardSyntaxParser
All options and trade-offs4 options · 765 words

Option A is the fix for this bug; option B is the PR to send if LFV-748 (toQueryString printing of the same nodes) is filed together. Both were validated in a clean scratch worktree at a9779ef3c193 with gradle (assertions on and off for the repro), and both diffs apply cleanly to that commit.

Side note for the same PR, not counted as a bug. The term-literal example in intervalfn/package-info.java:162 reads fn:or(\"quick fox\") with literal backslashes (Java string escaping leaked into the javadoc). Typed as shown it parses as two unquoted terms with any analyzer: with StandardAnalyzer (the independent reproducer's probe) they are body:or(fox,quick) and match the example sentence, not the phrase that the example says does not match; with a whitespace analyzer they keep the backslash and the quote and match nothing.

Unescape the four token images in the grammar (validated by the checks) (recommended)

Wrap the four token images of IntervalFuzzyTerm and IntervalText in discardEscapeChar(...).toString() (already imported, StandardSyntaxParser.jj:83), then regenerate the parser; the diff also carries the regenerated StandardSyntaxParser.java and its checksum file. fn:wildcard stays as it is on purpose.

// StandardSyntaxParser.jj, IntervalFuzzyTerm (:668-669)
  (<TERM> | <NUMBER>) { term = discardEscapeChar(token.image).toString(); }
| <QUOTED>            { term = discardEscapeChar(token.image.substring(1, token.image.length() - 1)).toString(); }
// IntervalText (:684-685)
  (<QUOTED>)          { return new AnalyzedText(discardEscapeChar(token.image.substring(1, token.image.length() - 1)).toString()); }
| (<TERM> | <NUMBER>) { return new AnalyzedText(discardEscapeChar(token.image).toString()); }
// then: ./gradlew :lucene:queryparser:regenerateFlexibleStandardSyntaxParser

Trade-offs

Escaped fn: literals now mean the unescaped text; anyone who relied on the raw text (no such use is documented) sees different hits, worth a CHANGES line. Malformed unicode escapes inside fn: (fn:or(a\u00)) now throw ParseException, as they already do outside fn:. Alone, it makes toQueryString print escaped literals in a form that no longer parses back (fn:or(a\:b) prints fn:or(a:b), a ParseException; fn:or(x\\y) prints fn:or(x\y), which re-parses as xy): pair it with option B's printing.

What the checks validated

Stock: the two repro files fail 9 of 10 (assertions on and off), and the whole module run fails exactly those 9. Fix: 10 of 10 pass (on and off); the whole lucene/queryparser suite (544 tests: 533 upstream + 10 repro + 1 probe; 0 failures, 1 upstream @Ignore) and the highlighter's TestMatchHighlighter (6) pass; :lucene:queryparser:check -x test (javacc checksums, google-java-format, ecjLint, forbiddenApis) passes. The regenerated parser equals the hunt's hand-patched one up to line wrapping; with that one shadowed onto the stock jars all 11 scope-probe queries hit doc 0 and the hunt's exhaustive escape check goes from 0 to 5,208 of 5,208 equal.

A plus the printing fix shared with LFV-748 (validated by the checks)

Option A plus the LFV-748 printing fix (quote every literal the grammar would not read back as one term; fn:fuzzyTerm prints %d %d; fn:wildcard prints a bare maxExpansions) and one helper, AnalyzedText.toLiteral, that escapes \ and " inside the quotes; FuzzyTerm.toString uses it. With it every parsed fn: query in the probe prints a string that parses back to the same query.

static String toLiteral(String term) {  // AnalyzedText; FuzzyTerm.toString uses it too
  if (requiresQuotes(term)) {            // requiresQuotes as broadened by the LFV-748 fix
    return '"' + term.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
  } else {
    return term;
  }
}

Trade-offs

toString/toQueryString now quote literals with query-syntax characters. fn:wildcard keeps its raw glob inside the quotes, because the wildcard rule does not unescape. Filed separately, A is this bug's fix and the printing belongs to LFV-748; say in each that they land together, and do not count the printing failures twice. Neither diff adds a test: the round-trip probe prints whether each re-parse equals the original and asserts nothing, so a PR should add the repro tests and a round-trip assertion.

What the checks validated

Same worktree: 9 of 9 round-trip probe inputs print a string that re-parses to the same query (fn:or(a\:b) -> fn:or("a:b"), fn:or(x\\y) -> fn:or("x\\y"), fn:fuzzyTerm(a\:b 0) -> fn:fuzzyTerm("a:b" 0 128)); the whole lucene/queryparser suite (544, 0 failures, 1 skipped) and :lucene:queryparser:check -x test pass; tidy only rewrapped one boolean chain.

Unescape in the AnalyzedText and FuzzyTerm constructors (not recommended)

One place instead of four and no parser regeneration.

Trade-offs

These are public node classes that programmatic callers build from plain text; unescaping there would change what new AnalyzedText("a\\b") means for them. Not run.

Document fn: literals as raw text (not recommended)

Keep the code and document the exception.

Trade-offs

Contradicts the parser's escaping section, leaves its own example wrong inside fn:, and leaves no fn: term literal that can hold a double quote. Not run.

Downstream note

An application that wraps user words escaped with QueryParserUtil.escape in fn: functions gets wrong hits for words with special characters, while the escapes still keep the query structure intact (an escaped ) stays in the literal, an escaped : does not switch the field).

How it was found

While writing an independent reference for fn: expressions (a printer plus an Intervals builder, compared with the parser on a 389-document index), reading the grammar showed that the fn: literal rule never unescapes. The reference's escaped-literal cases then disagreed every time: 0 of 5,208 checks equal, and 5,208 of 5,208 with the fix.

The method in full175 words

Method: bounded-exhaustive translation validation (semi-formal tier), in a hunt unit on the standard query parser's documented syntax.

  • The reference prints each fn: expression from an abstract syntax tree in the documented syntax and builds the IntervalsSource with the Intervals factory of the same name, unescaping term literals by the documented rule; it compares the parser's result by Query.equals and by match sets and positions on a 389-document two-field index.
  • Plain scope: 11,520 exhaustive checks (12 leaves, every depth-1 function, 3 field modes) and 75,000 random depth-2 and depth-3 checks, 0 mismatches; four deliberately broken references were all caught.
  • Escape dimension (7 escaped literals): 5,208 checks, 0 equal, 2,250 with different match sets; with the four-call fix 5,208 of 5,208 equal.
  • The signal came from reading the grammar while writing the reference (IntervalText has no discardEscapeChar); the run measured it. Found 2026-10-27, then checked by an independent reproducer, reviews by a model from another family (OpenAI GPT-5.6, reading only), an intended-behaviour devil's advocate, an adjudication, a history and prior-report follow-up and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 268 recorded search entries (258 queries, 10 reads) over GitHub issues, PRs and commits, the mailing lists, JIRA, Elasticsearch, OpenSearch, Solr and the web, plus the changed files of all 408 open PRs. Not fixed upstream.

Searches5 searches

Verdict new: no report found in the public sources searched, by anyone. 1,643 distinct hits scanned and 19 items read; none is this bug. A keyword search cannot prove absence; the list archive tokenizes fn: poorly (compensated with other words), and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; downstream repositories; the web2026-10-27two runs of the hunt's prior-report tool (identifiers, then symptom words): 32 GitHub issue/PR, 6 commit, 4 downstream, 40 mailing-list, 8 JIRA-archive and 12 web queries, comments of the top 20 hits read; the finder's (51) and the devil's advocate's (23) runs re-readno report
GitHub: apache/lucene, lucene-solr, solr, Elasticsearch, OpenSearch, all of GitHub2026-10-2757 manual GraphQL searches (fn:or, fn:ordered, fn:fuzzyTerm, interval with escape or backslash, the class names), also limited to items since 2021-11no report; a third-party engine that reimplements the syntax unescapes in its own code
all 408 open apache/lucene PRs; every queryparser-labelled item since 2021-112026-10-27changed-file lists and titlesno PR touches the grammar, the generated parser or the intervalfn nodes; #16892 (open, by others) changes the character set of QueryParserUtil.escape, another site
lists.apache.org, JIRA REST (LUCENE, SOLR), the web2026-10-2715 direct list searches, 5 JQL queries, 5 web queries (one throttled)no report
the discussions around the code's origin2026-10-27read in full: #11259 / PR #429, #11450 / PR #668, #11800no discussion of escaping inside fn:; in #11800 the fn: author advised escaping or quoting, with no fn: exception

History

Born with the fn: syntax: IntervalText in LUCENE-10223 (#429, 2021) and IntervalFuzzyTerm in LUCENE-10414 (#668, 2022), first released in 9.1.0. Executed on 20 releases from 9.1.0 to 10.5.2 and on main: all affected; 9.0.0 has no fn: syntax. Not fixed upstream.

Versions tested6 rows

Introduced by f725b27e12a on main, "LUCENE-10223: interval support in standard syntax parser (#429)" (2021-11-11), which added the fn: syntax, IntervalText, the escaping javadoc section and the term-literal docs; fn:fuzzyTerm (IntervalFuzzyTerm) came with f6cebac3337, "LUCENE-10414: Add fn:fuzzyTerm interval function to flexible query parser (#668)" (2022-02-10), with the same pattern. The branch_9x backports ff9ee28c603 and 9a293da5967 shipped in 9.1.0. Not a regression: both rules skipped discardEscapeChar from their first version, and the block is token-identical at every release tag checked and on main.

The 12 releases between executed ones (9.4.0, 9.4.1, 9.9.0, 9.9.1, 9.11.0, 9.12.1, 9.12.2, 10.2.0, 10.2.1, 10.3.0, 10.3.1, 10.5.1) have the same code; that is a reading, not a run. Upstream main f7bcc0abd816 (2026-10-27, the follow-up) and 20ced71c1e6e (2026-10-27, re-read by the fact check on 2026-10-28) and branch_10x still have both rules unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedcompact JUnit 4 of 4 and the independent test 5 of 6 fail through gradle, assertions on and off; the portable main() reports the bug with -ea and -da
10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.2 affectedportable main(): the bug, the same results; the hunt's JUnit 4 of 4 on 10.0.0 and 10.5.2
9.2.0 to 9.12.3 (12 releases run) affectedportable main(): the bug, the same results; JUnit 4 of 4 on 9.12.3
9.1.0 first releaseportable main(): 5 of 5 escaped fn: cases wrong; JUnit 4 of 4 fail
9.0.0 no fn: syntaxfn:or(a\:b) parses as a BooleanQuery: the feature does not exist yet
main with the fix fixportable main(): 6 of 6 fn: cases correct; gradle: all repro tests and the whole module pass

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication reproduced it with their own tests and passing controls; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause and rated the intended argument weak; this doc's gradle runs reproduce it and validate the fix.

Checks and verdicts427 words

Every executing check failed on stock main with its control passing, and passed with the fix. The cross-model reviews could not run code, so their non-"real" verdicts are procedural; all execution evidence comes from the other checks and separate re-runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive reference: 0 of 5,208 escaped-literal checks equal, 5,208 of 5,208 with the fix. Public-API JUnit fails 4 of 4 on main (-ea, -da, 4 seeds), 10.5.2, 9.12.3 and 9.1.0; passes with the fix; 138 upstream parser tests pass with and without it.
cross-model review, refute lens (OpenAI GPT-5.6) not real on procedure only, lowReading only; it needs an executed repro of its own. Its notes: "Static evidence strongly favors a genuine defect"; root cause agrees; intended argument weak. A separate re-run fails 4 of 4.
cross-model review, intended lens (OpenAI GPT-5.6) real, medium; intended argument weakReading only; the escaping javadoc, the lexer and the other term rules outweigh a sub-language reading; fn:wildcard rightly excluded.
independent reproducer realOwn test from the claim: 5 of 6 fail on main (-ea, -da), 10.5.2 (-ea, -da), 9.12.3 and 9.1.0 with identical messages, including the javadoc example; its control passes; 6 of 6 with the fix.
root-cause review (the cross-model review, OpenAI GPT-5.6) and its executed check uncertain (procedural); own test confirmsReading only, root cause agrees. A separate test (an escaped backslash and a nested leaf) fails 2 of 4 on main (-ea, -da); its quoted and fn:wildcard controls pass; 4 of 4 with the fix.
intended-behaviour devil's advocate real, medium-highBuilt the raw sub-language case and rejected it. Probe: the lexer honours fn:or(a\)); no fn: literal spelling yields a double quote; QueryParserUtil.escape("a:b") is a\:b; fn:wildcard keeps WildcardQuery escapes, which is consistent.
adjudication confirmed, high; duplicate of noneRe-ran the independent test with a new seed on a clean checkout: 5 of 6 fail with -ea and -da; 6 of 6 pass with the fix.
registry re-run and credibility audit keepNew seeds on a clean main: the finder's JUnit fails 4 of 4 (-ea, -da) and passes with the fix; the independent test fails 5 of 6; a fourth re-run fails 4 of 4.
this doc's gradle validation reproduced; fix validatedStock: 9 of 10 repro tests fail (on and off), and the module run fails only them. Fix A: 10 of 10, the 544 module tests and the static checks pass. Option B: the round trip holds for 9 of 9 probe inputs.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,365 words

Finding record

LFV-747, snapshot of 2026-11-06

Registry title
Flexible StandardQueryParser: term literals inside fn: interval functions keep their escape backslashes, so fn:or(a\:b) searches for the text a\:b instead of the term a:b
Area
queryparser / flexible StandardQueryParser interval functions (StandardSyntaxParser.jj IntervalText and IntervalFuzzyTerm, intervalfn AnalyzedText and FuzzyTerm)
Priority
Low, rank 380: Needs an escaped character inside a term literal of the flexible parser's niche fn: interval syntax.
Impact class
silent wrong results
Impact note
Hits applications that pass query text to the flexible StandardQueryParser and use its fn: interval functions (default parser settings, IndexWriterConfig, codec and searcher; any scale; -ea and -da) when a term literal inside fn: contains a backslash-escaped character, typed by a user or produced by QueryParserUtil.escape: in fn:or, fn:ordered and every other composite function (at any nesting depth) and in fn:fuzzyTerm the backslashes reach the analyzer or the fuzzy automaton, so the query silently misses the documents that hold the intended text and may match documents that hold the backslash text instead (with whitespace or keyword-style analyzers every escaped character changes the result; with StandardAnalyzer mainly an escaped colon, because a:b is one token while a\:b becomes the phrase a b); quoting avoids it except for a double quote, which no fn: term literal can express today (only fn:wildcard(a\"b) reaches it, as one unanalyzed term); nothing throws and the index is not touched.
Summary
You parse fn:or(a\:b) (or fn:ordered(a\:b c), fn:fuzzyTerm(a\:b 0), fn:or("a\"b")) with StandardQueryParser and expect it to find the documents that hold a:b (or a"b), because the parser's javadoc says 'any character can be quoted with a backslash' and the same escaped literal outside fn: is parsed as the term a:b. Instead the IntervalText and IntervalFuzzyTerm grammar productions pass the raw token text, backslashes included, to AnalyzedText and FuzzyTerm, so the interval query searches for the text a\:b. With WhitespaceAnalyzer it misses the a:b document and matches a document that holds a\:b; with StandardAnalyzer it becomes BLOCK(a,b) and matches 'a b c'; no error is raised.
Introduced
f725b27e12a992f12aab12a8e8bfb9b31125bdae (main, 2021-11-11, by dweiss): 'LUCENE-10223: interval support in standard syntax parser (#429)' (issue LUCENE-10223 = GitHub #11259, PR apache/lucene#429), which added the fn: syntax with the IntervalText production; and f6cebac3337926ca871b922241976a4ba4799c70 (main, 2022-02-10, by dweiss): 'LUCENE-10414: Add fn:fuzzyTerm interval function to flexible query parser (#668)' (GitHub #11450, PR #668), whose IntervalFuzzyTerm production handles the term the same way; the branch_9x backports ff9ee28c603 and 9a293da5967 are the commits that shipped in 9.1.0 [read: git log -S 'new AnalyzedText(' and -S 'IntervalFuzzyTerm', git blame -w -M -C -C on main lines 659-686 and git tag --contains in the history clone]. Not a regression: both productions passed the raw token image on without discardEscapeChar from their first version, and the block is token-identical at every release tag checked and on main. (2021-11-11 (IntervalText, main and branch_9x); 2022-02-10 (IntervalFuzzyTerm, main and branch_9x))
First affected version
9.1.0 (tagged 2022-03-21) [exec]; 9.0.0, the last release before, has no fn: interval syntax at all (fn:or(a\:b) parses as a BooleanQuery there) [exec], so there is no unaffected release that has the feature
Affected versions
every release with the fn: interval syntax, 9.1.0 through 9.12.3 and 10.0.0 through 10.5.2 (the last release before 10.6.0), and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] a portable main() repro reports the bug on 20 releases (9.1.0, 9.2.0, 9.3.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.2) and on main (-ea and -da) with identical output, and reports the bug absent on main with the fix shadowed in; the finder's JUnit fails 4 of 4 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's own test fails 5 of 6 on 9.1.0, 9.12.3, 10.5.2 (-ea and -da) and main (-ea and -da) with byte-identical messages. [read] the 12 releases not run (9.4.0, 9.4.1, 9.9.0, 9.9.1, 9.11.0, 9.12.1, 9.12.2, 10.2.0, 10.2.1, 10.3.0, 10.3.1, 10.5.1) have token-identical IntervalFuzzyTerm and IntervalText productions; upstream main f7bcc0abd816 (2026-10-27) and branch_10x have them unchanged. Not affected: 9.0.0 and earlier (no fn: syntax). All runs with JDK 25 on aarch64.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new: 268 recorded queries, namely two new scripted prior-report searches (A: identifiers IntervalText, IntervalFuzzyTerm, discardEscapeChar, AnalyzedText, FuzzyTerm, IntervalQueryNode and fn: phrasings; B: escape and unescape symptom terms; together 32 GitHub issue and PR, 6 commit, 4 downstream-repository, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, and 8 JIRA-archive queries, with the comments of the top 20 hits read), re-reads of the finder's run (51 queries) and the intended-behaviour check's run (23 queries), 57 manual GitHub GraphQL searches (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch and all of GitHub), the changed-file lists of all 408 open apache/lucene PRs, the titles of every queryparser-labelled item since 2021-11, 15 direct lists.apache.org searches, 5 JIRA REST queries (LUCENE and SOLR) and 5 web queries (one throttled); 1,643 distinct hits scanned, 19 items read, none the same bug). Closest items, not reports: #11259 (LUCENE-10223) with PR #429 and #11450 (LUCENE-10414) with PR #668, which added the code and never discuss escaping inside fn:; #11800 (closed, '@' no longer parsing in plain terms from 9.1.0), where the fn: author advised escaping (am\@zing) or quoting, with no fn: exception; open outside PRs #16892 (QueryParserUtil.escape character set; that helper produces a\:b, which this bug then mishandles inside fn:), #16853 (EscapeQuerySyntaxImpl.escapeIgnoringCase) and #16323 (toStringEscaped refactor), all other sites; #13887 and #13234 (escaped brackets in range bounds); #1666 (LUCENE-588), #1651 (LUCENE-573) and #13962 (classic-parser escaping). Not fixed upstream: main f7bcc0abd816 and branch_10x have both productions unchanged, and no open PR touches the grammar, the generated parser or any intervalfn file. Gaps: absence of a report is not proof; the list archive tokenizes 'fn:' poorly (compensated with other terms); one web query throttled; private channels cannot be searched.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit and the independent reproducer's test through gradle in a clean scratch worktree, stock and with the fix (parser regenerated from the grammar), assertions on and off; with the fix the whole lucene/queryparser suite (544 tests), the highlighter's TestMatchHighlighter and the module's static checks; the round-trip probe stock, with the fix and with the fix plus the printing change. Scope and edge probes on the hunt's classpath runner (-ea, -da, fix shadowed). Hunt runs on 10.5.2, 9.12.3 and 9.1.0 and a portable main() on 20 releases from 9.1.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:129-130 (_ESCAPED_CHAR 'Every character that follows a backslash is considered as an escaped character'), :159-161 (QUOTED and TERM include escaped characters), :659-679 (IntervalFuzzyTerm, term = token.image at :668-669), :681-686 (IntervalText, new AnalyzedText(token.image) at :684-685), :803-804 (Term, discardEscapeChar), :828-831 (QuotedTerm, discardEscapeChar); parser/StandardSyntaxParser.java:980, :986, :1022, :1046 (generated code, same raw images)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AnalyzedText.java:34-41 (Intervals.analyzedText on the stored text), nodes/intervalfn/FuzzyTerm.java:26 (javadoc 'An interval function equivalent to FuzzyQuery'), :36-50 (Intervals.fuzzyTerm on the stored text), nodes/intervalfn/package-info.java:147-152 (term literals), :162 (fn:or(\"quick fox\") example), StandardQueryParser.java:175-186 ('Special character escaping'), QueryParserUtil.java:167 (escape)
The finding in 30 seconds
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:129-168, 343, 383-409, 641-686, 727, 755-772, 798-804, 828-831
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.java:980, 986, 1022, 1046
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/EscapeQuerySyntaxImpl.java:185-267 (discardEscapeChar)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AnalyzedText.java:29-55; nodes/intervalfn/FuzzyTerm.java:25-59; nodes/intervalfn/package-info.java:147-152
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:175-186
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/QueryParserUtil.java:163-180
Why it looks unintended
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:175-186, 204-223; nodes/intervalfn/package-info.java:147-152; nodes/intervalfn/FuzzyTerm.java:26

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from runs on main a9779ef3c193 (stock and with the fix); the values on screen come from the executed repro and probes. The diagram's example uses the analysis module's whitespace analyzer, like the finder's original test; the compact repro uses the test framework's whitespace MockAnalyzer, which gives the same tokens.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.