All findings · Lucene finding · LFV-158 · jflex-refill-trailing-high-surrogate-drops-tokens

A text value that ends in half an emoji loses its tokens (all of them in a short value) in StandardAnalyzer and the other JFlex scanners

Text that ends in half an emoji (an unpaired high surrogate, as when a String is cut inside πŸ˜€) loses its tokens in StandardAnalyzer: all of them for "hello world" + U+D83D, so title:hello misses it. Four other JFlex scanners share this one-line refill bug, released since 7.7.0 or 9.1.0.

Summary

Priority
Medium, rank 10
Impact
silent wrong results
Since
7.7.0 (LUCENE-8527, 2019) for StandardTokenizer and UAX29URLEmailTokenizer; 9.1.0 (LUCENE-10239) for the other threeintroduced 2019-01-08 · about 7 years
Affected
7.7.0 to 10.5.2 and main (Standard, UAX29URLEmail); 9.1.0 to 10.5.2 and main (Classic, Wikipedia, HTMLStrip); 7.6.0 correct (run)
Status
documented
Found by
Bounded-exhaustive check of the real jar (all 7,380 inputs of 1-4 symbols)

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 JFlex scanners read text into a buffer through zzRefill(), whose javadoc says it returns false iff there was new input. When the text's very last char is a lone high surrogate, it returns true right after reading the text, and the scanner stops.

Summary card titled 'Half an emoji at the end of a text value: StandardAnalyzer indexes no tokens'. Panel 1: a title cut by substring(0, 12) inside the emoji U+1F600, shown as 12 char cells ending in D83D, indexed with StandardAnalyzer; expected tokens hello and world and final offset 12. Panel 2: what zzRefill() does in StandardTokenizerImpl.java: line 773 reads 12 chars, 782 finds the high surrogate, 790 reads one more char and gets -1, 792 returns true although the javadoc at 750 says false iff there was new input, and at 1034 getNextToken ends the stream, so the 12 chars are never scanned. Panel 3, executed on main with -ea and -da: tokens [hello, world] expected, [] actual; final offset 12 versus 0; documents found for title:hello 2 versus 1; the stored value 'hello world' + U+FFFD is kept. Right: longer values lose the tail or split a word; the same branch is in five scanners, which lose whole values of up to 254 chars (StandardTokenizer, UAX29URLEmailTokenizer, since 7.7.0), 4,095 (ClassicTokenizer, WikipediaTokenizer, since 9.1.0) and 16,383 (HTMLStripCharFilter, since 9.1.0). Footer: the fix sketch, return false at line 792 of panel 2 and in both JFlex skeletons, then regenerate.
The repro on one card: the title cut inside πŸ˜€ as 12 char cells, the zzRefill() and getNextToken() lines that lose it, expected versus actual (tokens, final offset, hits), then longer values and the other four scanners. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index the title "hello worldπŸ˜€".substring(0, 12) with StandardAnalyzer. The cut keeps "hello world" plus U+D83D, the first half of πŸ˜€ (U+1F600 is \uD83D\uDE00 in UTF-16). A JSON string ending in \ud83d decodes to the same kind of value.
  2. You expect the tokens [hello, world] and final offset 12, as without the half emoji, as with the same char in mid-text, and as Lucene 7.6.0 gives.
  3. You get [] and final offset 0. With two titles, "hello world" and this one, title:hello finds 1 document instead of 2. No error, and the stored value keeps its words (the lone surrogate becomes U+FFFD).
  4. Why: the scanner fills its 255-char buffer and reads all 12 chars. The last one is a high surrogate, so it reads one more char for the low half, hits the end of the text and returns true, which means "no new input". The scanner takes that as the end of the stream and never scans the 12 chars.
  5. Longer values lose or split only their last 255-char window. ClassicTokenizer, UAX29URLEmailTokenizer, WikipediaTokenizer and HTMLStripCharFilter have the same line, and lose whole values of up to 4,095, 254, 4,095 and 16,383 chars.

Who is affected

Anyone who analyzes text with StandardAnalyzer or StandardTokenizer at default settings, or with UAX29URLEmailTokenizer, ClassicTokenizer, WikipediaTokenizer or HTMLStripCharFilter, and has values that end in an unpaired high surrogate: strings cut by UTF-16 length inside an emoji, or JSON ending in \ud83d. Rare input, but the loss is silent.

Mechanism, terms and numbers1,031 words

When a text value ends in an unpaired high surrogate (U+D800..U+DBFF), which is what you get when a Java String is cut by length in the middle of an emoji, or when a JSON string ends in the escape \ud83d, StandardAnalyzer and StandardTokenizer at default settings emit no tokens for a value shorter than their 255-char buffer and report final offset 0: "hello world" + U+D83D (12 chars) gives [] instead of [hello, world], and the document is not found by title:hello. No error is raised and the stored value keeps its words (IndexWriter replaces the lone surrogate with U+FFFD), so the loss is silent. Longer values lose or split only their last buffer window; the other scanners have larger buffers (4,096 chars for Classic and Wikipedia, 16,384 for HTMLStrip). The cause is one line in the refill method of both Lucene JFlex skeletons: after reading new chars that end in a high surrogate, it reads one more char for the low half, hits end of input and returns true, which its own javadoc defines as "no new input"; the scanner then ends the stream without scanning the chars it just read. The same line is generated into StandardTokenizerImpl and UAX29URLEmailTokenizerImpl (since 7.7.0, LUCENE-8527) and ClassicTokenizerImpl, WikipediaTokenizerImpl and HTMLStripCharFilter (since 9.1.0, LUCENE-10239); 7.6.0 handles the same input correctly. Returning false there (one line in each skeleton, then regenerate) fixes this branch in all five; the regenerated fix passes the core analysis tests and the whole analysis-common test suite. No earlier report was found.

Terms

  • Surrogate pair: Java strings are UTF-16. A character above U+FFFF, such as most emoji, takes two chars: a high surrogate (U+D800..U+DBFF) then a low surrogate (U+DC00..U+DFFF). πŸ˜€ (U+1F600) is \uD83D\uDE00. Cutting a string between the two leaves an unpaired (lone) high surrogate at the end.
  • JFlex scanner: StandardTokenizer's engine, StandardTokenizerImpl, is generated by JFlex from a grammar (StandardTokenizerImpl.jflex) and a skeleton, Lucene's template for the buffer code (gradle/regenerate/jflex/). UAX29URLEmailTokenizer, ClassicTokenizer, WikipediaTokenizer and HTMLStripCharFilter are generated the same way.
  • Buffer and refill: the scanner reads the text in windows of its buffer size (255 chars for StandardTokenizer, ZZ_BUFFERSIZE); zzRefill() fetches the next window from the Reader.
  • Final offset: after the last token, end() must report the total number of chars processed.

The bug, line by line (StandardTokenizerImpl.java at ea57360912ed)

  1. The contract, :750: "@return false iff there was new input."
  2. :769-773: the refill asks the Reader for the free part of the 255-char buffer. For "hello world" + U+D83D the Reader returns all 12 chars, fewer than requested.
  3. :780-781: if (numRead > 0) { zzEndRead += numRead; The 12 chars are now in the buffer.
  4. :782, :789-790: the last char is a high surrogate and there is room, so it calls zzReader.read() "Expecting to read a paired low surrogate char".
  5. :791-792: the text has ended, read() returns -1, and the code does if (c == -1) { return true; }: "no new input", although 12 chars were just added.
  6. :1004, :1010-1012: getNextToken() takes true as end of input without decoding the buffered chars. At a token start (:1034-1037) it sets zzAtEOF and returns YYEOF, so StandardTokenizer returns no token (StandardTokenizer.java:159) and end() reports yychar() + yylength() = 0 (:181). Inside a token, the token is closed at the old buffer end, so a word that crosses the refill boundary is split in two.
  7. Without the bug, the lone U+D83D would be decoded and matched by the grammar's catch-all rule [^], whose action is "ignore it" (StandardTokenizerImpl.jflex:286), as happens when the same char is in mid-text.

The branch comes from the skeletons (skeleton.disable.buffer.expansion.txt:120-121, skeleton.default.txt:124-125) and is in all five generated scanners: UAX29URLEmailTokenizerImpl.java:33667, ClassicTokenizerImpl.java:505, WikipediaTokenizerImpl.java:658, HTMLStripCharFilter.java:5953.

The numbers

valuetokensfinal offset
"hello world" (11 chars), control[hello, world]11
"hello world" + U+D83D (12 chars)[]0
"hello world πŸ˜€ more words".substring(0, 13)[]0
"abcd ".repeat(51) + "tail" + U+D83D51 x abcd, tail lostnot measured
"abcd ".repeat(50) + "helloworld" + U+D83D..., hello, world (split)not measured

Two documents titled "hello world" and "hello world" + U+D83D: title:hello matches 1. A whole value is lost when it is one char shorter than the scanner's buffer, counting the surrogate:

scannerbufferwhole value lost atcontrol without the surrogate
StandardTokenizer, UAX29URLEmailTokenizer255254 chars: 0 tokens53 tokens
ClassicTokenizer, WikipediaTokenizer4,0964,095 chars: 0 tokens701 tokens
HTMLStripCharFilter (+ any tokenizer)16,38416,383 chars: 0 tokens2,499 tokens

A value of exactly the buffer size takes a different branch (numRead == requested, which holds the high surrogate back for the next read): the four tokenizers keep every token and report the full final offset (255 / 4,096). HTMLStripCharFilter keeps every token too but drops the trailing lone surrogate itself: a 16,384-char value comes out 16,383 chars long, final offset 16,383. That one-char loss is a different branch, which the fix below does not change (a possible sibling, not yet triaged).

In a seeded sweep over random word texts (2,000 per length), every text of 10..200 chars before the surrogate lost all tokens, 254 and 255 were unaffected, and at 256..5,000 about 1/6 lost the tail, 2/3 had one word split and 1/6 were unaffected; the fractions depend on the text shape.

Two conditions shape these numbers. The Reader: they are for a Reader that returns as many chars as asked, which is what a String field value gets (Field.java:498 calls Analyzer.tokenStream(String, String), which uses a ReusableStringReader, Analyzer.java:183-190). A Reader that returns fewer chars per call lets earlier tokens through; the branch still fires at the end (in the bounded check, a 1-char Reader gave 262 violations where a whole-string Reader gave 820). Buffer growth: Classic, Wikipedia and HTMLStrip come from the default skeleton, which doubles the buffer when a token outgrows it (skeleton.default.txt:97-104), so their "last window" is the current buffer, which starts at 4,096 or 16,384 chars.

Exposure and likelihood354 words
  • Default configuration. StandardAnalyzer with nothing changed, which is also what new IndexWriterConfig() uses when no analyzer is given (IndexWriterConfig.java:136-142). By code reading (not executed), it is also what Elasticsearch and OpenSearch build for an index's default analyzer; Elasticsearch's html_strip, classic and uax_url_email wrap the other scanners; Solr's _default configset uses the standard tokenizer for text_general.
  • Silent. No exception with -ea or -da. IndexWriter stores the value (the lone surrogate becomes U+FFFD, as its javadoc documents; the words are kept), so the document displays normally but its words cannot be searched. CheckIndex is clean.
  • Contained. Only the value that ends in the surrogate loses text. Other values of the same field, other fields and other documents are indexed normally. Inside the same document, a later value of the same multi-valued field gets a shifted term-vector position and offsets (position 0 and [1,6) where 2 and [12,17) are right), so offset-based highlighting can mark the wrong place there.
  • How much. A whole value of up to 254 chars (StandardTokenizer, UAX29URLEmailTokenizer), 4,095 (Classic, Wikipedia) or 16,383 (behind HTMLStripCharFilter, whatever tokenizer follows); longer values lose or split their last window (for Classic, Wikipedia and HTMLStrip the current buffer, which can grow). With a larger maxTokenLength, the StandardTokenizer window grows to about that length (read, not run).
  • No resource cost. On 1,000,000-char values each scanner reads the same chars with the same number of Reader calls as the control.
  • How likely. The value's very last char must be the first half of a supplementary character, with nothing appended after the cut: a length-limited title, snippet or column cut by substring, or a client that sends such a string as a JSON escape. A committer wrote in #15899 / #15900 that half surrogates from UTF-16 truncation are to be expected and code must be prepared for them. No report since 2019, but the loss is silent, and the random text behind the analysis tests' checkRandomData (TestUtil.randomAnalysisString) keeps surrogate pairs whole and trims a high surrogate left by a cut, so those tests do not produce this input. It may be under-reported.

Minimal repro

The test shows that StandardAnalyzer at default settings drops every token of "hello world\uD83D" (text ending in an unpaired high surrogate, e.g. a string cut inside an emoji).

TestStandardAnalyzerTrailingHighSurrogate.java (JUnit test)46 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.analysis.standard;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestStandardAnalyzerTrailingHighSurrogate extends LuceneTestCase {

  public void testTrailingUnpairedHighSurrogate() throws Exception {
    List<String> terms = new ArrayList<>();
    int finalOffset;
    try (Analyzer a = new StandardAnalyzer();
        TokenStream ts = a.tokenStream("f", "hello world\uD83D")) {
      CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
      OffsetAttribute offset = ts.addAttribute(OffsetAttribute.class);
      ts.reset();
      while (ts.incrementToken()) {
        terms.add(term.toString());
      }
      ts.end();
      finalOffset = offset.endOffset();
    }
    // expected [hello, world] with final offset 12 (the input length); actual [] with final offset 0
    assertEquals("[hello, world] finalOffset=12", terms + " finalOffset=" + finalOffset);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-158/, 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: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.analysis.standard.TestStandardAnalyzerTrailingHighSurrogate
Expected vs actual
Expected: tokens [hello, world] and final offset 12 (the input length). Actual: [] and final offset 0.

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

More files, environment, full output35 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
    => org.junit.ComparisonFailure: expected:<[[hello, world] finalOffset=12]> but was:<[[] finalOffset=0]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.analysis.standard.TestStandardAnalyzerTrailingHighSurrogate.testTrailingUnpairedHighSurrogate(TestStandardAnalyzerTrailingHighSurrogate.java:44)

Why it looks unintended

  • zzRefill()'s javadoc: "@return false iff there was new input"; the branch returns true right after reading the text.
  • The grammar's [^] rule accepts lone surrogates, and does in mid-text; IndexWriter documents that it accepts such strings.
  • A silent regression: 7.6.0 keeps the words, and LUCENE-10239 says it changes no analyzer's behaviour.
The full argument298 words
  • The method's contract. "@return false iff there was new input" (StandardTokenizerImpl.java:750, both skeletons :77). The branch returns true right after zzEndRead += numRead with numRead > 0.
  • The analysis contract. At the end of each field "The Offset attribute must contain the final offset (the total number of characters processed)" (analysis/package-info.java:276-281); BaseTokenStreamTestCase checks text.length() (:1423, and assertAnalyzesTo). Here it is 0 for a 12-char value.
  • The scanner accepts lone surrogates by design. [^] { ... ignore it. } (StandardTokenizerImpl.jflex:286); the JFlex manual says [^] matches "any character of the input character set, including unpaired Unicode surrogate characters". Executed controls: a lone high surrogate in mid-text, a trailing lone low surrogate, "high surrogate + space" and a trailing valid pair all keep [hello, world].
  • Lucene accepts this input elsewhere. IndexWriter documents the U+FFFD replacement (IndexWriter.java:1483-1485); that shows the input is accepted, not how an analyzer must tokenize it. TestAnalyzers.testLowerCaseFilterLowSurrogateLeftover feeds "BogustermBoguster\ud801" to WhitespaceTokenizer and expects it as a token; TestCharacterUtils.testFill and TestCharArraySet.testSingleHighSurrogate use raw lone high surrogates, including at the end of input. (Not TestHTMLStripCharFilter.testUTF16Surrogates: it uses numeric character references.)
  • History. LUCENE-8527 (2019) added the look-ahead for a different problem, well-formed pairs split by Readers that return short reads (jflex-de/jflex#538 / #543); an unpaired surrogate at the true end of input is never discussed, and its CHANGES entries mention only the JFlex 1.7.0 upgrade, Unicode 9.0 and emoji support. LUCENE-10239 (#452, 2021) copied the branch from JFlex's skeleton with the message "Change doesn't alter the behavior of any of the analyzers". 7.6.0 and, for Classic, Wikipedia and HTMLStrip, 9.0.0 keep both words with final offset 12 ([hello, world]; HTMLStripCharFilter + WhitespaceTokenizer gives [hello, world\uD83D], keeping the surrogate in its last token).
The devil's-advocate check292 words

The strongest case for "works as designed": an unpaired surrogate is ill-formed UTF-16 (Unicode conformance C10), so garbage in, garbage out. The test framework seems to agree: MockTokenizer fails with "stream ends with unpaired high surrogate" (MockTokenizer.java:271), TestUtil.randomSubString, which randomAnalysisString calls for its text (TestUtil.java:1686, :1703), trims a high surrogate left at the end of a cut (:1740-1744, LUCENE-3911), and in jflex#538 the author of the branch wrote that surrogates "should not be present unpaired in a well-formed UTF-16 sequence". A maintainer could reasonably say that ill-formed UTF-16 has no defined tokenization.

Why it is plausible but outweighed for this result: no javadoc of Analyzer, Tokenizer, StandardTokenizer, StandardAnalyzer or the analysis package sets that precondition, and IndexWriter documents that it accepts such strings (acceptance, not tokenization semantics); the grammar and JFlex's [^] accept them everywhere except at the very end; the output is not "garbage" for the bad char but the loss of every good word before it (one char erases up to 253 good chars, 16,382 behind HTMLStripCharFilter, while the same char one position earlier erases nothing); Lucene's own tests feed raw lone surrogates to other analysis components; and the behaviour arrived in toolchain upgrades meant to change nothing else. MockTokenizer and TestUtil show that Lucene's own tests keep random input well-formed (the code does not say why, and neither is a documented precondition for users), and they are why randomized tests never reached this branch. The cross-model reviews and the intended-behaviour verifier rated the case weak; this doc's OpenAI GPT-5.6 fact check rated it moderate for "ill-formed input is unsupported" in general and weak for this silent loss of the words in front. Verdict: not intended, a defect.

Suggested fix

Recommended Return false in the look-ahead branch of both skeletons, then regenerate

Return false instead of true when the low-surrogate look-ahead hits end of input, in both gradle/regenerate/jflex skeletons, then regenerate: each of the five scanners changes by that one line. With it, the core analysis tests (186) and the whole analysis-common suite (1,929) pass, and the two new tests go from 9 failures to 0.

--- a/gradle/regenerate/jflex/skeleton.default.txt   (same change in skeleton.disable.buffer.expansion.txt)
+++ b/gradle/regenerate/jflex/skeleton.default.txt
@@ -122,7 +122,7 @@
         } else {                    // There is room in the buffer for at least one more char
           int c = zzReader.read();  // Expecting to read a paired low surrogate char
           if (c == -1) {
-            return true;
+            return false;           // new chars were read; the next call reports EOF
           } else {
             zzBuffer[zzEndRead++] = (char)c;
           }
All options and trade-offs4 options Β· 820 words

One fix was regenerated into all five scanners and tested with gradle. The others are options for JFlex itself, a more invasive variant, and a workaround for applications.

Return false in the look-ahead branch of both skeletons, then regenerate (validated by the checks) (recommended)

numRead > 0 chars were just added, so by the javadoc the answer is false. The scanner then scans the buffered chars; the lone surrogate at the end is decoded and matched by [^] (ignored by the tokenizers, passed through by HTMLStripCharFilter), exactly as in mid-text. The next refill calls read() again, gets -1 and returns true from the existing numRead < 0 path (StandardTokenizerImpl.java:802-803). Scope: every value that takes this branch (the read left room in the buffer). It does not touch the numRead == requested branch, so a value of exactly 16,384 chars still comes out of HTMLStripCharFilter one char short (executed: with the fix, 12, 16,383 and 16,385 chars pass through unchanged, 16,384 does not).

--- a/gradle/regenerate/jflex/skeleton.default.txt   (same change in skeleton.disable.buffer.expansion.txt)
+++ b/gradle/regenerate/jflex/skeleton.default.txt
@@ -122,7 +122,7 @@
         } else {                    // There is room in the buffer for at least one more char
           int c = zzReader.read();  // Expecting to read a paired low surrogate char
           if (c == -1) {
-            return true;
+            return false;           // new chars were read; the next call reports EOF
           } else {
             zzBuffer[zzEndRead++] = (char)c;
           }

Trade-offs

  • Negligible cost: the branch runs at most once per value. With the fix a trigger value makes one more read() call, which returns -1: 3 Reader calls instead of 2 for "hello world" + U+D83D, the same 3 as for "hello world" without it (executed).
  • Calling read() again after -1 is not new: the stock scanner already does it for a value that ends inside a word, such as "hello world" (2 of its 3 calls return -1, executed). Only StringReader and ReusableStringReader were tried; a custom Reader that misbehaves after -1 was not tested.
  • JFlex's own skeleton keeps the branch, so a Lucene-only fix would come back if open issue #14645 replaces Lucene's skeletons with JFlex's built-in buffer features (option B).
  • It does not fix the sibling LFV-132 (other branches of the same method, maxTokenLength = 1), nor the unverified "H H L" short-read observation in the same block, nor the HTMLStripCharFilter exact-buffer-size case above.

What the checks validated

  • Scratch worktree of ea57360912ed, JFlex 1.9.1 from the build: regenerateStandardTokenizer, regenerateClassicTokenizer, regenerateWikipediaTokenizer, regenerateHTMLStripCharFilter and regenerateUAX29URLEmailTokenizer (with -x regenerateTlds, which would download a new TLD list) change exactly that line in each generated file plus five checksum files (12 files, +17/-17).
  • With the fix, seed B21EF158: :lucene:core:test --tests 'org.apache.lucene.analysis.*' 24 classes, 186 tests, 0 failures (TestStandardAnalyzer 45/45); the whole :lucene:analysis:common:test 343 classes, 1,929 tests, 0 failures, 18 skipped (TestUAX29URLEmailTokenizer 46, TestClassicAnalyzer 29, TestWikipediaTokenizer 8, TestHTMLStripCharFilter 36).
  • The two new tests (9) and the finder's, the first verifier's and the devil's-advocate verifier's tests pass; all seven trailing-surrogate test classes pass 20 iterations with seed 5EED158B (760 runs, 0 failures). checkGoogleJavaFormat and ecjLintTest pass for both modules.
  • Without the fix the same gradle run of the two new tests fails 5/5 and 4/4. Earlier, the hunt's shadow of StandardTokenizerImpl passed every reviewer's test. With such a shadow, the bounded-exhaustive checker's final-offset violations for StandardTokenizer at maxTokenLength 255 go from 820 to 0 with a whole-string Reader and from 262 to 0 with a 1-char Reader (re-run by this doc's fact check); the separate "H H L" count with a 1-char Reader stays (465 before, 502 after).

Make the same change in JFlex's own skeleton

JFlex's skeleton.default (master) has the same branch, so other scanners generated from it may have the same return path and could be checked (for example a copy in a TOML parser of a JSON library's text-format module; whether users see lost text depends on the grammar and the Reader; not tested). A maintainer may prefer this route: fix it in JFlex and carry the Lucene skeleton patch until a JFlex release is picked up.

Trade-offs

Needed for a durable fix if Lucene moves to JFlex's built-in buffer features (#14645); Lucene's own no-buffer-expansion skeleton needs the change either way; on its own it does not help Lucene until a JFlex release is picked up. Not tried.

Hold the trailing high surrogate back instead of reading ahead

Keep the high surrogate out of the buffer and decide on the next refill, as the numRead == requested branch does.

Trade-offs

More state and code than the one-line fix, in the branch where the sibling LFV-132 and jflex-de/jflex#1090 live. Not tried.

Workaround for applications today

Replace unpaired surrogates with U+FFFD, or cut strings by code point, or append a separator, before analysis.

Trade-offs

Not a Lucene fix. Reasoned from the verifiers' controls (a lone high surrogate followed by a space keeps the words), not run as a mitigation test.

Downstream note

Downstream: whoever writes the end of a value can hide it, with one char (a JSON \ud83d escape), from searches on the terms these scanners produce, while it still displays. It stays inside that value and breaks no documented guarantee (SECURITY.md lines 28, 38 and 50 put analyzer input out of scope), so it is an ordinary public bug.

How it was found

A bounded-exhaustive check of the real jar fed StandardTokenizer and UAX29URLEmailTokenizer all 7,380 sequences of 1 to 4 symbols from a 9-symbol alphabet with lone surrogates (177,120 runs), and checked that the final offset equals the input length. It flagged 820 inputs per tokenizer, the shortest a lone U+D801; a case analysis of zzRefill() then found the branch.

The method in full207 words

Method: semi-formal, bounded-exhaustive checking of the stock main jar (a hunt unit, analysis core and case folding, 2026-10-26).

  • The harness (StdTokTV) ran all 7,380 sequences of 1..4 symbols over a 9-symbol alphabet (StdTokTV.java:41-44: two supplementary characters, two lone surrogates, five BMP chars; so 1..8 UTF-16 units), with maxTokenLength 1..5 and 255, whole-string and 1-char Readers, through StandardTokenizer and UAX29URLEmailTokenizer: 177,120 runs.
  • Its reference tokenizer (an unlimited-buffer run of the same scanner) shares the bug, so a differential check alone missed it; the separate oracle "end() final offset equals the input length" flagged 820 inputs for each tokenizer at the default maxTokenLength 255 with a whole-string Reader (also the number of generated sequences that end in the lone high surrogate: 1 + 9 + 81 + 729). The shortest: the single unit U+D801 gives no tokens and final offset 0.
  • A written case analysis of zzRefill() located the c == -1 branch; a seeded length sweep measured how much text is lost, and a deterministic JUnit reproduced it on the stock jar with -ea and -da.
  • An independent reviewer from another model family (OpenAI GPT-5.6) then tried to refute it before it became a candidate, and wrote its own failing test.

Prior reports, history and checks

Prior reports

No earlier report found: 218 logged queries on 2026-10-26 over Lucene's GitHub issues, PRs and commits, the JIRA archive, the Lucene and Solr lists, four downstream trackers, all 1,112 JFlex issues and PRs, and the web. The closest items are the threads that added the branch, about well-formed pairs only.

Searches6 searches

Verdict new. Nothing upstream fixes it, no open PR touches the scanners or skeletons, and JFlex master's skeleton still has the branch.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA issues) and commit messages2026-10-26two scripted prior-report searches (51 and 111 queries) plus manual searches: class names, zzRefill, zzFinalHighSurrogate, isHighSurrogate, skeleton.default, "c == -1", ud83d, unpaired / lone / trailing surrogate, emoji, truncated, "no tokens", "final offset", LUCENE-8527no report; origin threads only
apache/lucene open PRs and recent commits2026-10-26changed files of the 38 open PRs with analysis-related titles (of 413); main since 2026-10-01 by path; git log -S on the look-aheadnone touches the scanners or skeletons; upstream head = the tested commit
jflex-de/jflex2026-10-26all 1,112 issues and PRs grepped by title and body; master skeleton.default and its history read#538 / #543 (origin, well-formed pairs); #1090 (a different zzRefill branch that throws); master still has the branch
Downstream trackers (Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-26scripted prior-report search downstream queries and 11 manual searcheselastic/elasticsearch#45771 (a lone high surrogate at the end of a highlight fragment: output side, not this bug)
JIRA archive and mailing lists2026-10-26LUCENE and SOLR JQL; dev@, java-user@, issues@, commits@ (Lucene), users@, dev@ and the old solr-user@ (Solr)no report
Web2026-10-2614 queriesno matching symptom

History

Introduced twice in JFlex upgrades: LUCENE-8527 (283b19a8da6, 2019) for StandardTokenizer and UAX29URLEmailTokenizer, first released in 7.7.0, and LUCENE-10239 (#452, af831d281056, 2021) for Classic, Wikipedia and HTMLStrip, first released in 9.1.0. Run on 13 versions: 7.6.0 is correct and every release since is affected.

Versions tested5 rows

Introduction 1. LUCENE-8527 (#9573), JFlex 1.7.0 upgrade by @sarowe, commit 283b19a8da6 (main line, 2019-01-08), e8c65da6bb8 (branch_7x, first release 7.7.0) and 0e903cab47e (branch_8x); a JIRA patch with no pull request. It added the look-ahead to the no-buffer-expansion skeleton only, which generates StandardTokenizer and UAX29URLEmailTokenizer.

Introduction 2. LUCENE-10239, PR #452, JFlex 1.8.2 upgrade by @rmuir, commit af831d281056 (main, 2021-11-19), backport ee56d314257 (first release 9.1.0). It took JFlex's own default skeleton, which had the branch, so ClassicTokenizer, WikipediaTokenizer and HTMLStripCharFilter got it too. The reviewed diff shows the lines; nobody commented on them.

Never fixed: every release from 7.7.0 to 10.5.2 and main for StandardTokenizer (24 runs on 13 versions, plus a 9.0.0 JUnit attempt that did not compile; 8.8.2-8.11.3 are untagged in the history clone and inferred from 8.8.1 and the executed 8.11.4); release branches were not checked. At every version run, the scanners that lose text are exactly those whose generated zzRefill has the branch.

Versions and branches executed, with results
Version or commitVerdictResult
6.6.6, 7.5.0, 7.6.0 not affectedall five scanners keep both words, final offset 12 ([hello, world]; HTMLStripCharFilter + WhitespaceTokenizer gives [hello, world\uD83D])
7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 Standard and UAX29URLEmail onlyStandardTokenizer and UAX29URLEmailTokenizer give [] and final offset 0; Classic, Wikipedia and HTMLStrip are correct
9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedall five scanners lose the text; the finder's JUnit fails 8 of 9 on 9.1.0, 9.12.3 and 10.5.2
main ea57360912ed affectedall five lose the text with -ea and -da; the brief's tests fail 5/5 and 4/4
main with the fix (regenerated) not affectedthe brief's tests pass; core analysis 186 tests and analysis-common 1,929 tests, 0 failures

Independent checks

Cross-model reviews (OpenAI GPT-5.6, refute and works-as-designed lenses), an independent reproducer and an intended-behaviour devil's advocate each rated it real with their own failing tests; the adjudication re-ran two repros with -ea and -da and confirmed it with high confidence. No reviewer dissented.

Checks and verdicts533 words

Each check wrote and ran its own tests on main ea57360912ed; the adjudication re-ran the finder's and the first verifier's repros with new seeds.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate8 of 9 tests fail with -ea and -da; 7 of 8 on 10.5.2 and 9.12.3; main() fails on 7.7.0-8.11.4 and passes on 7.6.0, 7.5.0, 6.6.6.
refute review (OpenAI GPT-5.6) real (high)Own test: "red blue" + a lone high surrogate gives [] and final offset 0 with -ea and -da; controls pass; the fix passes its test and TestStandardAnalyzer 45/45.
independent reproducer realOwn tests fail 8 of 13 on main, 10.5.2 and 9.12.3 with -ea and -da; all five scanners lose the text; length sweep 1..253 all lost, 254-255 fine, 256..507 one word split; corrected a test citation.
works-as-designed review (OpenAI GPT-5.6, the cross-model review) real (high)Rated the "malformed UTF-16, garbage in" case weak; its tests reproduce whole-value loss, tail loss and word splitting with five controls passing; 7.6.0 passes.
intended-behaviour devil's advocate real (high)Rebutted MockTokenizer, TestUtil.randomAnalysisString, Unicode C10 and the jflex#538 remark; found the per-scanner start versions (9.0.0: only Standard and UAX29URLEmail lose text; 9.1.0: all five).
adjudication confirmed (high)Re-ran the finder's repro (8 of 9 fail) and the independent reproduction's (8 of 13 fail) with -ea and -da, and the independent reproduction's test with the fix (8/8 pass).
this doc's runs reproduced; fix validatedNew plain program and two JUnit tests fail on main (-ea, -da), 10.5.2 and 7.7.0, pass on 7.6.0; the regenerated fix passes them and the core analysis and analysis-common suites.
Correction to the registry row 4 registry wordings corrected hereThe registry card below still says "every input of 1..4 UTF-16 units" (found by; it was 7,380 sequences of 1..4 symbols, 1..8 UTF-16 units), "the stored value stays intact" (impact note; the lone surrogate becomes U+FFFD, the words are kept), "no widening of an application's own query restrictions" (security note; splits add terms) and "upstream main head equals the tested commit" (affected versions; main has moved on with no change to these files). This page uses the corrected wording.
Fact check of this doc (independent AI tool) real; 6 text errors, fixedRe-ran the plain program (main -ea/-da, 10.5.2, 7.7.0, 7.6.0), both JUnit tests with -ea and -da, the long values, the fix shadow, the bounded checker with the fix and Reader-call counts. Bug, mechanism, versions and fix hold; 6 wording errors (splits add terms, the checker's input space, the HTMLStripCharFilter exact-buffer case, two scope words, a line citation) fixed; it also found a possible sibling (HTMLStripCharFilter at exactly 16,384 chars).
Cross-model fact check (OpenAI GPT-5.6) real; 8 text errors, fixed or answeredRead-only review against the code and run logs: the core bug is real and well demonstrated; 8 errors (1 high: splits add terms, so "only removes tokens" was wrong), 8 overclaims and 7 missing caveats; rated the intended-behaviour case moderate in general, weak for this result.
Diagram check facts correct; 1 overlap, fixed and re-renderedEvery on-screen value, code line and line number matched the code and the probe logs.

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

Finding record

LFV-158, snapshot of 2026-11-06

Registry title
StandardAnalyzer and the other JFlex-generated scanners (StandardTokenizer, UAX29URLEmailTokenizer, ClassicTokenizer, WikipediaTokenizer, HTMLStripCharFilter) silently index no tokens for a text value that ends in an unpaired high surrogate, such as a string cut inside an emoji; values longer than the scan buffer lose or split their last buffer window
Area
analysis / StandardTokenizer and the other JFlex-generated scanners (zzRefill in both Lucene JFlex skeletons)
Priority
Medium, rank 10:
Impact class
silent wrong results
Impact note
Anyone who analyzes text with StandardAnalyzer or StandardTokenizer at default settings (by code reading also the default analyzer of Elasticsearch and OpenSearch and the tokenizer of Solr's default text_general type), or with UAX29URLEmailTokenizer, ClassicTokenizer, WikipediaTokenizer or HTMLStripCharFilter, and whose field value ends in an unpaired high surrogate (a String cut by UTF-16 length inside an emoji, or a JSON \ud83d escape) gets no tokens and final offset 0 for a value that fits in one scanner read (up to 254 chars including the surrogate for StandardTokenizer and UAX29URLEmailTokenizer, 4,095 for ClassicTokenizer and WikipediaTokenizer, 16,383 behind HTMLStripCharFilter) and a lost or split last buffer window for longer values, with no error, with -ea and -da, while the stored value stays intact; released since 7.7.0 (StandardTokenizer, UAX29URLEmailTokenizer) and 9.1.0 (the other three); no scale needed.
Summary
You index a text field with StandardAnalyzer at default settings, and the value ends in an unpaired high surrogate, for example 'hello world' followed by U+D83D, which is what cutting a Java String inside an emoji, or decoding the JSON escape \ud83d, produces. You expect hello and world to be indexed and end() to report the input length as the final offset, as happens when the same lone surrogate sits mid-text and as Lucene 7.6.0 did. Instead the scanner's refill reads the whole text, looks one char ahead for the missing low surrogate, hits end of input and reports 'no new input', so the scanner ends the stream without scanning what it just read: no tokens, final offset 0, and the document does not match title:hello; values longer than the 255-char buffer lose or split their last window instead.
Introduced
283b19a8da6ab9e0b7e9a75b132d3067218d5502 (LUCENE-8527, apache/lucene#9573, JFlex 1.7.0 upgrade by sarowe, a JIRA patch committed without a pull request; the same change went to branch_7x as e8c65da6bb8 (first released in 7.7.0) and to branch_8x as 0e903cab47e) added the low-surrogate lookahead with 'if (c == -1) { return true; }' to the no-buffer-expansion skeleton only, which generates StandardTokenizerImpl and UAX29URLEmailTokenizerImpl; af831d281056 (LUCENE-10239, apache/lucene#452, JFlex 1.7.0 -> 1.8.2 by rmuir; branch_9x backport ee56d314257, first released in 9.1.0) brought the same branch into the default skeleton from JFlex 1.8.2's own skeleton, which generates ClassicTokenizerImpl, WikipediaTokenizerImpl and HTMLStripCharFilter [exec: per-scanner boundaries 7.6.0 -> 7.7.0 and 9.0.0 -> 9.1.0 on SHA-1-checked release jars; read: git log -S, git blame, grep of the generated scanners at every release tag in the history clone] (2019-01-08 (283b19a8da6, LUCENE-8527; 7.7.0 tagged 2019-02-08); 2021-11-19 (af831d281056, LUCENE-10239; 9.1.0 tagged 2022-03-21))
First affected version
7.7.0 [exec] for StandardTokenizer (and so StandardAnalyzer) and UAX29URLEmailTokenizer (7.6.0 passes); 9.1.0 [exec] for ClassicTokenizer, WikipediaTokenizer and HTMLStripCharFilter (9.0.0 passes for those three)
Affected versions
StandardTokenizer, StandardAnalyzer and UAX29URLEmailTokenizer: 7.7.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2 (the last release before 10.6.0) and main ea57360912ed; ClassicTokenizer, WikipediaTokenizer and HTMLStripCharFilter: 9.1.0-9.12.3, 10.0.0-10.5.2 and main. Executed: the follow-up's framework-free history probe ('hello world' + U+D83D through all five scanners) fails on 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea (also -da on 7.7.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.5.2 and main), losing text only in StandardTokenizer and UAX29URLEmailTokenizer on 7.7.0-9.0.0 and in all five from 9.1.0 on, and passes on 6.6.6, 7.5.0 and 7.6.0 (7.6.0 also -da); the finder's JUnit fails on 9.1.0, 9.12.3, 10.5.2 and main and its main() on 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.12.3 and 10.5.2; the independent reproduction's JUnit fails on 9.12.3, 10.5.2 and main (-ea, -da) [exec]. The other releases come from a grep of the generated scanners at every release tag in the history clone (branch present in StandardTokenizerImpl at every tag from 7.7.0 to 10.5.2, in ClassicTokenizerImpl from 9.1.0; 8.8.2-8.11.3 are not tagged there, 8.11.4 was executed) [read]. The long-text symptoms (tail lost or a word split at the last refill boundary) were executed on 9.1.0 and later only; on 7.7.0-9.0.0 they follow from the identical branch [read]. Not fixed upstream: upstream main head equals the tested commit, no open PR touches the scanners or skeletons, and JFlex master's skeleton.default has the same branch [read 2026-10-26]; release branches were not checked.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; 218 logged queries: the finder's scripted prior-report search (51), the follow-up's scripted prior-report search (111) and 56 manual searches, over apache/lucene issues and PRs including migrated JIRA issues, apache/lucene commit messages, the changed files of the 38 open apache/lucene PRs with analysis-related titles (of 413 open; none touches the scanners or skeletons), main commits since 2026-10-01 by path (none), git log -S on the lookahead, the Elasticsearch, OpenSearch, Solr and Lucene.NET trackers, all 1,112 jflex-de/jflex issues and PRs grepped by title and body, the LUCENE and SOLR JIRA archive, the dev@, java-user@, issues@ and commits@ Lucene lists, the users@ and dev@ Solr lists and the old solr-user@ list, and the web; also the intended-behaviour check's gh searches on apache/lucene and jflex-de/jflex); upstream main head equals the tested commit and JFlex master's skeleton.default still has the same branch; related, none of them this bug: #9573 (LUCENE-8527) and jflex-de/jflex#538 / #543 (origin of the branch, about well-formed pairs split by spoon-feeding Readers), #452 (LUCENE-10239, the reviewed skeleton diff shows the branch, nobody commented on it), #14645 (open: replace Lucene's skeletons with JFlex's built-in buffer features, which have the same branch), jflex-de/jflex#1090 with FasterXML/jackson-dataformats-text#403 / #411 / #412 (a different zzRefill branch giving an exception; relevant to the sibling LFV-132), #15899 / #15900 (TruncateTokenFilter half surrogates; a committer writes that such input must be expected), elastic/elasticsearch#45771 (a lone high surrogate at the end of a highlight fragment, output side) and #4969 (LUCENE-3896, CharTokenizer, fixed 2012)
Lucene commit tested
main ea57360912ed (11.0.0-SNAPSHOT; tested 2026-10-26): the plain program and both JUnit tests through the repro runner with -ea and -da, and through gradle in a scratch worktree, stock and with the fix regenerated into all five scanners (the worktree was removed after the runs); the Maven Central jars 10.5.2 and 7.7.0 (fail) and 7.6.0 (passes), SHA-1 checked. The follow-up's history probe ran on 13 versions from 6.6.6 to main.

Lucene and upstream sources

Finding record
  • a Lucene main checkout gradle/regenerate/jflex/skeleton.default.txt:77 (zzRefill javadoc '@return false iff there was new input'), :118-131 (lookahead; 'if (c == -1)' at :124); gradle/regenerate/jflex/skeleton.disable.buffer.expansion.txt:77, :110-127 ('if (c == -1)' at :120)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:51 (ZZ_BUFFERSIZE 255), :750 (javadoc), :780-797 (numRead > 0 branch, lookahead read, 'if (c == -1) { return true; }' at :791), :1004-1011 (refill true -> YYEOF), :1034-1037 (YYEOF at a token start ends the stream); StandardTokenizerImpl.jflex:286 ('[^]' catch-all rule, 'ignore it'); StandardTokenizer.java:100-104 (setMaxTokenLength sets the scanner buffer size)
  • a Lucene main checkout lucene/analysis/common/src/java/org/apache/lucene/analysis/classic/ClassicTokenizerImpl.java:34 (ZZ_BUFFERSIZE 4096), :505; wikipedia/WikipediaTokenizerImpl.java:34 (4096), :658; email/UAX29URLEmailTokenizerImpl.java:55 (255), :33667; charfilter/HTMLStripCharFilter.java:45 (16384), :5953 (the same 'if (c == -1)' branch in each)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/package-info.java:276-281 (end() must set the final offset to 'the total number of characters processed'); lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1423 (checkAnalysisConsistency passes text.length() as the expected final offset); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1483-1485 (malformed surrogates silently replaced with U+FFFD)
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/analysis/MockTokenizer.java:264, :271 (fails on unpaired surrogates, 'stream ends with unpaired high surrogate'); lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1742-1744 (randomAnalysisString trims a trailing high surrogate); lucene/core/src/test/org/apache/lucene/analysis/TestCharacterUtils.java:91 (testFill), lucene/core/src/test/org/apache/lucene/analysis/TestCharArraySet.java:203 (raw 'ABC\uD800'), lucene/analysis/common/src/test/org/apache/lucene/analysis/core/TestAnalyzers.java:176 (testLowerCaseFilterLowSurrogateLeftover)
  • a Lucene main checkout lucene/CHANGES.txt:6879 and :6954 (the two LUCENE-8527 entries of 7.7.0: Unicode 9.0 and emoji support, JFlex 1.7.0; nothing about surrogate input); SECURITY.md lines 28, 38, 44-46 and 50
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:51, 750, 769-803, 1004-1012, 1034-1044
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:159, 181; StandardTokenizerImpl.jflex:286
  • Lucene main ea57360912ed gradle/regenerate/jflex/skeleton.default.txt:77, 118-131; skeleton.disable.buffer.expansion.txt:77, 110-127
  • Lucene main ea57360912ed UAX29URLEmailTokenizerImpl.java:55, 33667; ClassicTokenizerImpl.java:34, 505; WikipediaTokenizerImpl.java:34, 658; HTMLStripCharFilter.java:45, 5953
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/Field.java:498; analysis/Analyzer.java:183-190; analysis/ReusableStringReader.java:45-51; gradle/regenerate/jflex/skeleton.default.txt:97-104
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1483-1485; StandardTokenizer.java:100-104
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:136-142; lucene/test-framework/.../TestUtil.java:629-650, 1681-1745
Why it looks unintended
  • Lucene main ea57360912ed StandardTokenizerImpl.java:750; StandardTokenizerImpl.jflex:286; analysis/package-info.java:276-281
  • Lucene main ea57360912ed: lucene/test-framework/.../BaseTokenStreamTestCase.java:745-765, 1423; MockTokenizer.java:271; TestUtil.java:1681-1706, 1740-1744
  • Lucene main ea57360912ed: lucene/analysis/common/src/test/.../core/TestAnalyzers.java:176-188; lucene/core/src/test/.../TestCharacterUtils.java:91-92; TestCharArraySet.java:199-203
  • Lucene main ea57360912ed: lucene/CHANGES.txt:6879, 6954; IndexWriter.java:1483-1485
Suggested fix
  • Lucene main ea57360912ed StandardTokenizerImpl.java:783-803; gradle/libs.versions.toml (jflex 1.9.1); lucene/core/build.gradle:101, 184, 193, 202, 260

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

Repro files

About the diagrams

A picture of the repro: the user code, the buffer cells, the verbatim zzRefill() and getNextToken() lines at ea57360912ed, and the expected and actual results; then longer values and the other four scanners. The PNG is the summary card. Made in parallel with this doc from the code and an executed probe (see the diagram directory's notes).

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.