All findings · Lucene finding · LFV-845 · range-goop-swallows-grammar-whitespace

Classic and flexible query parsers: a tab, line break or ideographic space touching a range bound becomes part of the bound

In the classic and flexible query parsers, a tab, line break or full-width space (U+3000) that touches a range bound becomes part of the bound: date:[<LF>20200101 TO 20201231<LF>] silently matches 4 dates instead of 3, and date:[20200101<TAB>TO<TAB>20201231] fails to parse. Default settings, every release since 1.3-rc1.

Summary

Priority
Low, rank 532It hits the default classic and flexible parsers in every release, but the input is rare (a TAB, line break or U+3000 inside a range), the usual result is a loud ParseException and bounds change silently only when the character touches a bracket.
Impact
silent wrong results
Since
1.3-rc1 (2002, the TO range syntax; LF/CR from 2004, U+3000 and the flexible parser from 2.9.0)introduced 2002-06-25 · over 24 years
Affected
1.3-rc1 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b9) and main
Status
documented
Found by
comparison of the grammars' character lists, then a bounded-exhaustive lexer check

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

Lucene's classic and flexible query parsers separate clauses at a tab, a line break or a full-width space (U+3000) just as at a space, but not next to a range bound. The token for a bound stops only at a plain space, ] or }, so such a character becomes part of the bound: the parse fails, or the bounds change silently.

Background: how the parser reads a range

  1. The query parser's lexer cuts the text into tokens. After [ or { it switches to a Range state with its own token rules, until ] or }.
  2. In the Range state a bound is one RANGE_GOOP token: a run of any characters except a plain space, ] and }. TO and quoted bounds are their own tokens.
  3. Whitespace is a SKIP rule: matched and thrown away. When two rules match, the lexer keeps the longest match; on a tie, the rule declared first.
Diagram of the example: five indexed dates (20191231, 20200101, 20200615, 20201231, 20210101) and the query date:[ LF 20200101 TO 20201231 LF ] typed over three lines. Both query grammars skip whitespace inside ranges, but the bound token RANGE_GOOP stops only at a space, ] or }, so it takes each line break into the bounds; the range then matches 4 dates instead of 3, 20191231 included. With the fix, RANGE_GOOP stops at every whitespace character of the grammar and the range matches 3.
Step by step: a date range typed over three lines, the bound token taking each line break, the 4 hits instead of 3, the other range shapes, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index five dates as StringFields: 20191231, 20200101, 20200615, 20201231, 20210101. Type a range over three lines, date:[ / 20200101 TO 20201231 / ], and parse it with the classic QueryParser or StandardQueryParser and StandardAnalyzer.
  2. You expect the 3 hits of date:[20200101 TO 20201231]: both grammars declare tab, LF, CR and U+3000 as whitespace, and skip whitespace inside ranges too.
  3. You get 4 hits, 20191231 included, and no error. The bound token stops only at a plain space, ] or }, so it swallows each line break: the bounds are <LF>20200101 and 20201231<LF>, and LF sorts before 2.
  4. The same rule breaks other shapes: date:[20200101<TAB>TO<TAB>20201231] throws a ParseException (one bound, no TO); date:{20200101 TO 20201231<TAB>} gives 2 hits instead of 1 (its own excluded endpoint); a leading U+3000, as CJK input methods type it, gives 0 instead of 3.

Who is affected

Anyone who parses user-typed query text with the classic QueryParser (or a subclass), StandardQueryParser or PrecedenceQueryParser with default parser settings, on every release since 1.3-rc1. Multi-line or tab-separated ranges and full-width spaces from CJK input methods reach it. A character that touches TO gives a ParseException; one glued to a bound but not to TO silently changes that bound. Rare.

Mechanism, terms and numbers752 words

Both query grammars, the classic QueryParser.jj and the flexible StandardSyntaxParser.jj, declare U+0020, TAB, LF, CR and U+3000 as whitespace and skip it in the Range lexical state as well. But the token for a range bound, RANGE_GOOP, is any run of characters other than U+0020, ] and }. The lexer keeps the longest match, so a TAB, LF, CR or U+3000 that touches a bound is swallowed into it.

Two outcomes follow. When the character separates a bound from TO, the bound and TO become one token and the parse fails: date:[20200101<TAB>TO<TAB>20201231] throws in both parsers. When it is glued to a bound but not to TO (right after [/{, right before ]/}, or between a bound and the space next to TO), the query parses without error and the bound carries the character: over five dates date:[<LF>20200101 TO 20201231<LF>] matches 4 instead of 3, date:{20200101 TO 20201231<TAB>} admits its own exclusive endpoint, date:[<U+3000>20200101 TO 20201231] matches nothing, and date:[20200101<LF> TO 20201231], a value pasted with a trailing newline, matches 2 instead of 3. It affects the classic parser and its subclasses, StandardQueryParser and PrecedenceQueryParser with default parser settings, and every release since the TO syntax arrived in 1.3-rc1. The fix adds the four characters to the exclusion list in both grammars and regenerates the token managers.

Terms

  • Grammar whitespace: <#_WHITESPACE: ( " " | "\t" | "\n" | "\r" | "\u3000") > (QueryParser.jj:187; the same at StandardSyntaxParser.jj:135). Outside ranges these five characters are also excluded from terms (_TERM_START_CHAR, QueryParser.jj:183-185), so there they always separate clauses: upstream testTabNewlineCarriageReturn and testCJK assert it.
  • Range state: after [/{ the lexer uses the rules RANGE_TO (TO), RANGEIN_END (]), RANGEEX_END (}), RANGE_QUOTED and RANGE_GOOP (QueryParser.jj:221-227; StandardSyntaxParser.jj:195-201), and the whitespace SKIP rule <DEFAULT, Range> SKIP : { < <_WHITESPACE>> } (QueryParser.jj:191-193; <DEFAULT, Range, Function> at StandardSyntaxParser.jj:139-141).
  • Range rule: bound, TO, bound, closing bracket (QueryParser.jj:343-347; StandardSyntaxParser.jj:855-859).

The bug

  1. RANGE_GOOP is (~[ " ", "]", "}" ])+ (QueryParser.jj:226, StandardSyntaxParser.jj:200): it excludes only U+0020 of the five whitespace characters.
  2. At a TAB that starts a token and is followed by a letter or digit, SKIP matches 1 character and RANGE_GOOP matches more, so the TAB joins the bound. SKIP wins only a tie, when the character starts a token and is followed by U+0020, ], } or the end of the input. Inside a bound there is no token start: RANGE_GOOP runs on through the character, so one glued to the end of a bound is swallowed whatever follows it (20200101<LF> TO gives the bound 20200101<LF>).
  3. Loud: in [20200101<TAB>TO<TAB>20201231] the whole inside is one RANGE_GOOP, the parser meets ] where it needs TO, and throws (Was expecting: "TO" in the classic parser, INVALID_SYNTAX_CANNOT_PARSE in the flexible one).
  4. Silent: a character glued to a bound but not to TO (right after [/{, right before ]/}, or between a bound and the space next to TO) stays in the bound text. The classic parser passes it to newRangeQuery, where Analyzer.normalize (QueryParserBase.java:550, :556) only lowercases for StandardAnalyzer; the flexible parser builds its bound nodes from the token text the same way (StandardSyntaxParser.jj:870-873). The result is a TermRangeQuery whose bound contains the character. The open-bound check "*".equals(goop1.image) (QueryParser.jj:354) also fails, so [<LF>* TO b] searches from the literal term <LF>*.

The numbers

Five StringField dates 20191231, 20200101, 20200615, 20201231, 20210101; both parsers give the same result on every line (the fact check ran four more parsers built on the two grammars, with the same result).

queryboundshitswith spaces
date:[20200101<TAB>TO<TAB>20201231]ParseException-3
date:[<LF>20200101 TO 20201231<LF>]<LF>20200101 to 20201231<LF>4 (20191231 too)3
date:{20200101 TO 20201231<TAB>}upper 20201231<TAB>2 (20201231 too)1
date:[<U+3000>20200101 TO 20201231]lower <U+3000>2020010103
date:[20200101<LF> TO 20201231]lower 20200101<LF>2 (20200101 itself excluded)3
date:[20200101 TO <TAB>20201231]upper <TAB>20201231 (TAB sorts before 2)03
date:[<TAB> 20200101 TO 20201231 <TAB>]right (each TAB starts a token and a space or ] follows it)33
date:[<CR><LF> 20200101 TO 20201231 <CR><LF>]ParseException (<CR><LF> is a 2-character bound)-3
date:["20200101"<TAB>TO<TAB>"20201231"]ParseException (quoting does not help)-3
date:[<LF>* TO 20201231]lower is the literal <LF>*, not open--
Exposure and likelihood462 words
  • Who is exposed. Applications that parse query text with the classic QueryParser or its subclasses MultiFieldQueryParser, ComplexPhraseQueryParser and ExtendableQueryParser, with StandardQueryParser, or with PrecedenceQueryParser (which extends it), with default parser settings (the caller supplies the analyzer; the repro passes StandardAnalyzer, and the defect is in the lexer, before any analysis). Executed for QueryParser, MultiFieldQueryParser, StandardQueryParser and PrecedenceQueryParser; the other two inherit the classic grammar. Only these two grammars have a range syntax: the surround parser (its grammar has no Range state) and SimpleQueryParser (no range operator) are not affected.
  • Inputs that reach it. A range typed over several lines or with tabs in a query text area; a value with a trailing newline or tab pasted between the brackets; Windows line endings (<CR><LF> breaks even indented input); the full-width space U+3000 that CJK input methods type, the case LUCENE-1528 added U+3000 for; generated query strings built from templates.
  • Loud vs silent. A character that touches TO makes TO part of a bound token and the parse fails: a ParseException for a range whose parts are separated by one of these characters, and quoting the bounds does not help. A character glued to a bound but not to TO silently changes that bound (extra hits, an exclusive endpoint admitted, or no hits at all): right after [/{, right before ]/}, a value pasted with a trailing newline (date:[20200101<LF> TO 20201231]: 2 hits instead of 3) or a tab typed after TO (date:[20200101 TO <TAB>20201231]: 0 instead of 3). A character with a space or bracket on both sides is skipped.
  • Scale. One query string reproduces it; no setting, index size or concurrency is involved, and assertions on or off behave the same.
  • Setup after lexing. The hit counts are for StringField terms and StandardAnalyzer, whose normalize only lowercases; an analyzer whose normalization strips or maps these characters would hide part of the silent outcome (not run). Both parsers first try to read each bound as a date in their locale, but with no date resolution configured (the default, as in the repro) DateTools.dateToString throws on the null resolution and both keep the bound text, so the random test locale cannot change the results; with a date resolution set, a bound the locale's date format reads as a date is rewritten first (not run).
  • How likely. Rare: it needs one of these characters inside the brackets of a range, the parse fails whenever one touches TO, and the searches (see Prior reports) found no public report in over 20 years of releases.
  • Downstream. Solr's fork of the classic grammar has the same RANGE_GOOP line and Range-state SKIP (read on apache/solr main by the finder).

Minimal repro

The test shows that the classic QueryParser keeps a line break that touches a range bracket as part of the bound, although its grammar skips LF as whitespace inside ranges (RANGE_GOOP excludes only U+0020).

TestRangeBoundWhitespace.java (JUnit test)30 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.classic;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRangeBoundWhitespace extends LuceneTestCase {

  public void testLineBreakInsideRangeBrackets() throws Exception {
    QueryParser qp = new QueryParser("body", new StandardAnalyzer());
    // expected the LFs skipped like spaces; actual bounds "\n20200101" and "20201231\n"
    assertEquals(
        qp.parse("date:[20200101 TO 20201231]"), qp.parse("date:[\n20200101 TO 20201231\n]"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-845/, 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/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.classic.TestRangeBoundWhitespace
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected date:[<LF>20200101 TO 20201231<LF>] to parse like date:[20200101 TO 20201231]; actual: the bounds are <LF>20200101 and 20201231<LF> (expected:<date:[20200101 TO 20201231]> but was:<date:[<LF>20200101 TO 20201231<LF>]>), with -ea and -da.

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

More files, environment, full output40 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: expected:<date:[20200101 TO 20201231]> but was:<date:[
20200101 TO 20201231
]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.queryparser.classic.TestRangeBoundWhitespace.testLineBreakInsideRangeBrackets(TestRangeBoundWhitespace.java:27)

Why it looks unintended

  • The grammar declares tab, LF, CR and U+3000 as whitespace and skips it inside ranges (QueryParser.jj:187-193); the bound token lets that rule fire only at a token start before a space or bracket.
  • Upstream tests treat all five characters as one class (testTabNewlineCarriageReturn, testCJK); no test pins the range behaviour.
  • The 2002 author described ranges as "using whitespace as delimiters".
The full argument313 words
  • The grammar's own rules. _WHITESPACE lists U+0020, TAB, LF, CR and U+3000 and is skipped in the Range state (QueryParser.jj:187, :191-193; StandardSyntaxParser.jj:135, :139-141). Because RANGE_GOOP swallows the other four, that SKIP fires for them only in a tie, so the same character is skipped or kept depending on its neighbours: date:[<TAB> 20200101 ... gives the lower bound 20200101, date:[<TAB>20200101 ... gives <TAB>20200101. No design note, test or discussion found explains this distinction.
  • Tests. testTabNewlineCarriageReturn (QueryParserTestBase.java:896, TestQPHelper.java:937) asserts that "+weltbank\n+worlbank", "+weltbank\r\n+worlbank" and similar equal "+weltbank +worlbank"; testCJK (QueryParserTestBase.java:250-255, TestQPHelper.java:324-329) that "term\u3000term\u3000term" equals the space form. No test has a range with a raw TAB, LF, CR or U+3000; with the fix all 30 upstream queryparser suites pass.
  • History. The 2002 commit that added the TO syntax also extended the whitespace SKIP, then " " and TAB, to the new range states, and defined a bound token that excludes only " ". Later the same day (about 17 hours after the commit), its author wrote on dev@: "right now, we're using whitespace as delimiters in the range -- which means that dates like 2002-01-01 1:35 PM are no good unless enclosed in quotes". The 2004 change (LUCENE-93) "treats \n and \r characters in query strings the same way that \t and " " are treated"; LUCENE-1528 (2009) added U+3000 for CJK text. Neither touched the bound token. The LUCENE-1528 review asked how to avoid "defining the whitespace chars in two places" and missed this third copy.
  • Committer remark. In the merged test PR #13323, dweiss wrote that range terms "are parsed differently compared to normal tokens. This is strange but I bet there was a reason for it - now long forgotten. I think, logically, it should accept the same term notation that is used elsewhere" (said about escapes).
The devil's-advocate check239 words

The strongest case for "intended" (built by the devil's-advocate verifier; the cross-model reviews rated it weak, the cross-model fact check of this doc moderate): the delimiter has always been written " TO " with ASCII spaces (the 2002 commit message, the LUCENE-7821 CHANGES entry, the test comment // " TO " is required between range endpoints at QueryParserTestBase.java:637); the user docs show only space-separated ranges; the package docs call the parser "designed for human-entered text, not for program-generated text" and say date ranges "are better added directly through the query API"; the behaviour is over 20 years old with no report found; and today a raw TAB inside an unquoted bound (k:[a<TAB>b TO c]) parses, which the fix would break.

Why it fails: " TO " is about the keyword being required and delimited (LUCENE-7821 was about ranges without TO and never discusses the kind of whitespace). The docs' silence is filled by the grammar's own declarations and the tests. "Human-entered text" argues for the fix: people press Enter in a text area, and CJK input methods type U+3000. Age shows the case was never exercised, not that it was chosen. The raw TAB inside an unquoted bound is the real compatibility question: no doc, test or design note mentions it, the 2002 author named quoting for a date containing spaces, and quoting keeps working with the fix; maintainers should weigh it against consistent Range-state whitespace.

Suggested fix

Recommended Add TAB, LF, CR and U+3000 to the RANGE_GOOP exclusions and regenerate

Exclude the grammar's other whitespace from the bound token in both grammars, (~[ " ", "\t", "\n", "\r", "\u3000", "]", "}" ])+, and regenerate the token managers. The repro and all 539 queryparser tests pass. An unquoted bound with a raw tab inside (k:[a<TAB>b TO c]) then needs quotes, so target main with a CHANGES entry.

-| <RANGE_GOOP:   (~[ " ", "]", "}" ])+ >
+| <RANGE_GOOP:   (~[ " ", "\t", "\n", "\r", "\u3000", "]", "}" ])+ >
All options and trade-offs4 options · 710 words

Option A is the fix the finder proposed and this doc validated with gradle. B merges it with the escape fix of #13234; C and D are listed because a reviewer may ask about them. Add the new test (or its cases in QueryParserTestBase) with whichever is chosen.

Add TAB, LF, CR and U+3000 to the RANGE_GOOP exclusions and regenerate (validated by the checks) (recommended)

One line in each grammar (QueryParser.jj:226, StandardSyntaxParser.jj:200), then ./gradlew :lucene:queryparser:regenerateClassicQueryParser :lucene:queryparser:regenerateFlexibleStandardSyntaxParser, which changes 7 lines in each generated token manager and updates the two checksum files.

-| <RANGE_GOOP:   (~[ " ", "]", "}" ])+ >
+| <RANGE_GOOP:   (~[ " ", "\t", "\n", "\r", "\u3000", "]", "}" ])+ >

Trade-offs

  • An unquoted bound that contains one of the four characters stops parsing: k:[a<TAB>b TO c] gives the bounds a<TAB>b and c today and a ParseException with the fix, while k:["a<TAB>b" TO c] keeps working. The 2002 author described whitespace as the range delimiter and said a date containing spaces needed quotes; propose it for main / 11.0 with a CHANGES entry ("the query parser syntax may change from release to release", classic package-info.java:74-75).
  • A JavaCC character list holds characters only and cannot name _WHITESPACE, so the whitespace list is now written three times per grammar. Add a comment that ties _WHITESPACE, _TERM_START_CHAR and RANGE_GOOP together (the LUCENE-1528 review already worried about two copies), and keep them in step if _WHITESPACE ever grows.
  • Nothing else changes: the rule differs only on the four characters and only in the Range state, so only ranges that contain one of them parse differently; with the fix quoted bounds next to a TAB parse too.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25: the new test fails 5 of 6 on stock (assertions on and off) and passes 6 of 6 with the fix; the whole :lucene:queryparser:test passes with assertions on (seed 5EED0845) and off (seed 5EED0846): 31 suites, 539 tests, 0 failures, 1 skipped (the 30 upstream suites include TestQueryParser, TestStandardQP, TestQPHelper, TestMultiFieldQueryParser, TestPrecedenceQueryParser, TestComplexPhraseQuery and TestExtendableQueryParser). checkGoogleJavaFormat, both regenerate checksum checks and forbiddenApisMain/forbiddenApisTest pass.
  • After the fact checks, the test file exactly as printed (the formatter's one-line assertEquals) was run again in a fresh scratch worktree: checkGoogleJavaFormat and forbiddenApisTest pass on stock and with the fix (with both regenerate checksum checks); the test fails 5 of 6 on stock and passes 6 of 6 with the fix, assertions on and off.
  • A probe with the regenerated token managers gives every input the bounds of its space form in both parsers; <CR><LF> input and quoted bounds next to a TAB parse; [<LF>* TO b] is open-ended again.
  • Earlier, with the finder's regenerated token managers, the finder's 4 tests and the independent reproducer's 7 tests passed, the bounded lexer check held in all four lexer/state pairs, and 14 upstream queryparser test classes (323 tests) passed.

One combined RANGE_GOOP rewrite with the escape fix of #13234 (not run)

For example (~["\\", " ", "\t", "\n", "\r", "\u3000", "]", "}"] | "\\" ~[])+: the rule proposed in closed PR #13887 plus the four characters. It would close #13234 and this bug in one change.

Trade-offs

It also changes how escapes inside bounds behave. #13887 proposed exactly that and was closed by its author (2026-03-04) without a review or a maintainer comment; dweiss noted on #13323 that such a change "will break backward compatibility but maybe consistency is worth this cost", so it needs that discussion first. #13887's rule alone leaves this bug in place.

Not recommended: trim whitespace from the bound text in the parser actions (not run)

Strip the four characters from both ends of each bound before the * check and getRangeQuery.

Trade-offs

Fixes the silent cases (a character at either end of a bound) if it runs before the * check. 20200101<TAB>TO<TAB>20201231 is still one token, so the ParseException stays; and the lexer and the grammar's SKIP rule keep disagreeing.

Not recommended: document that only U+0020 separates range parts

Say so in the classic package docs and the StandardQueryParser javadoc. Not run.

Trade-offs

Changes no output, leaves the silent bound changes, and contradicts the grammar's own Range-state SKIP.

Downstream note

Search applications and servers that hand user-typed query text to these parsers inherit the bug (Solr's fork of the classic grammar has the same RANGE_GOOP line); building range clauses through the query API avoids it.

How it was found

A program compared the character lists of both grammars and found that the bound token's exclusions miss four of the five declared whitespace characters. A bounded-exhaustive check of the real generated lexers then showed whitespace inside a token only there, and a public-API JUnit reproduced it.

The method in full206 words

Method: static comparison of the grammars' lexer tables, then bounded-exhaustive checking of the generated token managers (the study's "semi-formal" tier; the finder tagged it formal). A hunt unit, query-parser whitespace tables versus Unicode White_Space, 2026-10-28.

  • Table diff. A custom program (not a solver) extracted the character classes from QueryParser.jj and StandardSyntaxParser.jj and checked set obligations between them. Obligation O3, "the grammar's _WHITESPACE is a subset of the RANGE_GOOP exclusions", was violated for U+0009, U+000A, U+000D and U+3000 in both grammars. The signal came from this leg.
  • Lexer check. All 111,150 inputs of length 1 to 4 over an 18-symbol alphabet, run through the real generated token managers per lexer and lexical state: the grammar's own whitespace ends up inside a token only in the Range state and only in RANGE_GOOP; the DEFAULT state is clean. With the regenerated (fixed) token managers the property holds in all four lexer/state pairs.
  • Parser sweep. A metamorphic sweep (replace a space by each whitespace character and compare the parsed query) found 720 mismatches in 2,358 hard-oracle checks, all in the 10 range templates.
  • Then a public-API JUnit on the stock main jars, followed by independent reproduction, cross-model reviews, a devil's advocate, an adjudication and an audit (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (search of 2026-10-28): a scripted prior-report search (51 queries, 1,789 hits), about 20 manual searches in apache/lucene plus downstream repositories, all 411 open PRs, JIRA, the mailing lists and the web. Open #13234 and closed PR #13887 change the same line for escapes, a different defect.

Searches5 searches

Nothing reports that a TAB, LF, CR or U+3000 inside a [..] / {..} range ends up in a bound or breaks the parse. The RANGE_GOOP line is unchanged on upstream main 20ced71c1e6e, branch_10x and branch_10_6, and no open PR touches either grammar.

Related, not the same bug: open #13234 (escaped brackets inside an unquoted range bound fail with the same Was expecting: "TO" message) and closed-unmerged PR #13887 (its proposed RANGE_GOOP adds escapes and still swallows these characters); merged test PR #13323 (the remark that range terms are lexed differently); #1171 / LUCENE-93 (newlines between clauses, 2004); #2602 / LUCENE-1528 (U+3000); #8872 / LUCENE-7821 (TO made mandatory); open #4765 / LUCENE-3691 (a query of only U+3000, no range). You may prefer to post this as a comment on #13234 that proposes one combined fix.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, mailing lists, JIRA archive2026-10-28two runs of a scripted prior-report search, 51 queries each (for example RANGE_GOOP, range query newline parse, range tab ParseException, range query ideographic space, multiline range query), comments read for the top hitsno report; the follow-up's run: 1,789 distinct hits, 0 high overlap
GitHub, manual (apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet)2026-10-28about 20 queries in apache/lucene plus searches in the four downstream repositories, for example RANGE_GOOP, _WHITESPACE, RANGEIN_GOOP, range tab / newline queryparser, ideographic space, "Was expecting" "TO" rangeonly the related items above
Open PRs and recent work2026-10-28the changed files of all 411 open apache/lucene PRs; issues and PRs updated since 2026-10-25; re-checkednone touches QueryParser.jj, StandardSyntaxParser.jj or the token managers
JIRA (LUCENE, SOLR) and lists.apache.org2026-10-28JIRA REST queries; dev@ and solr-user@ mails that mention RANGE_GOOP scanned for tab, newline, CR and U+3000; java-user@ and users@solrno report; the 2002 dev@ thread with the design statement
Web2026-10-2812 queries in English (one CJK-language query was throttled)no report

History

Introduced with the TO range syntax in 2002 (8ab0396cfcc, first released in 1.3-rc1) for TAB; LF and CR joined in 2004 (LUCENE-93), U+3000 and the flexible parser in 2.9.0. A history probe found all 160 releases it ran, 1.3-rc1 to 10.5.2, affected (the 9 without a published jar have the same rules); 1.2 is not.

Versions tested10 rows

Introduced by 8ab0396cfcc (svn r149785, 2002-06-25, "Support for new range query syntax. The delimiter is " TO ", but is optional for backward compatibility with previous syntax."; no issue; CHANGES 1.3 RC1 item 5). It replaced the one-token range, whose inside the analyzer split, with range lexical states, extended the whitespace SKIP to them (whitespace was then " " and TAB) and defined bound tokens that exclude only " ". Widened without touching the bound token by 9ada9671259 (2004, LUCENE-93 / #1171: LF and CR), 13d5245314e (2009, LUCENE-1528 / #2602: U+3000) and 343992fcbb4 (2009, LUCENE-1567 / #2641: the flexible parser, with a copy of the rules). LUCENE-996 (c8c9ee2cb20, 2010) and the 2011 flexible-grammar commit cffd01664bc merged the two bound tokens into today's RANGE_GOOP with the same exclusions. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 5 of 6 fail (gradle and standalone, assertions on and off); the finder's 4 of 4
branch_10_6 4ae946e5a9b9 affectedthe finder's test 4 of 4 and the independent reproducer's 6 of 7 fail
10.5.2, 9.12.3 affectedthis doc's test 5 of 6 fail (release jars, SHA-1 checked)
2.9.0 to 10.5.2 (147 releases) affectedhistory probe: TAB, LF, CR and U+3000, classic and flexible parser
1.4.1, 1.4.3, 1.9.1 to 2.4.1 TAB, LF, CRhistory probe: classic parser (U+3000 not yet whitespace, no flexible parser)
1.3-rc1, 1.3 TAB onlyhistory probe on the release jars (LF, CR, U+3000 not yet whitespace)
1.2 no TO syntaxthe old one-token range; not affected
8ab0396cfcc vs its parent b1b3d33cf77 bisectthe commit is affected for TAB; its parent is not (both grammars regenerated and run)
9ada9671259 vs its parent cf06b516bc0 bisectTAB before; TAB, LF and CR after
main + fix fixedthis doc's test 6 of 6; 539 queryparser tests pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication confirmed it with their own runs, the adjudication with high confidence; reviews by another model family could not start any command and rated the intended-behaviour case weak. A registry re-run, an audit, this doc's own gradle runs and its fact check reproduced it.

Checks and verdicts569 words

Every check below ran its own test or the repro against the stock jars unless noted. Still open: a cross-model review that executes code.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 4 of 4 on main (assertions on and off), branch_10_6, 10.5.2 and 9.12.3; passes with the regenerated token managers; 14 upstream queryparser classes (323 tests) still pass.
cross-model review, refute lens procedural "not real" (low), not a refutationAn independent reviewer from another model family (OpenAI GPT-5.6) could not run any command (two attempts), so it ran and read nothing; it says the verdict is procedural. Its one counter-argument (the ASCII-space ' TO ') is weak. A separate run re-ran the repro: 4 of 4 fail.
Independent reproducer realOwn 7-test JUnit, written from the claim alone: 6 of 7 fail on main (assertions on and off), branch_10_6, 10.5.2 and 9.12.3, the control passes; 7 of 7 with the fix. Checked that #13887's proposed rule would not fix it.
Cross-model review, intended lens real (low, reading only)OpenAI GPT-5.6: intended-behaviour argument weak, root cause agrees, inputs in contract; it could not run any command, so it judged only from the material in its prompt; a separate run re-ran the repro (4 of 4 fail) and a probe on main.
Devil's advocate real (high)Re-ran the repro (4 of 4, assertions on and off); its probe on main and 10.5.2 covers four parsers, quoted bounds, CRLF and the open bound; found the 2002 design statement on dev@.
adjudication confirmed (high)Re-ran the independent reproduction's tests with a new seed: 6 of 7 fail with assertions on and off; security none.
security assessment not security-sensitiveA parse-correctness bug in the author's own range clause; a probe shows each widened range is a subset of a legal range and a query-API filter still applies.
registry re-run and audit keepThe finder's test fails 4 of 4 again with new seeds (assertions off, then on); the audit's own probe confirms the bounds in both parsers.
this doc's runs reproduced; fix validatedA new 6-test JUnit through gradle and the standalone runner on main, 10.5.2 and 9.12.3 (5 of 6 fail); the fix validated against the whole queryparser module with assertions on and off.
independent fact check of this doc bug holds; 3 errors, fixedRe-ran the gradle repro and the standalone runner on main, 10.5.2 and 9.12.3 (5 of 6 fail, assertions on and off), regenerated the fix (a byte-identical diff; 539 tests pass) and probed six parsers. It found three errors in the doc: the printed test failed the format check, #13887's closing reason was wrong, and the silent trigger was stated too narrowly. All three are fixed.
cross-model fact check (OpenAI GPT-5.6, reading only) substantially sound; 2 errors, 4 overclaims, 4 missing caveatsWorked from the saved material only. Fixed: StandardAnalyzer is the caller's choice, not a default; the refute review could not read source either; four overclaims; caveats on the analyzer and date handling, the license header and the parser scope. The seed caveat is answered from the code. It rated the intended-behaviour case moderate.
diagram check 1 layout error, fixedRead every frame against the code and the diagram's executed probe (no run of its own): every value matched. One clipped footnote in section 5 was wrapped.

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,667 words

Finding record

LFV-845, snapshot of 2026-11-06

Registry title
Classic QueryParser and flexible StandardQueryParser: a TAB, LF, CR or U+3000 next to a range bound inside [..] or {..} becomes part of the bound, because RANGE_GOOP excludes only U+0020 although both grammars declare these characters as whitespace and skip them in the Range state; such ranges fail to parse or, when the character touches a bracket, silently change their bounds
Area
queryparser / classic and flexible standard grammars (Range lexical state, RANGE_GOOP token)
Priority
Low, rank 532: It hits the default classic and flexible parsers in every release, but the input is rare (a TAB, line break or U+3000 inside a range), the usual result is a loud ParseException and bounds change silently only when the character touches a bracket.
Impact class
silent wrong results
Impact note
Applications that parse query text with the classic QueryParser (or MultiFieldQueryParser, ComplexPhraseQueryParser, ExtendableQueryParser), StandardQueryParser or PrecedenceQueryParser in their default configuration are exposed whenever a range is written with a TAB, a line break or a full-width space (U+3000, as typed by CJK input methods) next to a bound: the common outcome is a ParseException for a tab-separated or multi-line range, and the silent outcome (changed bounds: extra hits, an exclusive endpoint admitted, or no hits at all) needs the character directly after '[' / '{' or directly before ']' / '}'; no scale is needed, and every release since 1.3-rc1 is affected [exec].
Summary
You parse 'date:[20200101<TAB>TO<TAB>20201231]' or the multi-line 'date:[<LF>20200101 TO 20201231<LF>]' with the classic or the flexible standard query parser; both grammars declare TAB, LF, CR and U+3000 as whitespace and skip it inside ranges too, so you expect the same query as with spaces. Instead the range-bound token RANGE_GOOP, which excludes only U+0020 and the closing brackets, swallows any of these characters that touches a bound: the first query throws ParseException ('Was expecting: "TO"'), and the second parses without error into a TermRangeQuery whose bounds are '<LF>20200101' and '20201231<LF>', so it also matches 20191231 (4 hits instead of 3). In the same way 'date:{20200101 TO 20201231<TAB>}' admits its own exclusive endpoint, and a leading U+3000 makes the range match nothing.
Introduced
8ab0396cfccdafe51e6b741ea8e35a6491e1aa09 (svn r149785, author Brian Goetz, 'Support for new range query syntax. The delimiter is " TO ", but is optional for backward compatibility with previous syntax.'; no issue or PR; CHANGES.txt 1.3 RC1 item 5): it replaced the one-token range, whose inside the analyzer split, with the RangeIn/RangeEx lexical states, extended the whitespace SKIP rule to them ('<DEFAULT, RangeIn, RangeEx> SKIP', _WHITESPACE then ' ' | TAB) and defined RANGEIN_GOOP (~[' ', ']'])+ and RANGEEX_GOOP (~[' ', '}'])+, which exclude only U+0020, so a TAB next to a bound has been part of the bound since then [exec: commit-level, the grammars of the parent b1b3d33cf77 and of 8ab0396cfcc regenerated with JavaCC 7 in front of the 1.2 jar: parent not affected, 8ab0396cfcc affected for TAB]. Widened without touching the goop rule by 9ada9671259 (2004, LUCENE-93 / #1171, Bugzilla 18847: LF and CR added to _WHITESPACE) [exec: commit-level against its parent cf06b516bc0], 13d5245314e (2009, LUCENE-1528 / #2602: U+3000 added) [read; bracketed by the releases 2.4.1 and 2.9.0, exec] and 343992fcbb4 (2009, LUCENE-1567 / #2641: the flexible parser created with a copy of the same token rules) [read]; c8c9ee2cb20 (LUCENE-996, 2010) and cffd01664bc (2011, flexible grammar) merged the two goop rules into RANGE_GOOP (~[' ', ']', '}'])+ with the same exclusions [read] (2002-06-25 (author date of 8ab0396cfcc, TAB); widened 2004-03-03 (LF, CR), 2009-01-28 (U+3000) and 2009-08-03 (flexible parser))
First affected version
1.3-rc1 [exec] (TAB; LF and CR from 1.4-rc1 [read], first on Maven Central 1.4.1 [exec]; U+3000 and the flexible parser from 2.9.0 [exec]); 1.2 [exec] is not affected because the TO range syntax did not exist yet
Affected versions
Every release from 1.3-rc1 to 10.5.2 (latest 9.x: 9.12.3; latest 10.x before 10.6.0: 10.5.2), the branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) and main a9779ef3c193; not fixed upstream (the RANGE_GOOP line is unchanged on upstream main 20ced71c1e6e, branch_10x and branch_10_6, 2026-10-28 [read]). [exec] history probe (one class, public parse API through reflection): 1.3-rc1, 1.3, 1.4.1 and 1.4.3 (plain JDK runs on SHA-1-checked Maven Central jars) and all 156 releases from 1.9.1 to 10.5.2 through repro.sh are affected, 1.2 is not; the finder's JUnit fails 4/4 on main (-ea and -da), the branch_10_6 head, 10.5.2 and 9.12.3 (it does not compile on 9.0.0, older test-framework package); the independent reproduction's JUnit fails 6/7 on main (-ea and -da), the branch_10_6 head, 10.5.2 and 9.12.3. Characters affected: TAB in 1.3-rc1 and 1.3; TAB, LF and CR in 1.4-rc1 to 2.4.1 (classic only); all four in 2.9.0 to main, classic and flexible. [read] 1.4-rc1 to 1.4 final and 1.9 (not on Maven Central; same token rules in their trees). All runs JDK 25 on aarch64; the defect is in the generated lexer tables, so no platform dependence is expected (not executed on x86)
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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record verdict new; re-checked 2026-10-28): no issue, PR, commit, JIRA entry, mailing-list thread or downstream report says that a TAB, LF, CR or U+3000 inside a [..] / {..} range is swallowed into a bound or breaks the parse; the RANGE_GOOP line is unchanged on upstream main 20ced71c1e6e, branch_10x and branch_10_6, and no open PR touches either grammar or the generated token managers. Related, a different defect on the same token rule: open #13234 (2024-03-28, escaped brackets inside an unquoted range bound fail to parse with the same 'Was expecting: "TO"' message) and closed-unmerged PR #13887 (GitHub benchaplin, closed 2026-03-04), whose proposed RANGE_GOOP adds escapes and would not fix this bug; neither mentions TAB, LF, CR, U+3000 or line breaks. Context: merged test-only PR #13323, where GitHub dweiss remarked that range terms are lexed differently from normal tokens and 'logically' should accept the same term notation (said about escapes); LUCENE-93 (#1171, LF/CR to be treated like TAB and space, clause separation only), LUCENE-1528 (#2602, U+3000; its review discussed keeping two whitespace lists in sync and missed this third one) and LUCENE-7821 (#8872, ' TO ' made mandatory, no whitespace discussion); open LUCENE-3691 (#4765, a query consisting only of U+3000, no range) is unrelated. Searched: a scripted prior-report search (51 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive: 1,789 distinct hits, 0 high overlap), the finder's 51-query run, about 30 manual GitHub searches (apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet), the changed files of all 411 open apache/lucene PRs, JIRA REST (LUCENE, SOLR), lists.apache.org and 12 web queries. Gaps: one rate-limited GitHub query (its terms covered by others), one throttled CJK-language web query, and private reports to security@lucene.apache.org are not visible. Downstream: Solr's fork of the classic grammar has the same RANGE_GOOP and Range-state SKIP [read].
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars; the branch_10_6 head 4ae946e5a9b9 (the finder's and the independent reproducer's tests); a reflection-only history probe on 1.2 (not affected), 1.3-rc1, 1.3, 1.4.1, 1.4.3, all 156 releases from 1.9.1 to 10.5.2, plus commit-level runs of the introducing commit and its parent (follow-up). The fix was validated with gradle on a9779ef3c193. After the fact checks the test file as printed was run again (gradle with assertions on and off, stock and with the fix; the standalone runner on main, 10.5.2 and 9.12.3). Upstream main 20ced71c1e6e, branch_10x and branch_10_6 have the same RANGE_GOOP line (read, not executed).

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:183-185 (at a9779ef3c193) (_TERM_START_CHAR excludes ' ', TAB, LF, CR, U+3000), :187 (_WHITESPACE = ' ' | TAB | LF | CR | U+3000), :191-193 ('<DEFAULT, Range> SKIP : { <_WHITESPACE> }'), :226 (RANGE_GOOP: (~[ " ", "]", "}" ])+), :343-347 (range production goop1 RANGE_TO goop2)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:135 (at a9779ef3c193) (_WHITESPACE), :139-141 ('<DEFAULT, Range, Function> SKIP'), :200 (RANGE_GOOP, same definition), :855-859 (TermRangeExpr)
  • lucene/queryparser/src/test/org/apache/lucene/queryparser/util/QueryParserTestBase.java:250 (at a9779ef3c193) (testCJK, U+3000 separates clauses), :896 (testTabNewlineCarriageReturn); lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestQPHelper.java:324 (testCJK), :937 (testTabNewlineCarriageReturn)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/package-info.java:74-75 (at a9779ef3c193) ('the query parser syntax may change from release to release'), :82-95 (parser designed for human-entered text; range and other program-generated clauses better built through the query API)
The finding in 30 seconds
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:183-185, (at a9779ef3c193) :187, :191-193, :221-227 (:226 RANGE_GOOP), :343-347, :354, :359
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:131-135, (at a9779ef3c193) :139-141, :195-201 (:200 RANGE_GOOP), :855-859, :870-873
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:542-561; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:99-101
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/MultiFieldQueryParser.java:31; (at a9779ef3c193) complexPhrase/ComplexPhraseQueryParser.java:61; ext/ExtendableQueryParser.java:66; flexible/precedence/PrecedenceQueryParser.java:35
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/surround/parser/QueryParser.jj:192-237 (at a9779ef3c193) (lexical states DEFAULT and Boost only); queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:47-58 (operators, no range)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.java:91 (at a9779ef3c193) (the constructor takes the analyzer); queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:235, :255-258
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:447-481 (at a9779ef3c193) (getRangeQuery: date parse, catch at :456-458); queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:101-141; core/src/java/org/apache/lucene/document/DateTools.java:90-92, :150 (switch on the resolution)
Why it looks unintended
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:187, (at a9779ef3c193) :191-193, :226
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:135, (at a9779ef3c193) :139-141, :200
  • lucene/queryparser/src/test/org/apache/lucene/queryparser/util/QueryParserTestBase.java:250-255 (at a9779ef3c193) (testCJK), :637 (" TO " comment), :896-915 (testTabNewlineCarriageReturn)
  • lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestQPHelper.java:324-329 (at a9779ef3c193) (testCJK), :937-956 (testTabNewlineCarriageReturn)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/package-info.java:74-75, (at a9779ef3c193) :82-95, :225-239 (Range Searches)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/StandardQueryParser.java:145-158 (at a9779ef3c193) (Range operators)
  • GitHub, read-only: apache/lucene PR #13323 (dweiss's comment), issue #8872 (LUCENE-7821), issue #2602 (LUCENE-1528)
Suggested fix
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/package-info.java:74-75 (at a9779ef3c193)

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

Repro files

About the diagrams

A light, step-by-step picture of the repro, made in parallel with this doc from executed probe runs on main a9779ef3c193 (stock and with the fix) and the code; it ends on a summary card, which is the PNG.

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.