All findings · Lucene finding · LFV-359 · simple-parser-phrase-slop-skips-next-operator

SimpleQueryParser skips the operator typed directly after a phrase slop, so "a b"~2+c means phrase OR c

In SimpleQueryParser + means AND, but typed directly after a phrase slop it is skipped: "new york"~2+pizza becomes "new york"~2 OR pizza and matches 3 documents instead of 1, with default settings, no error, in every release since 4.7.0.

Summary

Priority
Low, rank 263Default SimpleQueryParser syntax, but only an operator glued directly after phrase-slop digits triggers it, and a space avoids it.
Impact
silent wrong results
Since
4.7.0 (LUCENE-5410, 2014)introduced 2014-02-02 · over 12 years
Affected
4.7.0 to 10.5.2 and main
Status
documented
Found by
code reading, confirmed by exhaustive checks on short query strings

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

SimpleQueryParser lets you glue operators to tokens, as in token1+token2. After a phrase slop such as ~2, the parser skips the next character, so a glued +, |, quote or ( loses its meaning.

Summary card titled "Lucene SimpleQueryParser drops the operator typed right after a phrase slop". Panel 1: parser.parse of "a b"~2+c on field f with default operator OR, on the documents d0 "a b" and d1 "c": expected +f:"a b"~2 +f:c with 0 hits, actual f:"a b"~2 f:c with 2 hits (d0, d1); the + is lost and AND became OR. Panel 2: the characters of the query at positions 0 to 8; parseFuzziness reads the 2 and stops on the + at position 7, a token delimiter, and the ++state.index at line 362 moves the index from 7 to 8, so the + is never parsed; without a slop that line steps over the closing quote. Panel 3: the same skip on main for "a b"~2|c under default AND (OR became AND), "a b"~2"c d" (two loose terms) and "a b"~2(c+d) (group lost); not affected: a space after the digits, the fuzzy term a~1+b, and a ) that closes a group. Panel 4: the fix at line 362, if (hasSlop == false) { ++state.index; }; every release from 4.7.0 to 10.5.2 and main; no error: silently wrong hits.
The repro "a b"~2+c on two documents, the characters with the parser's position (the slop parse stops on the + at position 7, then line 362 steps to 8), the other characters skipped the same way, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents: "new york pizza", "new york bagels" and "chicago pizza". Parse "new york"~2+pizza with SimpleQueryParser and its default settings: the phrase new york with up to 2 positions of slop, AND pizza.
  2. You expect 1 hit, "new york pizza". With a space, "new york"~2 +pizza, or without the slop, "new york"+pizza, that is exactly what you get.
  3. Instead you get 3 hits. The parser reads the slop digit 2 and stops on the +; then a leftover step, meant to skip the phrase's closing quote, skips the +. The query is built as phrase OR pizza.
  4. The same happens to | (with default operator AND, an OR becomes an AND), to a quote (the next phrase becomes loose words) and to ( (the group is lost). No error, with assertions on or off.
  5. A space after the slop digits avoids it. A fuzzy term (a~1+b) and a ) that closes a group are not affected.

Who is affected

Every SimpleQueryParser user with the default flags (the near operator is on by default) who types a sloppy phrase followed, with no space, by +, |, a quote or (. No scale is needed. By code reading, parsers built on this class downstream inherit it. The shape is rare, and the searches found no prior report.

Mechanism, terms and numbers761 words

SimpleQueryParser's javadoc says that whitespace and ( ) + | " delimit tokens, that a phrase is a token, that token1+token2 is AND and token1|token2 is OR (written without spaces), and that "term1 term2"~5 is a near query. When a phrase with a slop is followed directly by one of those delimiters, the parser skips the delimiter: "a b"~2+c becomes f:"a b"~2 f:c (OR) instead of +f:"a b"~2 +f:c, although "a b"~2 +c and "a b"+c both give the AND. On the documents "new york pizza", "new york bagels" and "chicago pizza", "new york"~2+pizza matches 3 documents instead of 1.

The cause is an off-by-one in consumePhrase: parseFuzziness reads the slop digits and stops on the delimiter after them, and the unconditional ++state.index that follows, written in 2013 to step over the closing quote, then steps over that delimiter. It needs the default flags only, raises no error, and is in every release since 4.7.0 (2014), the first with the ~ operator. The fix is one condition: skip the closing quote only when there was no slop.

Terms

  • Slop (near query). "a b"~2 is a phrase whose words may be up to 2 positions apart (PhraseQuery(2, "f", "a", "b")). An empty ~ followed by another character means slop 2 (since 6.5.0); a ~ at the very end of the input means no slop.
  • Default operator. What two tokens with nothing between them combine with: OR, unless setDefaultOperator(MUST).
  • Delimiter. A character that ends a token: whitespace, ", |, +, ( or ) (javadoc SimpleQueryParser.java:41-42, tokenFinished :536-550).

What the parser builds (field f)

inputdefault operatorexpected (= the spaced form)on main
"a b"~2+cOR+f:"a b"~2 +f:cf:"a b"~2 f:c
"a b"~+c (empty slop = 2)OR+f:"a b"~2 +f:cf:"a b"~2 f:c
"a b"~2|cANDf:"a b"~2 f:c+f:"a b"~2 +f:c
"a b"~2"c d"ORf:"a b"~2 f:"c d"f:"a b"~2 f:c f:d
"a b"~2(c+d)ORf:"a b"~2 (+f:c +f:d)+(f:"a b"~2 f:c) +f:d
"new york"~2+pizza on {"new york pizza", "new york bagels", "chicago pizza"}OR1 hit3 hits

The expected column is what the javadoc's operators define and what the same input with a space after the slop gives on main; the slop 2 of an empty ~ is the parser's own rule, not a javadoc statement. Not affected: "a b"+c (no slop), the fuzzy term a~1+b, a ) that closes a group right after the slop ((x|"a b"~2)+c), and an extraneous ) ("a b"~2)c), which is ignored either way.

Further cases (executed on main, 10.5.2 and with the fix):

  • A glued + under default AND, or | under default OR, is skipped too. When a token follows, the default operator then gives the same query; when a second operator follows, it does not, because the parser keeps the first explicit operator: under default AND "a b"~2+|c gives OR on main and AND with a space or the fix.
  • The skip also follows slop text that does not parse: "a b"~x+c gives slop 0 and loses the + (f:"a b" f:c), and so do "a b"~1a+c and "a b"~2\+c.
  • An empty phrase: a""~+a gives f:a f:a instead of +f:a +f:a.

The mechanism (main ea57360912ed)

  1. consumePhrase (SimpleQueryParser.java:296-364) reads the phrase up to its closing quote. If ~ and more characters follow, it moves onto the ~ and sets hasSlop (:317-324).
  2. parseFuzziness (:495-533, called at :356) advances from the ~ and stops at the first delimiter (:505-507), so the index is left on the +.
  3. consumePhrase then always runs ++state.index (:362). That line dates from 2013, when the index was always on the closing quote there. After a slop it skips the +, and the parse loop never sees it.
  4. The lost + or | leaves the next token to the default operator; a lost quote turns the next phrase into separate terms; a lost ( merges the group into the main sequence.
  5. consumeToken calls the same parseFuzziness for fuzzy terms (:410) with no trailing increment, so a~1+b keeps its AND. A group's ) is safe because consumeSubQuery parses the group's inner text as its own substring (:288-289).
// SimpleQueryParser.java:355-362
if (hasSlop) {
  branch = newPhraseQuery(phrase, parseFuzziness(state)); // returns with the index ON the delimiter
} else {
  branch = newPhraseQuery(phrase, 0);
}
buildQueryTree(state, branch);

++state.index; // meant to step over the closing quote; after a slop it skips the delimiter
Exposure and likelihood298 words
  • Who is exposed. Every caller of SimpleQueryParser with the default flags: the two-argument and the weights constructors pass -1 (SimpleQueryParser.java:141-148), which includes NEAR_OPERATOR. Nothing has to be configured. There is no other in-tree caller outside the parser's own package.
  • What it takes. A +, |, " or ( typed directly after the slop digits, after an empty ~, or after slop text that does not parse (~x). Glued operators are the documented style of this syntax (token1+token2), so users who write "new york"~2+pizza, and code that builds query strings by concatenation, can produce it.
  • What goes wrong. A query of the wrong structure, silently. Under default OR it over-matches: AND becomes OR, AND NOT becomes OR NOT, a phrase becomes loose terms. Under default AND a glued | under-matches: OR becomes AND, while a lost quote still widens the query (the second phrase becomes required loose terms: 2 hits instead of 1 on the documents "a b c d" and "a b d c"). A lost ( can go either way. No exception, crash or index change.
  • Downstream (by code reading only, not run). Elasticsearch's and OpenSearch's simple_query_string parsers and Solr's simple query parser extend SimpleQueryParser; consumePhrase is private, so they inherit the parse loop.
  • How likely. Rare: slop is an advanced feature, the upstream test writes "foo bar"~12 baz with a space, the most natural glued case (a ) closing a group) is unaffected, and the searches found no prior report (see Prior reports).
  • Opt-out. The targeted opt-out is turning NEAR_OPERATOR off, which disables phrase slop altogether; turning off the phrase operator or the operator that follows also removes the path, but disables more syntax. The practical workaround is a space after the slop digits.

Minimal repro

The test shows that SimpleQueryParser skips the + typed directly after a phrase slop, so "a b"~2+c does not parse like "a b"~2 +c.

TestSimpleQueryParserSlopOperator.java (JUnit test)29 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.simple;

import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSimpleQueryParserSlopOperator extends LuceneTestCase {

  public void testAndDirectlyAfterPhraseSlop() {
    SimpleQueryParser parser = new SimpleQueryParser(new MockAnalyzer(random()), "field");
    // expected: "a b"~2+c parses like "a b"~2 +c (phrase AND c); actual: the '+' is skipped (phrase OR c)
    assertEquals(parser.parse("\"a b\"~2 +c"), parser.parse("\"a b\"~2+c"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-359/, 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.simple.TestSimpleQueryParserSlopOperator
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected +field:"a b"~2 +field:c (phrase AND c); actual field:"a b"~2 field:c (phrase OR c), with -ea and -da.

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

More files, environment, full output38 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:<+field:"a b"~2 +field:c> but was:<field:"a b"~2 field:c>
       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.simple.TestSimpleQueryParserSlopOperator.testAndDirectlyAfterPhraseSlop(TestSimpleQueryParserSlopOperator.java:27)

Why it looks unintended

  • The javadoc defines token1+token2 as AND and "term1 term2"~5 as a near query, so "a b"~2+c is valid syntax.
  • The parser itself stops the slop at the +, and keeps it after a space, after a phrase without slop and after a~1+b.
  • The skipping step is older than the slop feature: it was written to skip the closing quote.
The full argument251 words
  • Contract. SimpleQueryParser.java:40-42: "Tokens are considered to be any of a term, phrase, or subquery ... Whitespace ... and certain operators may be used to delimit tokens ( ) + | \" ." :50-51: "'+' specifies AND operation: token1+token2", "'|' specifies OR operation: token1|token2". :56: "'~N' at the end of phrases specifies near query: \"term1 term2\"~5".
  • Upstream tests. testORPhrase requires "foo bar"|"star wars" to be an OR although the test helper's default operator is AND, so an operator glued to a phrase must be honoured (testANDPhrase agrees, but under that default it cannot tell a kept + from a dropped one). No test covers an operator glued to a slop; testPhraseWithSlop checks "foo bar"~12 baz with a space, which the skip does not harm, so it is not evidence either way. With the fix all 46 TestSimpleQueryParser tests pass.
  • The parser's own consistency. parseFuzziness ends the slop exactly at the + because tokenFinished says it is a delimiter, and then the delimiter is discarded. The fuzzy-term path calls the same helper without the extra step and keeps the operator.
  • History. The ++state.index came with the class (LUCENE-5336, 2013), when the index was always on the closing quote at that point. LUCENE-5410 (2014) put the slop branch in front of it without changing it. Its review noted that the term and phrase paths differ "in how they consume/increment state.data and state.index", but no comment discusses an operator after a slop. No CHANGES, MIGRATE or javadoc text describes the skip.
The devil's-advocate check246 words

The strongest "works as designed" case (the GPT-5.6 reviews, the devil's advocate and the adjudication looked for it): the javadoc promises only a best effort, "Any errors in query syntax will be ignored and the parser will attempt to decipher what it can; however, this may mean odd or unexpected results", and slop parsing is lenient on purpose ("foo bar"~1a and "foo bar"~-1 silently give slop 0 in an upstream test). A maintainer could call an operator glued to a slop badly composed input. They could also point out that no upstream example or test puts an operator right after a slop, that - is not a delimiter and is swallowed into the slop text, and that the behaviour has not changed in twelve years, so a stored query could depend on it.

Why it fails: the input is not a syntax error, since the javadoc defines every piece of it; the parser itself recognizes the + as the end of the slop and then throws it away; the same delimiter is honoured after a space, after a phrase without slop and after a fuzzy term; the lenient tests are about the slop text itself, while the + after a valid ~2 is outside it; and the cause is a leftover increment from before the slop feature, not a decision. Twelve years of the same behaviour argue for a CHANGES entry, not for keeping a skip that no documentation describes. Verdict: not intended, a defect.

Suggested fix

Recommended Run the trailing increment only when there was no slop

Skip the closing quote only when there was no slop: wrap the trailing ++state.index in consumePhrase in if (hasSlop == false). Then the new test passes 7 of 7, the existing 46 TestSimpleQueryParser tests still pass, and so does the whole queryparser module (540 tests, 0 failures).

      buildQueryTree(state, branch);

-      ++state.index;
+      if (hasSlop == false) {
+        // skip the closing quote (or a trailing '~'); after a slop, parseFuzziness already left the
+        // index on the delimiter that ended it, which must still be parsed, or at the end of input
+        ++state.index;
+      }
    }
  }
All options and trade-offs4 options · 569 words

One fix was built and validated in a scratch worktree at ea57360912ed (option A), together with a variant that moves the same line (option B).

Run the trailing increment only when there was no slop (validated by the checks) (recommended)

fix.diff, +5/-1 in SimpleQueryParser.java. Without a slop the index is on the closing quote (or a trailing ~ at the end of the input) and must be skipped as before. With a slop, parseFuzziness has already consumed the ~ and the digits and stands on the delimiter or at the end of the input, so nothing is left to skip; the parse loop then handles the delimiter as it does after a space.

      buildQueryTree(state, branch);

-      ++state.index;
+      if (hasSlop == false) {
+        // skip the closing quote (or a trailing '~'); after a slop, parseFuzziness already left the
+        // index on the delimiter that ended it, which must still be parsed, or at the end of input
+        ++state.index;
+      }
    }
  }

Trade-offs

  • The resulting query changes only when a +, |, " or ( follows the slop text: after a valid or empty slop (the bug), and also after slop text that does not parse ("a b"~x+c), which then parses like its spaced form. Whitespace, an extraneous ) and the end of input give the same query as before (executed).
  • A stored query written in the glued shape and tuned to today's result would now parse as documented, so a CHANGES entry under bug fixes is worth adding.
  • Not reviewed by a committer.

What the checks validated

  • Stock: this doc's test fails 6 of 7 with assertions on and off; with the fix all 7 pass.
  • The simple package (TestSimpleQueryParser 46 tests and the new test), assertions on and off: 53 tests, 0 failures.
  • The whole queryparser module: 31 classes, 540 tests, 0 failures, 1 skipped.
  • :lucene:queryparser:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Every earlier hunt test with the fix shadowed on the main jars passes (the finder's 4, the independent reproducer's 6 and its empty-slop test, the cross-model review stage's 5, the devil's advocate's 3), and the security probe gives the spaced results.
  • The finder's two exhaustive harnesses with the same one-condition change report 0 mismatches (10,047,388 and 2 x 11,413,636 checks).
  • After the fact checks the patch's code comment was reworded (comment only): both diffs apply to ea57360912ed, google-java-format finds nothing to change, and this doc's test passes 7 of 7 with it, assertions on and off.

Move the increment into the no-slop branch (same logic) (validated by the checks)

option-b-move-increment.diff: put ++state.index in the else branch next to newPhraseQuery(phrase, 0). It reads as "skip the closing quote where it is still the current character".

Trade-offs

Same behaviour as the recommended option; a matter of style.

What the checks validated

The simple package passes: 53 tests, 0 failures (assertions on).

Make parseFuzziness consume the delimiter

Change the shared helper instead of the phrase path.

Trade-offs

Not recommended and not tested: the fuzzy-term caller relies on the index being left on the delimiter, so it would break a~1+b.

Workaround in every affected release

Put a space after the slop digits ("a b"~2 +c); applications that build query strings should do the same, and keep access restrictions in a separate FILTER clause.

Trade-offs

Avoids the symptom; does not fix the parser.

Downstream note

The parser returns the whole query, so a restriction written in this syntax inherits the misparse. A glued + turns its AND into OR and it fails open: in the probe 7 of 8 documents match instead of 3. Only the rule's author creates the shape; a separate FILTER clause and the documented escaping contain it. No exploit known.

How it was found

Semi-formal. While building exhaustive checks of SimpleQueryParser, an AI tool spotted the extra step by reading the code. The checks then found it as the only mismatch: over all 2.4 million strings of up to 7 characters from an 8-symbol alphabet, a space next to an operator changed the query only after a phrase slop.

The method in full179 words

Method: bounded-exhaustive checking on the stock jar (the hunt's semi-formal tier; no model checker, SMT solver or prover).

  • In a hunt unit (query parsers, 2026-10-26) the finder, an AI tool, saw the off-by-one by reading consumePhrase while building two harnesses. Its first run, every string of up to 5 characters, found nothing: the bug needs at least 6.
  • Metamorphic check. "A space next to an operator does not change the query", over every string of up to 7 characters over a + | - " ~ 1 and space: 2,396,745 strings, 10,047,388 checks, 819 mismatches per default operator, every one a phrase followed by ~.
  • Translation validation. An independent reference semantics of the documented grammar, evaluated as match sets on all 40 documents of up to 3 tokens over {a, b, c}: 11,413,636 checks per default operator, 354,538 (OR) and 349,990 (AND) mismatches, every one an operator directly after phrase~N.
  • With the fix both harnesses report 0 mismatches. The finder then wrote a deterministic public-API JUnit; the independent checks, history sweep, prior-report search and security assessment followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 179 read-only queries and checks over Lucene issues and PRs, commits, mailing lists, JIRA, open-PR file lists, downstream trackers, Stack Overflow and the web. Closest: the 2014 change that introduced it; its review does not mention this case.

Searches4 searches

Verdict new: no issue, PR, JIRA entry, list thread, forum post or downstream report describes the parser dropping the character typed after a phrase slop, by anyone. No open PR touches the parser's package, and the newest upstream commits to the file are formatting changes. Limits: GitHub search drops punctuation, so the literal trigger cannot be searched; a report worded only as "AND is ignored" could be missed; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (migrated JIRA included), commits2026-10-26a scripted prior-report search (34 GitHub and commit queries) and 25 GraphQL searches; re-checkedonly #6473 (LUCENE-5410, the introducing change; all 20 comments read), #6400 (LUCENE-5336, origin of the increment), #8763 (empty slop means 2), #11687 (open, stack overflow on deep nesting) and unrelated parser issues
Open PRs2026-10-26the changed files of all 411 open apache/lucene PRs; a title searchnone touches the simple query parser package
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@ (40 queries); LUCENE and SOLR JQL (14 queries)no report; a 2018 java-user@ thread loses an operator through '-' with whitespace operators off, a different input and mechanism
Downstream, forums and the web2026-10-26Elasticsearch, OpenSearch, Solr and Lucene.NET issues and PRs (6 phrasings per repository, each as issue and PR search, plus 5 tool queries); 7 Stack Overflow queries; 12 web queries; 8 forum and list pages readno report; Elasticsearch #4985 is the 2014 copy of the introducing code

History

Introduced in 4.7.0 by LUCENE-5410 (2014), which added ~ to the parser and put the slop branch in front of an older step that skips the closing quote. Wrong on all 13 releases run, from 4.7.0 to 10.5.2, and on main; never fixed. 4.7.0 is the first release with the class at all.

Versions tested7 rows

Introduced by LUCENE-5410 "add fuzzy and near to SimpleQueryParser" (issue #6473; patch by @dakrone, committed by @rmuir on 2014-02-02 as 584fda3bdf0 on trunk and b18fdadedec on branch_4x, an SVN-era patch with no pull request). The skipping ++state.index is older: LUCENE-5336 (3c9e753df64, 2013-11-12) added it when the index was always on the closing quote at that point.

consumePhrase is unchanged in all 110 release tags of the history clone (4.7.0 to 10.5.2; the clone lacks 8.8.2 and 8.9.0 to 8.11.4, of which 8.8.2 and 8.11.4 were executed); parseFuzziness changed in 6.5.0 (an empty slop means 2), 9.0.0 and 9.10.0 without moving where the index stops. Not a regression. Upstream main (head f4cc6ac2097c, 2026-10-26) has no change to the file since df6f71a3729, a formatting commit already in the tested tree.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test 6 of 7 fail (gradle and harness, -ea and -da); portable probe fails
10.0.0, 10.5.2 affectedportable probe fails; this doc's test 6 of 7 fail on 10.5.2
9.0.0, 9.12.3 affectedportable probe fails; this doc's test 6 of 7 fail on 9.12.3
5.0.0, 6.4.2, 6.5.0, 6.6.6, 7.7.3, 8.8.2, 8.11.4 affectedportable probes fail (6.6.6 by the devil's advocate's probe, 8.8.2 by the fact check)
4.7.0, 4.10.4 first releaseportable probe fails; 4.7.0 is the first release with SimpleQueryParser and the '~' operator
4.6.1 API absentthe simple query parser package does not exist; the probe does not compile
fix on main not affectedthis doc's test 7 of 7, the simple package 53 tests and the queryparser module 540 tests pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication, each running tests on main, and a "works as designed" review from another model family (OpenAI GPT-5.6, reading only) say real. A refute review that could not run code said "not real" only procedurally. This doc's own test was run on main, 10.5.2, 9.12.3 and with the fix.

Checks and verdicts423 words

Every check ran on main ea57360912ed; the GPT-5.6 reviews could read code but not run it, and the harness that drove them re-ran the finder's repro (not a reproduction of their own).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 4 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; pass with the fix; upstream TestSimpleQueryParser 46 of 46 with the fix.
refute review (GPT-5.6, reading only) not real, low (procedural)Could not run code, so it voted not real on procedure only; it agreed with the root cause, found the inputs within the contract and rated the intended argument weak. A separate run re-ran the repro: 4 of 4 fail.
Independent reproducer realOwn JUnit from the claim alone: 5 of 6 fail on main, 10.5.2 and 9.12.3, adding the '(' case; the fuzzy-term control passes; 6 of 6 with the fix; the empty slop loses the '+' too.
"works as designed" review (GPT-5.6, reading only) real, mediumIntended argument weak: the disclaimer covers syntax errors and this input is valid syntax. A separate JUnit fails 4 of 5 on main and 9.12.3 and passes with the fix.
Devil's advocate real, highNo javadoc, test, CHANGES, MIGRATE or issue text makes the skip intended; its probe fails on 10.5.2, 9.12.3, 8.11.4, 6.6.6 and 5.0.0.
adjudication confirmed, highRe-ran the independent reproduction's JUnit: 5 of 6 fail with -ea and -da, 6 of 6 pass with the fix; not a duplicate.
Follow-up re-run and audit keepFresh seeds: the finder's test fails on main (-ea, -da) and 10.5.2 and the independent reproduction's on main (-ea, -da); both pass with the fix; a new probe shows a ')' closing a group is not affected; the audit's own probe agrees.
this doc's re-runs (2026-10-26) reproduced; fix validatedNew 7-test JUnit: 6 of 7 fail on main (gradle and harness, -ea and -da), 10.5.2 and 9.12.3; with the fix 7 of 7 pass and the queryparser module passes (540 tests).
fact checks and diagram check (2026-10-26) reproduced; text correctedAn AI tool that did not write this doc re-ran the gradle command, the fix, the harnesses and its own probe: no false statement, 5 precision points. An independent reviewer from another model family (OpenAI GPT-5.6, reading only) found 7 errors (0 high), 6 overclaims and 5 missing caveats, none against the bug or the fix; a diagram check found one wrong state value in the diagram. All were fixed.

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

Finding record

LFV-359, snapshot of 2026-11-06

Registry title
SimpleQueryParser drops the '+', '|', quote or '(' typed directly after a phrase slop, so "a b"~2+c parses as phrase OR c and "a b"~2|c (default operator AND) as phrase AND c
Area
queryparser / SimpleQueryParser (near operator: consumePhrase phrase slop)
Priority
Low, rank 263: Default SimpleQueryParser syntax, but only an operator glued directly after phrase-slop digits triggers it, and a space avoids it.
Impact class
silent wrong results
Impact note
Every SimpleQueryParser user with the default flags (NEAR_OPERATOR is on by default) who types a sloppy phrase immediately followed by '+', '|', a quote or '(' with no space gets a query of the wrong structure and wrong hits with no error (AND becomes OR under the default OR, OR becomes AND under default MUST, a following phrase becomes loose terms, a following group loses its grouping); no scale is needed, every release since 4.7.0 has it, a space after the slop digits avoids it, and a ')' that closes a group directly after the slop is not affected.
Summary
You parse "a b"~2+c with SimpleQueryParser and expect what its javadoc defines, the near query AND c, just as "a b"~2 +c and "a b"+c parse. Instead the parser skips the character right after the slop digits, so the '+' is lost and you get phrase OR c (on an index with the documents 'a b' and 'c' it matches 2 documents instead of 0); the same happens to '|' ("a b"~2|c becomes AND under default MUST), to a quote ("a b"~2"c d" becomes the phrase OR c OR d) and to '(' ("a b"~2(c+d) loses its group). The cause is an off-by-one in consumePhrase: parseFuzziness already stops on the delimiter that ends the slop, and the unconditional '++state.index' that was written to step over the closing quote then skips that delimiter.
Introduced
584fda3bdf0a5b044046feb63c322ae8dee997a4 (trunk, LUCENE-5410 'add fuzzy and near to SimpleQueryParser', svn r1563558) and its branch_4x backport b18fdadedecad444583eaa0eb343c35d6aa84707 (svn r1563562, the commit that shipped first, in 4.7.0); an SVN-era commit of a JIRA patch, no pull request; issue LUCENE-5410 = apache/lucene#6473 (CHANGES credit 'Lee Hinman via Robert Muir'). The skipping '++state.index' itself is older (LUCENE-5336, 3c9e753df64 / 64e591e1b7a, 2013-11-12), when it only stepped over the closing quote; LUCENE-5410 put the slop branch, whose parseFuzziness leaves the index on the next delimiter, in front of it. [read: git blame and git log -S hasSlop in the full-history clone; exec: 4.7.0 fails, and 4.7.0 with SimpleQueryParser.java from the parent 64e591e1b7a shadowed passes only trivially, because '~' was not an operator yet] (2014-02-02)
First affected version
4.7.0 [exec] (the first release with SimpleQueryParser and its '~' operator; 4.6.1 has no SimpleQueryParser, the probe does not compile)
Affected versions
every release from 4.7.0 to 10.5.2 (the last before 10.6.0) and main ea57360912ed; never fixed. [exec] the follow-up's version-portable main() probe (4 bug cases against their spaced forms, 2 controls) fails on 4.7.0, 4.10.4, 5.0.0, 6.4.2, 6.5.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main and passes on main with the fix; the finder's JUnit fails 4 of 4 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 6 on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; the intended-behaviour check's main() probe fails on 10.5.2, 9.12.3, 8.11.4, 6.6.6 and 5.0.0. [read] consumePhrase is whitespace-normalized identical in all 110 release tags of the history clone (4.7.0 to 10.5.2) and on main (the clone lacks 8.9.0 to 8.11.3; 8.11.4 was executed); parseFuzziness changed in 6.5.0 (LUCENE-7712: an empty slop means 2), 9.0.0 and 9.10.0, none of which moves where the index stops; the newest upstream commits to the file on main (df6f71a3729, in the tested tree) and branch_10x (ef740ebd967) change only javadoc formatting. All runs on JDK 25, aarch64 Linux.
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: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record verdict new, 179 logged queries and checks: a scripted prior-report search with 100 queries over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web; 25 GitHub GraphQL searches over apache/lucene; the changed files of all 411 open apache/lucene PRs (none touches lucene/queryparser/.../simple/); 24 searches, each as issue and PR search, over elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr and apache/lucenenet; 6 JQL queries over SOLR and LUCENE; 7 Stack Overflow queries; 8 forum and list pages read; the upstream commit history of the file; and the finder's earlier 51-query run). No item reports or discusses SimpleQueryParser dropping the character after a phrase slop. Closest items, none the same bug: #6473 (LUCENE-5410, the introducing change; all 20 comments read, none mentions a delimiter after a phrase slop), #6400 (LUCENE-5336, origin of the increment), #8763 (LUCENE-7712, empty slop means 2), #11687 (open, stack overflow on deep nesting), #9611, #10407, #8541, #7359, the java-user@ thread of 2018-04-12 'SimpleQueryParser does not tokenize on NOT operator' (no replies; an operator lost in tokenisation too, but a different input and mechanism: '-' is not a delimiter when WHITESPACE_OPERATOR is disabled), SOLR-8318, SOLR-5447, SOLR-10897, elastic/elasticsearch#4985 (the copy of the LUCENE-5410 code), #62789, #7875, #4911, #23366 and opensearch-project/OpenSearch#22557 (a nesting-depth pre-scan, not the slop path). Not fixed upstream. Gaps: GitHub search drops punctuation, so the literal trigger cannot be searched; 2 Stack Exchange queries failed on quota and 2 Stack Overflow pages were read by title only; keyword search can miss a report that says only 'AND is ignored'; private reports are not visible.
Lucene commit tested
2026-10-26 on main ea57360912ed (commit of 2026-10-24): this doc's test through gradle with assertions on and off and through the standalone harness, also on the 10.5.2 and 9.12.3 release jars; the earlier checks' tests on main, 10.5.2 and 9.12.3; a version-portable main() on 4.7.0, 4.10.4, 5.0.0, 6.4.2, 6.5.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (and, in the fact check, 8.8.2), and another on 6.6.6; the recommended fix in a scratch worktree at ea57360912ed (removed afterwards): the simple package with assertions on and off, the whole queryparser module (540 tests) and the static checks; every earlier hunt test with the fix shadowed.

Lucene and upstream sources

Finding record
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:36-45 (at ea57360912ed) (intent: 'a person should be able to type whatever they want'; whitespace and ( ) + | " delimit tokens; syntax errors 'may mean odd or unexpected results'), :50-57 (token1+token2 is AND, token1|token2 is OR, "term1 term2"~5 is a near query, ( ) give precedence), :146-148 (default flags -1 include NEAR_OPERATOR), :237-293 (consumeSubQuery finds the matching ')' and parses the group as its own substring), :314-325 (consumePhrase: closing quote followed by '~' sets hasSlop), :355-362 (parseFuzziness, then the unconditional ++state.index), :495-533 (parseFuzziness stops on tokenFinished and leaves the index on the delimiter), :536-550 (tokenFinished: the delimiter set)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:40-42, 50-51, 56 (javadoc: delimiters, + and |, near query), 175-235 (parseSubQuery), 237-294 (consumeSubQuery; substring at 288-289), 296-364 (consumePhrase; hasSlop 317-324; parseFuzziness call 356; ++state.index 362), 366-432 (consumeToken; parseFuzziness 410, no trailing increment), 495-533 (parseFuzziness; tokenFinished break 505-507), 536-550 (tokenFinished)
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:186-207 (an explicit operator is ignored when one is already set), 322-324 (hasSlop only when characters follow the '~'), 512-525 (empty slop 2; NumberFormatException gives 0)
Who is affected
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:141-148 (default flags -1), :50 (token1+token2), :108-136 (the operator flags), :536-546 (tokenFinished checks each flag)
  • Lucene main ea57360912ed: lucene/queryparser/src/test/org/apache/lucene/queryparser/simple/TestSimpleQueryParser.java:135 ("foo bar"~12 baz with a space), :602-609 (testDisableSlop)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:40-42, 44-45, 50-51, 56, 362, 505-507
  • Lucene main ea57360912ed: lucene/queryparser/src/test/org/apache/lucene/queryparser/simple/TestSimpleQueryParser.java:54-63 (the test helper's default operator MUST), :113-136 (testPhraseWithSlop; lenient slops 124-127; spaced form 135), :155-163 (testANDPhrase), :195-203 (testORPhrase)
Suggested fix
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:366-432 (consumeToken relies on parseFuzziness leaving the index on the delimiter)
Diagrams
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/simple/SimpleQueryParser.java:355-362, 495-533 (the steps shown)

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

Repro files

About the diagrams

A summary diagram (PNG), made in parallel with this text from the code at ea57360912ed and the executed runs.

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.