All findings · Lucene finding · LFV-659 · icutokenizer-word-zwj-emoji-dropped

ICUTokenizer drops a word, and the emoji after it, when a ZERO WIDTH JOINER sits between them

ICUTokenizer silently drops a word that runs straight into an invisible ZERO WIDTH JOINER (U+200D) and an emoji, and the emoji too: hello world+ZWJ+๐Ÿ˜€ again gives only hello, again, so a search for world misses the document. Every release since 7.4.0 (2018) and main.

Summary

Priority
Low, rank 291Needs a word directly followed by an invisible ZERO WIDTH JOINER and an emoji, a rare input.
Impact
silent wrong results
Since
7.4.0 (2018: the ICU 60.2 word-break rules, LUCENE-8122/8175)introduced 2018-03-26 · about 8 years
Affected
7.4.0 to 10.5.2 and main (67 releases executed); 7.0.0-7.3.1 keep the word
Status
documented
Found by
Exhaustive comparison with StandardTokenizer over generated strings, then a JUnit (no formal tool)

AI-assisted page: this finding was first signalled by code reading or testing 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

ICUTokenizer asks ICU's word breaker for pieces of text and keeps only pieces with a nonzero rule status (a word, a number, an emoji). A word glued to an emoji by an invisible joiner becomes one piece with status 0, and the tokenizer throws it away.

Summary diagram in three panels. Panel 1: the text hello world + U+200D + U+1F600 + again (20 Java chars, the invisible ZWJ at char 11) indexed with new ICUTokenizer(), and a TermQuery for world. Panel 2: the segments hello (status 200), space (0), world+ZWJ+emoji as one segment (status 0), space (0), again (200); Default.rbbi's word rule takes the ZWJ and chains into WB3c, which has no status; isEmoji reads only the first code point, w; ICUTokenizer.java:211 skips every status-0 segment; ICU's own ROOT rules also give status 0. Panel 3, executed on main with -ea and -da: the expected tokens hello, world, emoji, again versus the actual hello, again; TermQuery body:world finds 0 documents instead of 1; silent, the stored value is intact and CheckIndex is clean; an NFKC_CF char filter in front is a workaround, ICUFoldingFilter after the tokenizer is not; 2024, abc, an ideograph and a Malayalam word are lost too, also with cjkAsWords=false. Footer: since 7.4.0 (2018, the ICU 60.2 word-break rules, 2dcf263b5207); lost on all 67 releases 7.4.0-10.5.2 and on main; 7.0.0-7.3.1 keep the word; fix A (validated) ends the text after such a ZWJ, so the word and the emoji become tokens.
How hello world+ZWJ+๐Ÿ˜€ again becomes one status-0 segment that ICUTokenizer skips, and what the tokens and a TermQuery for world look like with and without the joiner. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index hello world+ZWJ+๐Ÿ˜€ again with new ICUTokenizer(): an invisible ZERO WIDTH JOINER (U+200D) sits between world and ๐Ÿ˜€ (offset 11 of 20 Java chars, counting from 0). On screen it looks like hello world๐Ÿ˜€ again. Stray joiners turn up in pasted social-media text and in Malayalam written in an older spelling.
  2. You expect four tokens, as with a space before the emoji, without the joiner, or with StandardTokenizer: hello, world, ๐Ÿ˜€, again.
  3. You get two: hello, again. Next to the same text with a space and without the joiner, TermQuery(body:world) finds 2 of 3 documents, never the one with the joiner. abc, 2024, ไธญ and ๆ—ฅๆœฌ่ชž vanish the same way.
  4. Why: Unicode rule WB3c keeps world+ZWJ+๐Ÿ˜€ in one piece. Lucene's break rules give that piece rule status 0, which ICUTokenizer treats as a non-token; the emoji check reads only its first character w, and ICUTokenizer skips every status-0 piece. In unspaced Japanese, Chinese, Thai or Myanmar text the whole run before the joiner goes, such as a whole sentence.
  5. No error: nothing throws, CheckIndex is clean and the stored text keeps the word. Fix A ends the break iterator's text after such a joiner where the piece would be skipped: world+ZWJ and ๐Ÿ˜€ become tokens, as with StandardTokenizer, and the ICU module's tests run with 0 failures (10 skipped).

Who is affected

Users of ICUTokenizer (optional analysis-icu module), default config, cjkAsWords=false or icu factory defaults. It takes a word directly followed by U+200D and an emoji: stray joiners in pasted text, or Malayalam in the older joiner spelling. Each hit silently loses the emoji and the run before it: one word, or a whole unspaced Japanese, Chinese, Thai or Myanmar sentence.

Mechanism, terms and numbers953 words

ICUTokenizer with its default configuration emits no token for abc + U+200D + ๐Ÿ‘, and hello world + U+200D + ๐Ÿ˜€ + again gives only hello and again: the word and the emoji are both gone. With a space instead of the ZERO WIDTH JOINER, or without it, both are tokens, and StandardTokenizer keeps both (world+ZWJ, ๐Ÿ˜€). Unicode word-break rule WB3c (ZWJ ร— Extended_Pictographic) keeps world ZWJ ๐Ÿ˜€ in one segment; in Lucene's break rules (and in ICU's own root rules) that segment ends in a rule without a status, so its rule status is 0, which ICUTokenizer treats as a non-token; BreakIteratorWrapper's emoji check reads only the segment's first code point, a letter; and ICUTokenizer skips every status-0 segment. Letters, digits, ideographs and kana are hit alike, with cjkAsWords true or false; a TermQuery for the word misses the document. Nothing throws, CheckIndex is clean and the stored value keeps the text. It started in 7.4.0 with the 2018 ICU rule upgrade: a public-API probe loses the word on all 67 releases from 7.4.0 to 10.5.2 and on main, and keeps it on all 7 releases 7.0.0-7.3.1. In unspaced text that a dictionary segments (Japanese or Chinese with the default cjkAsWords=true, Thai, Myanmar) one trigger loses the whole run before the joiner, such as a whole Japanese sentence. Fix A (+76/-4 in BreakIteratorWrapper) ends the break iterator's text right after such a joiner, but only where the segment holding it would be skipped: the word and the emoji become separate tokens (the same tokens as StandardTokenizer for the tested Latin, digit, single-ideograph and Malayalam inputs), the repro and an exact-token test pass, and the ICU module's tests run with 0 failures.

Terms

  • Rule status: ICU's word breaker cuts text into segments and labels each with the status of the rule that ended it: 200 for a word with letters, 100 for a number, 300 for kana, 400 for ideographs, and 0 when that rule has no status tag (spaces, punctuation). ICUTokenizer treats status 0 as a non-token and keeps only segments with a nonzero status ("skipping over non-tokens (rule status 0)", ICUTokenizer.java:209-214).
  • ZERO WIDTH JOINER (ZWJ, U+200D): an invisible character that glues emoji into one picture, as in ๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ. Unicode rule WB4 attaches a ZWJ to the word before it, and rule WB3c ("ZWJ ร— Extended_Pictographic") forbids a break between a ZWJ and a following emoji.
  • Emoji status: ICU gives a lone emoji status 0 as well, so Lucene's BreakIteratorWrapper re-labels a segment as <EMOJI> when its first code point is an emoji.

The bug (main a9779ef3c193)

  1. Default.rbbi runs under !!chain (:46). The word rules end in $ExFm*, which includes the ZWJ (:116, :122-131; the Hangul rule at :127 does not, the separate Hangul bug), and WB3c $ZWJ $Extended_Pict; (:104-107) has no status tag. The match of world+ZWJ continues into WB3c, so world+ZWJ+๐Ÿ˜€ is one segment, and its status is 0. world+ZWJ alone gets 200.
  2. ICU4J 78.3's own ROOT word iterator gives the same 0. With cjkAsWords=true, Han and kana runs use exactly that iterator (DefaultICUTokenizerConfig.java:73, :113-114); its dictionary splits ใฒใ‚‰ใŒใช+ZWJ+๐Ÿ‘ into four pieces, all with status 0.
  3. BreakIteratorWrapper.calcStatus (:57-66) changes the status only when isEmoji (:75-91) holds, and isEmoji reads only the first code point (:78), here w.
  4. ICUTokenizer.incrementTokenBuffer skips the segment (ICUTokenizer.java:211): the word and the emoji are lost together.

The numbers (main, -ea and -da; the same with cjkAsWords=false unless noted)

input (ZWJ = U+200D)ICUTokenizerStandardTokenizerICUTokenizer with fix A
hello world+ZWJ+๐Ÿ˜€ againhello, againhello, world+ZWJ, ๐Ÿ˜€, againsame as StandardTokenizer
abc+ZWJ+๐Ÿ‘noneabc+ZWJ, ๐Ÿ‘same
2024+ZWJ+๐Ÿ‘none2024+ZWJ, ๐Ÿ‘same
foo ไธญ+ZWJ+๐Ÿ‘ barfoo, barfoo, ไธญ+ZWJ, ๐Ÿ‘, barsame
ๆ—ฅๆœฌ่ชž+ZWJ+๐Ÿ‘, default confignoneๆ—ฅ, ๆœฌ, ่ชž+ZWJ, ๐Ÿ‘ๆ—ฅๆœฌ่ชž+ZWJ, ๐Ÿ‘
ๆ—ฅๆœฌ่ชž+ZWJ+๐Ÿ‘, cjkAsWords=falseๆ—ฅ, ๆœฌas aboveๆ—ฅ, ๆœฌ, ่ชž+ZWJ, ๐Ÿ‘
meet in Paris+ZWJ+โค๏ธ soonmeet, in, soonmeet, in, Paris+ZWJ, โค๏ธ, soonsame
Malayalam เด…เดตเดจเต+ZWJ+๐Ÿ˜‚ (older chillu spelling)noneword+ZWJ, ๐Ÿ˜‚same
abcโ„น+ZWJ+๐Ÿ‘ (the word ends in U+2139, an emoji that is also a letter)noneabcโ„น+ZWJ, ๐Ÿ‘same
controls: abc ๐Ÿ‘, abc๐Ÿ‘, abc+ZWJ, ๐Ÿ˜‚+ZWJ+๐Ÿ‘all keptall keptunchanged

Search over three documents, hello world+ZWJ+๐Ÿ˜€ again, the same with a space, and without the joiner: TermQuery(body:world) finds 2 (never the first), TermQuery(body:๐Ÿ˜€) 2, TermQuery(body:hello) 3. In a query, the text analyzes to nothing and the classic QueryParser drops the clause (QueryParserBase.java:367-369): +report -secret+ZWJ+๐Ÿ”’ parses to +body:report.

How much one trigger loses: the run of letters, digits or ideographs before the joiner, up to about 4,000 chars (the tokenizer reads 4096-char chunks). In space-separated text that is one word. In unspaced text that a dictionary segments it is the whole run: ็งใฏๆ—ฅๆœฌ่ชžใŒๅฅฝใใงใ™+ZWJ+๐Ÿ‘ gives no token with the default config (6 tokens lost; with cjkAsWords=false only the last kana), ๆˆ‘็ˆฑๅŒ—ไบฌๅคฉๅฎ‰้—จ+ZWJ+๐Ÿ‘ none (4 tokens), a Thai run (เธชเธงเธฑเธชเธ”เธตเธ„เธฃเธฑเธš) or a Myanmar run before ZWJ+๐Ÿ‘ none with either config, and a 1003-char Japanese run none. Fix A restores all of them.

Related loss, same mechanism: a stray ZWJ before an emoji at the start of the text or after a space loses the emoji: ZWJ+๐Ÿ˜€ cd gives cd only. Fix A restores it too.

Exposure and likelihood338 words
  • Who is hit. Applications that analyze text with ICUTokenizer: new ICUTokenizer(), new ICUTokenizer(new DefaultICUTokenizerConfig(false, true)) (both executed), or the icu factory with default parameters, which builds the same config (ICUTokenizerFactory.java:96-97, 112, 156; read). Lucene's default analysis (StandardAnalyzer) is not affected. The class is @lucene.experimental.
  • What triggers it. A run of letters, digits, ideographs or kana immediately followed by U+200D and an emoji: an Extended_Pictographic character that is not also a letter, such as ๐Ÿ‘ (U+2139 โ„น is both, so abc+ZWJ+โ„น stays one word). The run may itself end in an emoji-like letter or a digit: abcโ„น+ZWJ+๐Ÿ‘ and abc1๏ธโƒฃ+ZWJ+๐Ÿ‘ also give no token. Ordinary keyboards do not type a joiner between a word and an emoji; realistic sources are stray joiners in pasted or generated social-media text and Malayalam in the older <consonant, virama, ZWJ> chillu spelling (the newer one-character chillu letters are not affected). How often real text has it was not measured; the registry rates it rare.
  • What goes wrong. That word and that emoji are not in the field's terms: a TermQuery, a phrase or a MUST_NOT exclusion on the word misses the document, and in a query the clause disappears.
  • Silent and deterministic. No exception, CheckIndex clean, stored value intact, the rest of the text tokenized normally, the same with -ea and -da.
  • Not more than that. No hang: the probe's 1M- and 4M-char inputs finished, the bug pattern faster than a control (it emits fewer tokens), with time roughly proportional to size. Memory was not measured.
  • Long-lived. Every release since 7.4.0 (2018). A fix changes tokens only of texts that hit the pattern; existing indexes need a reindex to gain the lost words.
  • Workaround until a fix ships: an ICUNormalizer2CharFilter (NFKC_CF) in front of the tokenizer removes the joiner, and the word and emoji become tokens; it also splits real emoji ZWJ sequences (๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ becomes three emoji). A token filter after the tokenizer (ICUFoldingFilter) does not help.

Minimal repro

The test shows that ICUTokenizer silently drops the word "abc" (and the emoji) from the input "abc" + U+200D (ZERO WIDTH JOINER) + U+1F44D.

TestICUTokenizerWordZwjEmoji.java (JUnit test)44 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.icu.segmentation;

import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestICUTokenizerWordZwjEmoji extends LuceneTestCase {

  public void testWordFollowedByZwjAndEmojiIsKept() throws Exception {
    List<String> tokens = new ArrayList<>();
    try (Tokenizer t = new ICUTokenizer()) {
      t.setReader(new StringReader("abc\u200D\uD83D\uDC4D"));
      CharTermAttribute term = t.addAttribute(CharTermAttribute.class);
      t.reset();
      while (t.incrementToken()) {
        tokens.add(term.toString());
      }
      t.end();
    }
    // expected: a token containing "abc" for "abc" ZWJ U+1F44D; actual: no tokens at all
    assertTrue(
        "expected a token containing \"abc\", got " + tokens,
        tokens.stream().anyMatch(s -> s.contains("abc")));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-659/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/analysis/icu/build/libs/lucene-analysis-icu-$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.icu.segmentation.TestICUTokenizerWordZwjEmoji
# the module(s) analysis-icu also need their third-party runtime jars
Expected vs actual
Expected: some token contains "abc". Actual: no tokens at all (expected a token containing "abc", got [], with -ea and -da).

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

More files, environment, full output36 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 a token containing "abc", got []
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.analysis.icu.segmentation.TestICUTokenizerWordZwjEmoji.testWordFollowedByZwjAndEmojiIsKept(TestICUTokenizerWordZwjEmoji.java:40)

Why it looks unintended

  • ICUTokenizer "Breaks text into words according to UAX #29"; its only documented tailoring is dictionary breaking.
  • Lucene already keeps ICU's status-0 emoji as <EMOJI> tokens, and StandardTokenizer, also UAX #29, keeps both the word and the emoji.
  • A regression: 7.3.1 keeps the word; the 2018 rule upgrade that lost it never discussed this input.
The full argument309 words
  • The contract is word segmentation. "Breaks text into words according to UAX #29: Unicode Text Segmentation" (ICUTokenizer.java:31). DefaultICUTokenizerConfig "Generally tokenizes Unicode text according to UAX#29" with one listed tailoring, dictionary breaking for Thai, Lao, Myanmar, Khmer and CJK (DefaultICUTokenizerConfig.java:28-38). The module overview presents it "As a more thorough replacement for StandardTokenizer" (overview.html:67).
  • Lucene overrides ICU's status for emoji on purpose. BreakIteratorWrapper exists for "emitting a rule status for emoji sequences" (BreakIteratorWrapper.java:25-26): ICU gives a lone emoji status 0, and Lucene keeps it as <EMOJI>. Upstream testEmojiTokenization expects poo๐Ÿ’ฉpoo to give poo, ๐Ÿ’ฉ, poo (TestICUTokenizer.java:491-497); every upstream emoji test with a ZWJ starts with an emoji (:456-465, :506-519).
  • Lucene's UAX #29 reference keeps this text. StandardTokenizer gives a+ZWJ, ๐Ÿ›‘ for the WordBreakTest line 0061 ร— 200D ร— 1F6D1; Lucene's test generator skips that line on purpose, "because it conflicts with TR#51 v11.0 rules" (generateJavaUnicodeWordBreakTest.pl:130-141), and its oracle counts every segment that holds a letter, digit, ideograph or Extended_Pictographic character as a token (WordBreakTestUnicode_12_1_0.java:28-39).
  • A regression. 7.3.1 gives abc for abc+ZWJ+๐Ÿ‘; 7.4.0 gives nothing. With ICU4J held at 61.1, the 7.3.1 rule file gives abc status 200 and the 7.4.0 rule file gives the joined segment status 0.
  • No sign it was decided. The status-0 skip dates from the class's first commit (LUCENE-2414, 2010); that commit's wrapper for dictionary iterators gave a status-0 segment a word or number status when it held a letter or digit. The 2018 upgrade that brought WB3c (LUCENE-8122 / #9170, LUCENE-8175 / #9223) and the emoji status (LUCENE-8125 / #9173) never discusses a word before ZWJ and an emoji; no javadoc, test, CHANGES or MIGRATE entry accepts dropping such text. No evidence was found that the 2018 change meant this text to disappear.
The devil's-advocate check282 words

Strongest case for "intended" (rated moderate by the cross-model review lenses and the cross-model fact check): UAX #29 WB3c forbids a break between a ZWJ and an emoji, so one segment is correct; ICU's own root iterator also returns status 0, Default.brk is meant to be "the same as ROOT, except no dictionary segmentation for cjk" (DefaultICUTokenizerConfig.java:80), and ICU calls the status "secondary information" (ICU-20966, closed as a caller-side choice); a test rule file documents that status 0 is "not tokenized by ICUTokenizer" (Latin-break-only-on-whitespace.rbbi:27); valid emoji ZWJ sequences start with an emoji, LUCENE-8125 calls its emoji check "very conservative", StandardTokenizer departs from WB3c on purpose with an explicit TR #51 emoji grammar (StandardTokenizerImpl.jflex:166-208), and the class is @lucene.experimental.

Why it fails: the segmentation is not the defect, dropping the segment is. Skipping status 0 is Lucene's own choice (ICUTokenizer.java:211), and ICU-20966 puts exactly that choice on the caller. Lucene already refuses to drop ICU's status-0 emoji; a status-0 segment that holds a word is the same situation. ROOT parity is about boundaries: Lucene already sets its own status for emoji, and the test rule file uses status 0 for whitespace, a segment with no content. StandardTokenizer, also documented as UAX #29, keeps both, and 7.3.1 kept the word. @lucene.experimental marks API that may change, not license to lose valid text. The case is stronger for the lost word than for the lost emoji: before 7.4.0 there were no emoji tokens, so only the word is a regression. Verdict: not intended, a defect. Whether the right output is one token (world+ZWJ+๐Ÿ˜€) or the word plus the emoji is a maintainer choice.

Suggested fix

Recommended A: end the break iterator's text after a ZWJ where the joined segment would be skipped

In BreakIteratorWrapper, end the break iterator's text right after a ZWJ before an emoji where the segment holding it would be skipped (+76/-4). The word and the emoji become separate tokens (world+ZWJ, ๐Ÿ˜€, as with StandardTokenizer); the repro and an exact-token test pass, and the ICU module's 174 tests run with 0 failures (10 skipped).

    for (int i = pieceStart; i < length - 1; i++) {
      if (text[start + i] == 0x200D
          && EXTENDED_PICTOGRAPHIC.contains(UTF16.charAt(text, 0, start + length, start + i + 1))
          && emojiBefore(i) == false
          && isSkipped(i)) {
        textIterator.setText(text, start + pieceStart, i + 1 - pieceStart);
        rbbi.setText(textIterator);
        return;
      }
    }
    rbbi.first();
All options and trade-offs3 options ยท 1,318 words

Fix A and fix B were each validated with the repro through the repro runner and with gradle in a scratch worktree at a9779ef3c193 (OpenJDK 25.0.3); fix A also with an exact-token test and the module's static checks. Option C was not run. Neither fix was benchmarked. The related ICU status-0 bugs (Han letters such as ใ€… with cjkAsWords=false; a Hangul syllable before Extend, Format or ZWJ) are separate: their repros still fail with fix A and pass with fix B.

A: end the break iterator's text after a ZWJ where the joined segment would be skipped (validated by the checks) (recommended)

BreakIteratorWrapper looks in each script run for a U+200D directly followed by an Extended_Pictographic character. If the code point before that ZWJ (Extend, Format and ZWJ characters skipped) is an emoji that cannot end a word, the ZWJ is inside an emoji sequence and nothing changes. Otherwise the wrapper asks the break iterator for the segment that holds the ZWJ (following(), getRuleStatus(), previous()). Only if that segment has status 0 and does not start with an emoji, exactly when the tokenizer would skip it, the break iterator gets the text up to and including that ZWJ, and the rest as the next piece; current() and next() add the piece offset. One file, +76/-4.

The result: the word plus its ZWJ, then the emoji, as two tokens, with both configs. For Latin, digits, single ideographs and Malayalam these are StandardTokenizer's tokens (world+ZWJ <ALPHANUM>, ๐Ÿ˜€ <EMOJI>); the word token is what ICUTokenizer already gives for the word plus ZWJ alone. Words that end in an emoji-like letter or a keycap digit (abcโ„น+ZWJ+๐Ÿ‘, abc1๏ธโƒฃ+ZWJ+๐Ÿ‘) are covered too. On a dictionary path it gives what the tokenizer gives for the run plus ZWJ alone, then the emoji (ๆ—ฅๆœฌ่ชž+ZWJ, ๐Ÿ‘ with cjkAsWords=true, where StandardTokenizer splits the ideographs); whole Japanese and Chinese sentences and Thai and Myanmar runs come back token for token. It also restores the emoji after a stray ZWJ (ZWJ+๐Ÿ˜€ cd gives ๐Ÿ˜€, cd). Emoji-led sequences stay one <EMOJI> token: ๐Ÿ˜‚+ZWJ+๐Ÿ‘, ๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ, ๐Ÿ‘จ๐Ÿผโ€โš•๏ธ, a keycap or a flag before ZWJ + emoji, tag sequences, โ„น+ZWJ+๐Ÿ‘.

An earlier version decided from the character before the ZWJ alone and missed words ending in an emoji-like letter or keycap digit (aโ„น+ZWJ+๐Ÿ‘ still gave no token); the cross-model fact check found it, and the final fix asks the break iterator instead.

    for (int i = pieceStart; i < length - 1; i++) {
      if (text[start + i] == 0x200D
          && EXTENDED_PICTOGRAPHIC.contains(UTF16.charAt(text, 0, start + length, start + i + 1))
          && emojiBefore(i) == false
          && isSkipped(i)) {
        textIterator.setText(text, start + pieceStart, i + 1 - pieceStart);
        rbbi.setText(textIterator);
        return;
      }
    }
    rbbi.first();

Trade-offs

  • A deliberate deviation from UAX #29 WB3c. StandardTokenizer also breaks there (its grammar replaces WB3c with TR #51's emoji sequences); the class javadoc promises UAX #29, so CHANGES should say so.
  • The word token keeps its trailing ZWJ (world+ZWJ), as ICUTokenizer and StandardTokenizer already do for world+ZWJ alone. The bare word matches after a filter that removes U+200D: with ICUFoldingFilter, TermQuery(body:world) finds 2 of 2 documents (1 of 2 on stock); with the tokenizer alone 1 of 2, as with StandardTokenizer.
  • Scope: tokens change only where a segment that holds a ZWJ before an Extended_Pictographic character would be skipped today: the word cases here and the stray-ZWJ cases. This holds for every break iterator, including custom rule files given to the icu factory: their text is split only where their own segment would be skipped (reasoned from the code, not run).
  • Not covered: a Hangul word before ZWJ + emoji keeps only the emoji, because Hangul + ZWJ has status 0 even without an emoji, and the Han status-0 letters such as ใ€…: their repros still fail with fix A.
  • Cost: one pass over each script run looking for U+200D; at each ZWJ before an emoji a short walk back, and, unless an emoji precedes the ZWJ, one lookup on the break iterator. A rough timing (4M chars per text, median of 21 runs, stock and fix interleaved, one shared host with up to about 20% run-to-run spread): no clear difference on plain Latin, CJK or emoji text without a ZWJ; Malayalam with ZWJ chillus about 10-16% slower; text made only of emoji ZWJ sequences about 50-85% slower; text where every word has the pattern about 1.7 times the same text without the ZWJ (about 2.2 times stock, which skips those segments); โ„น+ZWJ+๐Ÿ‘ repeated about 2.1-2.4 times; a 4M-char chain without spaces about 3.6 times. The cost grows with the number of ZWJs before an emoji; the fix was not optimized further. Not benchmarked.
  • Tokens change only for affected texts; existing indexes need a reindex to gain the lost words.

What the checks validated

  • The repro: 5 of 6 fail on stock, 6 of 6 pass with the fix (the repro runner -ea and -da; gradle module runs).
  • An exact-token test, TestICUTokenizerZwjEmojiSplit (10 tests: tokens and types with both configs, offsets for one input, words ending in an emoji-like letter or keycap, the dictionary path, stray ZWJs, emoji sequences unchanged, random strings): 10 of 10 pass with the fix (-ea and -da); on stock 7 of 10 fail; with the earlier version of fix A 2 of 10 fail.
  • All of :lucene:analysis:icu:test with both new tests: 18 suites, 174 tests, 0 failed, 10 skipped, with assertions on (seed 5EED0659) and off (seed 0DA50659); TestICUTokenizer 45 of 45. Stock with the repro: 17 suites, 164 tests, 5 failed (all in the repro), the same 10 skipped (TestICUTokenizerCJK, @AwaitsFix LUCENE-8222).
  • :lucene:analysis:icu:check -x test passes (checkGoogleJavaFormat, ecjLint, forbiddenApis, the rbbi checksum check).
  • Token probes on the inputs above, the kana, ideograph and dictionary-script probes, the edge cases and the search check: identical to the earlier version on every earlier input.

B: keep the joined segment as one word token (validated by the checks)

In calcStatus, a status-0 segment that holds a letter becomes WORD_LETTER, one with a digit WORD_NUMBER (+19/-2; the shape of verifier's root-cause probe). It keeps UAX #29 boundaries exactly, and it also keeps the Han and Hangul status-0 segments of the related bugs (one fix site for three bugs): their repros pass with fix B (4 of 4 and 6 of 6).

Trade-offs

  • The output is one glued token world+ZWJ+๐Ÿ˜€ <ALPHANUM>: TermQuery(body:world) still misses the document, even with ICUFoldingFilter (the folded term is world๐Ÿ˜€), and the emoji is no <EMOJI> token.
  • Types change on the dictionary path: ใฒใ‚‰ใŒใช+ZWJ+๐Ÿ‘ gives four <ALPHANUM> tokens; stock types ใฒใ‚‰ใŒใช as <IDEOGRAPHIC>.
  • The emoji after a stray ZWJ stays lost (ZWJ+๐Ÿ˜€ cd gives cd).
  • Wider behaviour change: every status-0 segment that holds a letter or digit becomes a token, not only this pattern (including the segments of the related Han and Hangul bugs).

What the checks validated

  • The repro: 6 of 6 pass (the repro runner -ea).
  • :lucene:analysis:icu:test with the new test: 17 suites, 164 tests, 0 failed, 10 skipped (assertions on, seed 5EED0659); TestICUTokenizer 45 of 45. Verifier earlier ran its probe of this shape against upstream TestICUTokenizer: 45 of 45.

C: change the break rules instead

Give WB3c chains that start with a word the word's status in Default.rbbi (keeps one glued token), or apply WB3c only to a ZWJ after an emoji, then regenerate Default.brk. Not run.

Trade-offs

Neither covers Japanese-script runs with cjkAsWords=true, which use ICU's ROOT iterator (DefaultICUTokenizerConfig.java:73, :113-114), and a tailored WB3c has to be carried across ICU upgrades. ICU may want to know too, since its root rules give the same status 0; both validated fixes are Lucene-side.

Downstream note

Applications that match analyzed text to exclude or flag content should know that a ZERO WIDTH JOINER plus an emoji right after a word hides that word from ICUTokenizer; an ICUNormalizer2CharFilter (NFKC_CF) in front prevents it.

How it was found

A security check of a related ICU bug first noticed ab+ZWJ+๐Ÿ˜€ cd giving only cd. A hunt then compared ICUTokenizer with StandardTokenizer over 2,593,337 generated strings in two configs: every "X + ZWJ + emoji" string lost X, for every letter, digit and ideograph class tried. No formal tool was involved.

The method in full235 words

Method: bounded-exhaustive translation validation of the stock jar against a reference (the hunt's semi-formal tier for the hunt; the first signal was non-formal).

  • First signal (2026-10-26). While assessing the security of the ICUTokenizer surrogate-split bug (LFV-445), an AI tool ran a public-API probe on main and saw ab+ZWJ+๐Ÿ˜€ cd give only cd, with ICU's root word iterator giving that span status 0. It noted that this "may deserve its own candidate" and did not investigate further.
  • The hunt (2026-10-27, main a9779ef3c193). A hunt unit on ICUTokenizer's script iterator ran a harness over 2,593,337 generated strings in two configurations, with a "content dropped" oracle against StandardTokenizer. Every string with X + ZWJ + ๐Ÿ‘ (U+1F44D) dropped X, for every letter, digit and ideograph class in its alphabet; a buffer-boundary harness also flagged the WB3c rule. The finder reduced it to a public-API JUnit.
  • Checks. Claude verifiers wrote their own tests (the independent reproduction: Latin, mid-sentence, Malayalam with both configs, digits, the emoji, 4 controls, plus a root-cause probe; the intended-behaviour check: a devil's advocate who found Lucene's WordBreakTest stance and the NFKC_CF workaround). Review records by a model from another family (OpenAI GPT-5.6, executed mode) wrote and ran their own tests: both "real, high". An adjudication re-ran the independent reproduction's test with a fresh seed, and an independent audit added new inputs (see you tomorrow, Paris+ZWJ+โค๏ธ, x9).

Prior reports, history and checks

Prior reports

No report found (2026-10-27, 240 logged queries and reads over Lucene, Solr, ICU and downstream issues, JIRA, mailing lists, the web and all open Lucene PRs). The nearest items design the emoji status (#9173) or show a different consumer dropping status-0 emoji (ICU-20966).

Searches5 searches

Novelty new: no issue, PR, commit, JIRA ticket (LUCENE, SOLR, ICU), list thread or web page reports ICUTokenizer losing a word directly followed by U+200D and an Extended_Pictographic character, or such a segment getting status 0. Closest items, none the same bug: #9173 / LUCENE-8125 (designed the emoji status, "not a perfect science", "very conservative"; its tests and 14 comments cover only ZWJ sequences that start with an emoji); #9170 / LUCENE-8122 and #9413 / LUCENE-8366 (brought and reshaped WB3c); open #6140 / LUCENE-5076 (a first-code-point loop in an older calcStatus that LUCENE-8125 removed; no ZWJ, emoji or lost text); ICU-20966 (a browser engine dropped WORD_NONE segments holding a lone bullet or emoji; ICU called the status secondary and closed it as caller-side). Gaps: keyword search only; the literal-U+200D GitHub query was rate-limited (the ZWJ, "zero width joiner" and 200D queries cover it); some forums only through the web index; private security reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (2 runs) and the finder's run2026-10-27185 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the webno report; #9173 the nearest
GitHub (manual)2026-10-2740 searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, unicode-org/icu and unicode-org/icu4x; related issues read in full#9173, #9170, #9413, #6140, #9573 and downstream configuration issues; none about this bug
ICU and ASF JIRA2026-10-2711 ICU JIRA and 9 LUCENE/SOLR queriesICU-20966 (different consumer, no letters involved)
Mailing lists and web2026-10-2720 Lucene and Solr list queries, 10 extra web queriesnothing reporting this bug
Open PRs2026-10-27changed files of all 409 open apache/lucene PRsonly #16911 (deletes branch_10x files), not a fix

History

Introduced in 7.4.0 by the 2018 ICU word-break rule upgrade (2dcf263b5207, LUCENE-8122/8175), which added rule WB3c without a status. A public-API probe loses the word on all 67 releases 7.4.0-10.5.2 and on main, and keeps it on all 7 releases 7.0.0-7.3.1. Not fixed upstream.

Versions tested7 rows

Introduced by 2dcf263b5207 (branch_7x, shipped in 7.4.0; on master 4522e45bdadd), @rmuir, 2018-03-26, "LUCENE-8175: un-revert "LUCENE-8122: Upgrade analysis/icu to ICU 60.2"" (JIRA patch era, no PR; issues #9170 and #9223). Its Default.rbbi let the word macros absorb a trailing ZWJ and added WB3c $ZWJ ($Extended_Pict | $EmojiNRK); with no status. It re-lands 07407a5b53bf / 96be7b432ebd (2018-01-08), reverted on 2018-02-20 for an unrelated ICU4J concurrency bug before any release. With ICU4J held at 61.1, the 7.3.1 rule file gives abc status 200 and the 7.4.0 one gives the joined segment status 0, so the rule change alone causes it.

Older ingredients, not regressions by themselves: the status-0 skip (LUCENE-2414, 2010) and the first-code-point emoji check (LUCENE-8125, also 7.4.0). Later rule rewrites (LUCENE-8366 / ICU 62.1 in 7.5.0, #13239 / ICU 74.2, #14386 / ICU 77.1 in 10.2.0) kept the behaviour, so git blame on today's lines points at them. Not fixed upstream: after LUCENE-8366 (June 2018), which changed isEmoji's character set and rewrote much of Default.rbbi, BreakIteratorWrapper.java has had only formatting commits, ICUTokenizer.java only formatting commits since the March 2018 re-land, and Default.rbbi only the two ICU upgrades, which kept the behaviour; the only open PR touching these files deletes them on branch_10x.

Caveats: no commit-level bisect (the 2018 commits use the old build system and were not built); the 7.3.1 to 7.4.0 release boundary and the same-ICU rule-file isolation stand in for it. The rule probe compiles the .rbbi source rather than loading the shipped Default.brk; the release sweep loads the shipped jars.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis page's JUnit fails 5 of 6 with -ea and -da; the finder's 3 of 4; the independent reproduction's 6 of 10
10.5.2, 9.12.3 affectedthis page's JUnit fails 5 of 6 (-da); the probe drops 5 of 5 bug-case words with -ea and -da
10.0.0-10.5.1, 9.0.0-9.12.2, 8.0.0-8.11.4 affectedthe probe drops 5 of 5 bug-case words, 0 of 4 controls
7.4.0-7.7.3 first affectedthe probe drops 5 of 5; the independent reproduction's main() variant drops all 4 bug inputs on 7.4.0
7.0.0-7.3.1 not affectedthe probe keeps all words (emoji were not tokens before 7.4.0)
7.3.1 vs 7.4.0 rule files, ICU4J 61.1 isolation7.3.1 rules: abc status 200; 7.4.0 rules: abc+ZWJ+๐Ÿ‘ status 0
main with fix A fixthe repro passes 6 of 6 and the exact-token test 10 of 10 (-ea and -da); the ICU module's 174 tests: 0 failed, 10 skipped

Independent checks

Reproduced by the finder, independent verifiers with their own tests, cross-model reviews by OpenAI GPT-5.6 that ran their own tests (both real, high), an adjudication with a fresh seed and an independent audit with new inputs, all with and without assertions.

Checks and verdicts465 words

Each check worked from the code and its own runs. This page's runs added a new repro, two fixes, gradle runs of the whole ICU module, kana and ideograph probes and a search check.

Each independent check and its verdict
CheckVerdictWhat it found
Finder's harness and JUnit candidateEvery string with X + ZWJ + ๐Ÿ‘ (U+1F44D) in 2,593,337 x 2 configs dropped X; the JUnit fails 3 of 4 on main with -ea and -da, on 10.5.2 and on 9.12.3.
Cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Its own JUnit fails both defect cases with -ea and over 3 iterations with -da; the status diagnostic shows one WORD_NONE span; release probe fails on 7.4.0 and 10.5.2, passes on 7.3.0. Intended argument: moderate.
Independent reproducer realOwn 10-test JUnit fails 6 of 10 on main (-ea, -da), 10.5.2 and 9.12.3, controls pass; 7.4.0 and 8.11.4 affected, 7.3.0 not; a root-cause probe makes it pass and upstream TestICUTokenizer 45/45.
Cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)Own test reproduces the full token loss; Lucene's rules and ICU ROOT both give WORD_NONE; "ICU ROOT parity is the best closure argument, but Lucene already tailors ICU status to produce searchable emoji tokens, so parity is not dispositive".
Intended-behaviour devil's advocate realNo intended argument survives; also loses digits and an ideograph mid-sentence; found Lucene's WordBreakTest stance and the NFKC_CF char-filter workaround; the atomic chillu is unaffected.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit with seed 7A11D6E5: 6 of 10 fail with -ea and with -da, 0 with the root-cause probe.
History regression in 7.4.067 affected releases and 7 clean ones executed; rule-file isolation with ICU4J 61.1.
Security screen noneNo hang or amplification, CheckIndex clean, stored text intact, nothing from other documents; a query clause that analyzes to nothing is dropped.
Credibility audit keepFinder's JUnit 3 of 4 with -ea and -da; own probe with new inputs; an indexed Paris+ZWJ+โค๏ธ document is not found by body:Paris.
This page's runs fails 5 of 6 on main, 10.5.2, 9.12.3; fix A passesThe repro runner and gradle, -ea and -da; the whole ICU module and its static checks with fix A and its exact-token test; fix B; token, kana, edge-case, extent, search and timing probes.
Fact checks of this page (independent, and OpenAI GPT-5.6) bug confirmed; fix A revisedBoth re-ran the repro on main (and on 10.5.2 and 9.12.3) and confirmed the bug, the release boundary and the security classification. They found an input the first fix A missed (a word ending in an emoji-like letter, aโ„น+ZWJ+๐Ÿ‘), the wider loss in dictionary-segmented text, and text errors. Fix A was revised and re-validated, and the text corrected.

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

Finding record

LFV-659, snapshot of 2026-11-06

Registry title
ICUTokenizer silently drops a word that is directly followed by ZERO WIDTH JOINER and an emoji, together with the emoji, because the joined 'word ZWJ emoji' segment gets rule status 0 (UAX #29 WB3c chain in Default.rbbi) and the emoji check looks only at its first code point
Area
analysis / ICU (ICUTokenizer rule status: Default.rbbi WB3c chain, BreakIteratorWrapper emoji status, status-0 skip)
Priority
Low, rank 291: Needs a word directly followed by an invisible ZERO WIDTH JOINER and an emoji, a rare input.
Impact class
silent wrong results
Impact note
Applications that analyze text with ICUTokenizer (the analysis-icu module: new ICUTokenizer() with the default config or with cjkAsWords=false, and by code reading the 'icu' factory with default parameters) silently lose a letter, digit or ideograph run together with the following emoji whenever the run is immediately followed by U+200D and an Extended_Pictographic character ('abc' ZWJ U+1F44D, '2024' ZWJ U+1F44D, 'hello world' ZWJ U+1F44D ' again' losing 'world', or a Malayalam word in the legacy ZWJ chillu encoding before an emoji), so a TermQuery for that word misses the document, deterministically with -ea and -da and at any input size, in every release from 7.4.0 to 10.5.2; nothing throws, CheckIndex is clean, the stored value is intact, the rest of the text is tokenized normally, and an ICUNormalizer2CharFilter (NFKC_CF) in front hides it.
Summary
You index text through the stock ICUTokenizer in which a word runs straight into an invisible ZERO WIDTH JOINER (U+200D) and then an emoji, for example 'abc' + U+200D + U+1F44D, or a Malayalam word written in the older ZWJ chillu form followed by an emoji, and expect the word and the emoji to be tokens, as they are with a space before the emoji, without the ZWJ, or with StandardTokenizer. Instead neither appears: Unicode rule WB3c (ZWJ x Extended_Pictographic) keeps 'word ZWJ emoji' in one segment, the break rules give that segment rule status 0 ('not a word'), BreakIteratorWrapper's emoji check looks only at the segment's first code point (a letter), and ICUTokenizer skips every status-0 segment, so 'hello world' + U+200D + U+1F44D + ' again' gives only [hello, again]. A TermQuery for the lost word finds nothing, with no error.
Introduced
2dcf263b5207243f6854c0e48d2496036f678eee (branch_7x, the commit that shipped in 7.4.0; same change on master 4522e45bdadd4268a9270135130fc28a7f46c627), by rmuir, 2018-03-26, 'LUCENE-8175: un-revert "LUCENE-8122: Upgrade analysis/icu to ICU 60.2"' (JIRA patch era, no PR; issues LUCENE-8122 / #9170 and LUCENE-8175 / #9223, neither discussing word + ZWJ + emoji input or rule statuses). Its Default.rbbi added $ZWJ, let the word macros absorb a trailing ZWJ, and added the UAX #29 WB3c rule '$ZWJ ($Extended_Pict | $EmojiNRK);' with no status tag, so under !!chain a word ending in ZWJ chains into a status-less rule [read: git log -S/-G, blame -w -M -C and merge-base --is-ancestor in the full-history clone; exec: with ICU4J held at 61.1, the 7.3.1 rule file gives the 'abc' segment status 200 and the 7.4.0 rule file gives the merged 'abc' ZWJ U+1F44D segment status 0]. It re-lands 07407a5b53bf (master) / 96be7b432ebd (branch_7x) of 2018-01-08, reverted 2018-02-20 for an unrelated ICU4J concurrency bug before any release. Older ingredients that are not regressions by themselves: the status-0 skip in ICUTokenizer (LUCENE-2414, 5abbf3429cf, 2010) and the first-code-point isEmoji of LUCENE-8125 (c0b92e27942 / 23bff7dbc20, same day, also 7.4.0). Later rule rewrites kept the behaviour [exec on both sides]: LUCENE-8366 (2ea416ee3d2, ICU 62.1, 7.5.0, current form of Default.rbbi:107), #13239 (54a2e11434d, ICU 74.2, current form of :122) and #14386 (ICU 77.1, 10.2.0), so git blame on today's lines does not point at the introduction. (2018-03-26 (shipped re-land 2dcf263b5207 / 4522e45bdadd); first landed 2018-01-08 (07407a5b53bf / 96be7b432ebd), reverted 2018-02-20 before any release)
First affected version
7.4.0 [exec] (7.3.1, the last release before, keeps the word [exec], as do all of 7.0.0-7.3.1; before 7.4.0 ICUTokenizer emitted no emoji tokens at all, so only the lost word is a regression, the lost emoji only relative to the same version's 'abc U+1F44D' output)
Affected versions
Every release from 7.4.0 to 10.5.2 (the last release before 10.6.0) and main a9779ef3c193; never fixed. [exec] the follow-up's public-API main() probe ZwjHistoryMain drops 5 of 5 bug-case words and 0 of 4 controls on all 67 releases 7.4.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3 and 10.0.0-10.5.2 and on main (-ea; -da also on 9.12.3, 10.5.2 and main), and 0 of 5 on all 7 releases 7.0.0-7.3.1; the finder's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 6 of 10 on main (-ea and -da), 10.5.2 and 9.12.3, and its main() variant on 8.11.4 and 7.4.0 (7.3.0 keeps the words); the cross-model reviewers' release probes fail on 7.4.0 and 10.5.2 and pass on 7.3.0; the intended-behaviour check re-ran the finder's JUnit on 10.5.2. 8.8.2-8.11.4 are untagged in the history clone and were run from checksum-verified release jars. [read] upstream main and branch_10x: BreakIteratorWrapper.java and ICUTokenizer.java have only formatting commits since 2018, Default.rbbi only the ICU upgrades #13239 and #14386 (both executed on either side), and no open PR fixes it, so the unreleased 10.x and 11.0 lines are affected. JDK 25.0.3, aarch64; pure Java and ICU4J logic.
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
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-27 plus the finder's run, read-only; the prior-report record: 240 logged queries and reads, namely two hunt prior-report tool runs (140 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 40 manual GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, unicode-org/icu and unicode-org/icu4x, 11 ICU JIRA and 9 ASF JIRA (LUCENE, SOLR) queries, 20 Lucene and Solr list queries, 10 extra web queries, live commit lists for the 4 files and the changed files of all 409 open apache/lucene PRs; plus the finder's tool run of 45 queries). Not fixed upstream: BreakIteratorWrapper.java and ICUTokenizer.java have only formatting commits since 2018 and Default.rbbi only the ICU upgrades #13239 (74.2) and #14386 (77.1), which kept the behaviour; the only open PR touching the files is #16911 ('Decommission branch 10x', which deletes them on branch_10x). Related items, none reporting this bug: #9173 / LUCENE-8125 (origin of the emoji status, called 'not a perfect science' and 'very conservative' by its author; its tests and comments cover only ZWJ sequences that start with an emoji), #9170 / LUCENE-8122 and #9413 / LUCENE-8366 (the ICU upgrades that brought and reshaped the WB3c rule), open #6140 / LUCENE-5076 (a first-code-point loop in an older calcStatus that LUCENE-8125 removed; no ZWJ, emoji or lost text), ICU-20966 (ICU JIRA: a browser engine dropped WORD_NONE segments for a lone bullet or emoji; ICU called the rule status 'secondary information' and closed it as caller-side), #9573 / LUCENE-8527 (StandardTokenizer emoji grammar), open #10793 / LUCENE-9754 (script-run splitting, loses no text), elastic/elasticsearch#31224 and jolicode/emoji-search#21 (an emoji on its own eliminated under a custom rule file before 7.4, closed as configuration). Gaps: the literal-U+200D GitHub query was rate-limited and not retried (the 'ZWJ', 'zero width joiner' and '200D' queries cover it); Wikimedia Phabricator, the Elastic and OpenSearch forums and Stack Overflow were covered only through the web index; private reports to security@lucene.apache.org are not visible. Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main a9779ef3c193 (2026-10-27) on OpenJDK 25.0.3, Linux aarch64, ICU4J 78.3: this page's JUnit through the repro runner with -ea and -da and through gradle in a scratch worktree (stock, fix A, fix B; the worktree was removed after the runs); the whole ICU module test suite with fix A (assertions on and off) and its static checks; the 10.5.2 and 9.12.3 release jars; a public-API probe on all 74 releases 7.0.0-10.5.2 (history step).

Lucene and upstream sources

Finding record
  • lucene/analysis/icu/src/data/uax29/Default.rbbi :46 (!!chain), :105-107 (WB3c '$ZWJ $Extended_Pict;', no status), :122 ('[^$CR $LF $Newline $ExFm] $ExFm*;', 'no special rule status value')
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/BreakIteratorWrapper.java :25 ('emitting a rule status for emoji sequences'), :57-66 (calcStatus), :75-91 (isEmoji reads only the first code point)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizer.java :31 ('Breaks text into words according to UAX #29'), :38 (@lucene.experimental), :41 (IOBUFFER = 4096), :209-214 (skip status-0 segments; test at :211)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/DefaultICUTokenizerConfig.java :28-38 (tailorings: dictionary breaking only), :73 (cjkBreakIterator = ICU ROOT word instance), :81-82 (Default.brk), :111-122 (Japanese script to the ROOT iterator, others to Default.brk); lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizerFactory.java :96, :112, :156 (default parameters build DefaultICUTokenizerConfig) [read]
  • lucene/analysis/icu/src/test/org/apache/lucene/analysis/icu/segmentation/TestICUTokenizer.java :491-497 (testEmojiTokenization 'poo' U+1F4A9 'poo' -> poo, U+1F4A9, poo)
  • lucene/test-framework/src/java/org/apache/lucene/tests/analysis/standard/generateJavaUnicodeWordBreakTest.pl :129-140 (special-cases '0061 x 200D x 1F6D1' for StandardTokenizer)
  • SECURITY.md :28, :38, :42, :50 (threat model; cited by the security record)
The finding in 30 seconds
  • lucene/analysis/icu/src/data/uax29/Default.rbbi:46, 104-107, 116, 122-131
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/BreakIteratorWrapper.java:25-26, 57-66, 75-91
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizer.java:31, 38, 69-70, 209-214
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/DefaultICUTokenizerConfig.java:28-38, 73, 81-82, 111-123
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:367-369
Who is affected
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizer.java:38 (@lucene.experimental), :69-70 (default config)
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizerFactory.java:96-97, 112, 156
Why it looks unintended
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/ICUTokenizer.java:31, 211
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/DefaultICUTokenizerConfig.java:28-38, 80
  • lucene/analysis/icu/src/java/org/apache/lucene/analysis/icu/segmentation/BreakIteratorWrapper.java:25-26
  • lucene/analysis/icu/src/test/org/apache/lucene/analysis/icu/segmentation/Latin-break-only-on-whitespace.rbbi:27-28
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.jflex:166-208
  • lucene/analysis/icu/src/java/overview.html:58, 67
  • lucene/analysis/icu/src/test/org/apache/lucene/analysis/icu/segmentation/TestICUTokenizer.java:456-465, 491-497, 506-519
  • lucene/test-framework/src/java/org/apache/lucene/tests/analysis/standard/generateJavaUnicodeWordBreakTest.pl:130-141; WordBreakTestUnicode_12_1_0.java:28-39

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

Repro files

About the diagrams

The drawn example uses the emoji U+1F600; the repro mostly uses U+1F44D, which behaves the same way.

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.