The flexible parser removes escape backslashes from every term, except from the term literals of fn: interval functions.
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.- Index two documents with a whitespace analyzer: doc 0 holds
a:b c, doc 1 holdsa\:b c(with a real backslash). - Search
a\:bwithStandardQueryParser. The backslash escapes the colon, as the parser documents ("any character can be quoted with a backslash"): the query isbody:a:band it finds doc 0. - Put the same word inside an interval function:
fn:or(a\:b). You expect the same terma:band doc 0. - Instead the query is
body:a\:band finds doc 1. The two grammar rules that readfn:literals pass the raw token on and never remove the backslash, unlikeTerm,QuotedTermand the other rules that produce plain term text. - No error. All 10
fn:forms tried behave the same (fn:ordered, nested functions,fn:fuzzyTerm); withStandardAnalyzer,fn:or(a\:b)becomes the phrasea b. Quoting (fn:or("a:b")) avoids it, but nofn:term literal can hold a double quote.
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.
- Escaping: in
StandardQueryParserquery text, a backslash makes the next character literal:a\:bis the terma:b, not fieldaand termb(StandardQueryParser.java:175-186). - Interval functions:
fn:or(...),fn:ordered(...),fn:fuzzyTerm(...)and 18 more build anIntervalQuery; their leaves are term literals, "converted into (analyzed) text intervals" (intervalfn/package-info.java:147-152).
- The lexer treats
\xas an escape everywhere (StandardSyntaxParser.jj:129-136,:159-161); afterfn:the(switches it back to the default state (:147,:167-168), soa\:binsidefn:is one term token with the imagea\:b. The escape protects the structure:fn:or(x\))parses,fn:or(x))does not. - 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) andfn:wildcard(:649-650), pass it toRegExpandWildcardQuery, which read it as an escape. IntervalFuzzyTerm(:668-669) andIntervalText(:684-685) do not: they handtoken.image(or the quoted image minus its quotes) toFuzzyTermandAnalyzedText(generated parser:980,:986,:1022,:1046).AnalyzedTextpasses the text toIntervals.analyzedTextandFuzzyTermtoIntervals.fuzzyTerm(AnalyzedText.java:34-41,FuzzyTerm.java:43-50). Neither knows query escapes, so the backslash becomes part of the searched text.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 thereWildcardQueryreads them as escapes, which is consistent for glob escapes such as\*(its handling of\uXXXXescapes was not tested); it is not part of this bug.
Exposure and likelihood340 words
- Who. Applications that pass query text to the flexible
StandardQueryParserand use itsfn:interval functions, when a term literal insidefn:contains a backslash-escaped character: typed by a user, or produced by the documented helperQueryParserUtil.escape(escape("a:b")isa\:b), for example when an application wraps escaped user words infn: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. Thefn: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
StandardAnalyzerthe backslash is usually split off and dropped, so the visible cases are mainly an escaped colon (a:bis one token,a\:bgivesa,b), escapes a user types before other in-word punctuation (a\.bisbody:a.b, butfn:or(a\.b)isBLOCK(a,b)) and\uXXXXescapes. - Workaround. Quote without escapes:
fn:or("a:b")works. A double quote is the exception: nofn:term literal can express it, sincefn:or("a\"b"),fn:or(a\"b),fn:or(a\u0022b)andfn:fuzzyTerm("a\"b" 0)all keep the escape sequence. The only escape hatch isfn:wildcard(a\"b): it finds thea"bdocument becauseWildcardQueryreads\"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 anfn: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:; quotedfn:literals without backslashes;fn:wildcard; the classicQueryParser, which has nofn:syntax.
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.
/*
* 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));
}
}
}
}
# 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>).
More files, environment, full output37 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The parser says "any character can be quoted with a backslash", with no
fn:exception; its own example\:\(quoted\+term\)\:fails insidefn:or(...). - The lexer already treats
\xas an escape insidefn:; 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 callsfn: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. Thefn: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", anda\:b~1outsidefn:is unescaped. - The javadoc example fails inside
fn:.fn:or(\:\(quoted\+term\)\:)finds the document with the backslash text; the documented quoted formfn:or(":(quoted+term):")finds the right one (executed by the independent reproducer). - The grammar disagrees with itself. The lexer honours the escape inside
fn:, andTerm,QuotedTerm,FuzzyOp, the range productions and field names all calldiscardEscapeChar. The only other rules that keep the raw image,Term's/regexp/branch andfn:wildcard, hand it toRegExpandWildcardQuery, which read backslashes as escapes;IntervalTextandIntervalFuzzyTermhand 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) thefn:author advised escaping (am\@zing) or quoting, with nofn: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.
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:regenerateFlexibleStandardSyntaxParserAll 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.
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:regenerateFlexibleStandardSyntaxParserTrade-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.
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.
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.
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.
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).
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 theIntervalsSourcewith theIntervalsfactory of the same name, unescaping term literals by the documented rule; it compares the parser's result byQuery.equalsand 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 (
IntervalTexthas nodiscardEscapeChar); 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; the JIRA archive; downstream repositories; the web | 2026-10-27 | two 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-read | no report |
| GitHub: apache/lucene, lucene-solr, solr, Elasticsearch, OpenSearch, all of GitHub | 2026-10-27 | 57 manual GraphQL searches (fn:or, fn:ordered, fn:fuzzyTerm, interval with escape or backslash, the class names), also limited to items since 2021-11 | no 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-11 | 2026-10-27 | changed-file lists and titles | no 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 web | 2026-10-27 | 15 direct list searches, 5 JQL queries, 5 web queries (one throttled) | no report |
| the discussions around the code's origin | 2026-10-27 | read in full: #11259 / PR #429, #11450 / PR #668, #11800 | no discussion of escaping inside fn:; in #11800 the fn: author advised escaping or quoting, with no fn: exception |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | compact 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 | affected | portable 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) | affected | portable main(): the bug, the same results; JUnit 4 of 4 on 9.12.3 |
| 9.1.0 | first release | portable main(): 5 of 5 escaped fn: cases wrong; JUnit 4 of 4 fail |
| 9.0.0 | no fn: syntax | fn:or(a\:b) parses as a BooleanQuery: the feature does not exist yet |
| main with the fix | fix | portable main(): 6 of 6 fn: cases correct; gradle: all repro tests and the whole module pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exhaustive 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, low | Reading 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 weak | Reading only; the escaping javadoc, the lexer and the other term rules outweigh a sub-language reading; fn:wildcard rightly excluded. |
| independent reproducer | real | Own 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 confirms | Reading 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-high | Built 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 none | Re-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 | keep | New 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 validated | Stock: 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. |
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.
- PNG 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,365 words
- 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 wholelucene/queryparsersuite (544 tests), the highlighter'sTestMatchHighlighterand 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 portablemain()on 20 releases from 9.1.0 to 10.5.2.
- 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-831lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.java:980, 986, 1022, 1046lucene/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-152lucene/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
- TestIntervalFunctionEscapedTerm.java (JUnit test):
repro/LFV-747/src/org/apache/lucene/queryparser/flexible/standard/TestIntervalFunctionEscapedTerm.java(50 lines)
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.
assets/LFV-747/bug.png(468 KB)
