All findings · Lucene finding · LFV-609 · passageformatter-crossing-markers-close-order

matchhighlight PassageFormatter closes crossing highlights in the wrong order, so per-highlight end tags cross

With a separate end tag per highlight, matchhighlight's PassageFormatter closes crossing highlights (overlapping, not nested) outer first: foo bar baz with [0,7) as <a> and [4,11) as <b> gives <a>foo <b>bar</a></b><b> baz</b>, not <a>foo <b>bar</b></a><b> baz</b>. Every release since 9.0.0 has it; one shared end tag hides it.

Summary

Priority
Low, rank 1048Matchhighlight's PassageFormatter mis-nests crossing markers only when per-marker end strings differ and two matches cross in this little-used package, so it is cosmetic output only.
Impact
minor
Since
9.0.0 (LUCENE-9463, 2020)introduced 2020-08-14 · about 6 years
Affected
9.0.0 to 10.6.0 (all 34 releases) and main
Status
documented
Found by
exhaustive small-input check against a reference formatter, then a public-API JUnit

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

The formatter wraps each highlight in a start and an end string; where two highlights overlap, it closes and re-opens the inner one so the markup stays nested, but it writes the two end strings in the wrong order.

Background: how the formatter handles overlapping highlights

  1. The matchhighlight package finds where queries match in a field value as character ranges, picks passages, and PassageFormatter wraps every range in a start and an end string, for example <b> and </b>. The strings can be one pair for all ranges or computed per range, for example one tag per query.
  2. Two ranges can overlap without one containing the other: foo bar = [0,7) and bar baz = [4,11) share bar. Wrapping each one as is would cross the tags.
  3. So the formatter keeps a stack of open ranges. Where a range ends, it closes the ranges opened after it, closes the range itself and re-opens the others, which splits bar baz into bar inside foo bar and baz after it.
Diagram of the bug: the text foo bar baz with two overlapping highlights, [0,7) foo bar as <a> and [4,11) bar baz as <b>. At offset 7, where <a> ends, <b> is still open inside it; the formatter closes </a> first and then </b> and re-opens <b>, giving <a>foo <b>bar</a></b><b> baz</b> with crossed tags. With the fix it closes </b> first, giving <a>foo <b>bar</b></a><b> baz</b>.
Two overlapping highlights over foo bar baz and the stack of open highlights at offset 7: the formatter closes </a> before </b>; the fix closes </b> first. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You give each highlight its own tags with new PassageFormatter("...", startFn, endFn) and format foo bar baz with two overlapping highlights: [0,7) = foo bar as <a>...</a> and [4,11) = bar baz as <b>...</b>.
  2. At offset 7, where <a> ends, <b> is still open inside it. You expect the formatter to write </b>, then </a>, then re-open <b>: <a>foo <b>bar</b></a><b> baz</b>.
  3. Instead you get <a>foo <b>bar</a></b><b> baz</b>: </a> comes before </b>, so the tags cross and an XML parser rejects the passage. The text and the highlighted characters are right; no error is raised.
  4. The same happens end to end: MatchHighlighter with the phrase queries foo bar and bar baz and one tag per query returns <q1>foo <q2>bar</q1></q2><q2> baz</q2>.
  5. Why: at each highlight's end, the loop that closes the highlights opened after it walks them outer first instead of inner first. With one shared end tag both orders print the same bytes, which is why Lucene's own callers and tests never show it.

Who is affected

Applications that build their own highlighter from matchhighlight and give highlights different end strings through the public Function constructor, for example one tag per query (the 'different colors' use case). It takes two matches in one passage that overlap without nesting, such as phrases foo bar and bar baz. The String constructor and every caller in Lucene are unaffected.

Mechanism, terms and numbers613 words

PassageFormatter, the matchhighlight package's passage formatter, takes the start and end strings either as constants or as functions of each highlight (PassageFormatter(String, Function<OffsetRange,String>, Function<OffsetRange,String>)). When two highlights in a passage overlap without one containing the other, it keeps the output nested: where the first one ends, it closes the second, closes the first and re-opens the second. It closes them in the wrong order, outer first, so foo bar baz with [0,7) as <a> and [4,11) as <b> comes out <a>foo <b>bar</a></b><b> baz</b> instead of <a>foo <b>bar</b></a><b> baz</b>, which an XML parser rejects.

The text, the highlighted characters and the balance of start and end strings are right; only the order of the end strings where highlights cross is wrong (two in this example; a chain of three crossing highlights puts three in opening order). With one shared end string, which every Lucene caller and test uses, both orders print the same bytes, so Lucene's own callers and tests cannot show the defect, and have not since the class was added in 9.0.0. A one-line change, closing from the top of the stack down, fixes it and leaves shared-end-string output byte-identical.

The code (main 645d4fe14a20, lucene/highlighter/.../matchhighlight/PassageFormatter.java)

  1. format(StringBuilder, CharSequence, Passage) (:110-135) handles 0 and 1 markers inline and sends 2 or more to multipleMarkers ("Multiple markers, possibly overlapping or nested.", :127-131).
  2. slicePoints (:175-201) makes a START and an END point per marker, sorted by offset; at one offset ends come before starts (END(1), START(2), :204-206), "Longer start slice points come first" (:190) and "Shorter end slice points come first" (:193): the inner marker ends first.
  3. multipleMarkers (:138-173) keeps markerStack, the open markers in opening order. START (:147-150) pushes the marker and appends its start string. END (:152-161):
int markerIndex = markerStack.lastIndexOf(currentMarker);          // :153
for (int k = markerIndex; k < markerStack.size(); k++) {           // :154  bottom-up: outer first
  b.append(markerEnd.apply(markerStack.get(k)));
}
markerStack.remove(markerIndex);                                   // :157
for (int k = markerIndex; k < markerStack.size(); k++) {           // :158  re-open, bottom-up (correct)
  b.append(markerStart.apply(markerStack.get(k)));
}

The markers above markerIndex were opened after the ending marker and are still open: they sit above it on the stack ([4,11) starts inside [0,7) but is not contained in it). Closing and re-opening them keeps the output nested only if they are closed top of the stack down. The re-open loop correctly runs bottom-up; the close loop runs bottom-up as well, and that is the bug.

Trace for foo bar baz, [0,7) as <a>, [4,11) as <b>

offsetslice pointstack beforeappended nowappended with the fix
0START [0,7)[]<a><a>
4START [4,11)[a]<b><b>
7END [0,7)[a, b]</a></b>, then re-open <b></b></a>, then <b>
11END [4,11)[b]</b></b>

Result: <a>foo <b>bar</a></b><b> baz</b>; with the fix <a>foo <b>bar</b></a><b> baz</b>.

When it shows

  • Only for crossing markers: a nested marker is on top of the stack when it ends, so the close loop runs once. Longer crossing chains put more end strings out of order: abcdefghi with [0,5) as <a>, [2,7) as <b> and [4,9) as <c> gives <a>ab<b>cd<c>e</a></b></c><b><c>fg</b></c><c>hi</c>, three end strings in opening order at offset 5; with the fix <a>ab<b>cd<c>e</c></b></a><b><c>fg</c></b><c>hi</c> (executed, assertions on and off).
  • Only when end strings differ: the String constructor (:42-44) maps every marker to the same end string, and the same strings in another order are the same bytes. With a shared <m>/</m> the crossing case prints <m>foo <m>bar</m></m><m> baz</m>, well nested and identical with the fix (executed).
Exposure and likelihood380 words
  • Who. Users of PassageFormatter(String, Function<OffsetRange,String>, Function<OffsetRange,String>) (PassageFormatter.java:46-53) whose end strings differ per marker, for example <q1>/<q2> or <em>/<strong> per query, the use case LUCENE-9461 (#10501) names: "there can be more than one query at one time and they should highlight the same content (with different colors)". Paths: a direct PassageFormatter.format call, or MatchHighlighter with FieldValueHighlighters.highlighted(maxPassageWindow, maxPassages, passageFormatter, matchFields) (FieldValueHighlighters.java:91-115), where the application supplies the formatter.
  • Opt-in, not default. The String constructor and every in-tree caller share one end string (FieldValueHighlighters.java:59 uses ""; the tests use ">"/"<" or "<span>"/"</span>"). The upstream per-query example varies only the start string and shares "</span>" (TestMatchHighlighter.java:734-742).
  • Trigger. Two matches in one passage that overlap without nesting. No index size, codec, analyzer setting or concurrency is involved; it is deterministic and the same with assertions on and off.
  • Effect (a probe of 95,919 random passages with 2-5 markers, main with assertions on and off and 10.5.2): the buggy and the fixed output have the same characters and length in every case and differ in order in 37,680. A strict XML parser rejects all 37,680; the fixed output is always well formed. With inline highlight tags (four sweeps, 76,744 of the passages), HTML parsing (jsoup's HTML parser; no browser run) recovers the intended highlighting when every marker has its own tag name (0 differences in 19,215 passages); with one tag per query, where several markers share a tag name, part of one highlight is lost in 135 of 19,205 (em/mark) and 309 of 19,132 (mark/span) passages, and in none of 19,192 (b/i). With these inline tags a highlight is never added and the text never changes. A fifth sweep with block-level tags (div, p) as markers, not a realistic highlight setup (19,175 passages), loses labels in both outputs (buggy 4,089, fixed 3,983); only the buggy output also gains labels (1,798) and has its parsed text moved (3).
  • Reach. GitHub code search (best effort) found no use of the matchhighlight package in Solr, Elasticsearch or OpenSearch. #16820 (2026-10-12) proposes deprecating the classic and fast-vector highlighters and points users to "the matches highlighter components" for bespoke needs.

Minimal repro

One test class with one method on the public API: a PassageFormatter whose tags depend on the highlight, one passage over foo bar baz with two overlapping highlights, and one assertEquals. No index, no randomness, single thread; it fails with assertions on and off.

TestPassageFormatterCrossingMarkers.java (one class, one method, public API)33 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.search.matchhighlight;

import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestPassageFormatterCrossingMarkers extends LuceneTestCase {

  public void testCrossingMarkersCloseInnermostFirst() {
    PassageFormatter formatter =
        new PassageFormatter(
            "...", r -> r.from == 0 ? "<a>" : "<b>", r -> r.from == 0 ? "</a>" : "</b>");
    Passage passage = new Passage(0, 11, List.of(new OffsetRange(0, 7), new OffsetRange(4, 11)));
    String out = formatter.format(new StringBuilder(), "foo bar baz", passage).toString();
    // expected: <b> (opened last) closes before </a>; actual: "<a>foo <b>bar</a></b><b> baz</b>"
    assertEquals("<a>foo <b>bar</b></a><b> baz</b>", out);
  }
}
Command
cp $REPRO/TestPassageFormatterCrossingMarkers.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.matchhighlight.TestPassageFormatterCrossingMarkers -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false; the test uses no randomness, so the seed does not matter
Expected
<a>foo <b>bar</b></a><b> baz</b>: the inner <b> is closed before </a>.
Observed
ComparisonFailure: expected:<<a>foo <b>bar</[b></a]><b> baz</b>> but was:<<a>foo <b>bar</[a></b]><b> baz</b>>

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

More files, environment, full output195 words

The test sits in org.apache.lucene.search.matchhighlight by upstream convention and uses only public API (PassageFormatter, Passage, OffsetRange); the marker functions pick the tag from the range's start offset. The same test is the suggested regression test: it passes with the fix and passes the highlighter lint checks as written.

Not asserted here (shown by the verification runs): the end-to-end MatchHighlighter case with two phrase queries, the XML parser's rejection, that the text is preserved, and that a shared end string hides the order.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 9.1.0 to 10.6.0: fail (the minimal test run on 10.6.0, 10.5.2 and 9.1.0; the rest have identical code); 9.0.0: fails with the import changed to the old `org.apache.lucene.util.LuceneTestCase` (as written it does not compile there); with the fix: passes
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

# stock main 645d4fe14a20, gradle, seed DEADBEEF (identical with -Ptests.asserts=false); abridged
TestPassageFormatterCrossingMarkers > testCrossingMarkersCloseInnermostFirst FAILED
    org.junit.ComparisonFailure: expected:<<a>foo <b>bar</[b></a]><b> baz</b>> but was:<<a>foo <b>bar</[a></b]><b> baz</b>>
        ...
        at app//org.apache.lucene.search.matchhighlight.TestPassageFormatterCrossingMarkers.testCrossingMarkersCloseInnermostFirst(TestPassageFormatterCrossingMarkers.java:31)
BUILD FAILED

# 10.6.0 release jars, standalone JUnit run, assertions on and off: the same ComparisonFailure

# with the fix (asserts true and false)
:lucene:highlighter:test (SUCCESS): 1 test
BUILD SUCCESSFUL

Why it looks unintended

  • The public constructor takes an end string per highlight; the Passage javadoc says markers "can be overlapping or nested".
  • Closing and re-opening the inner highlight only serves nesting: if crossed tags were fine, <a>foo <b>bar</a> baz</b> would do.
  • The re-open loop and the slice-point order already put the inner highlight first; the close loop does not.
The full argument250 words
  • The API invites per-marker end strings. PassageFormatter(String, Function<OffsetRange, String> markerStart, Function<OffsetRange, String> markerEnd) (PassageFormatter.java:46-53), present since the class was added (150a8dacb59), with no restriction anywhere that end strings be equal.
  • Crossing markers are legal input. Passage.java:23-24: "The markers can be overlapping or nested, but they're always contained within the passage." PassageFormatter.java:128: "Multiple markers, possibly overlapping or nested."; :137: "Handle multiple markers, possibly overlapping or nested."
  • The class resolves overlaps. PassageFormatter.java:29-33: "Formats a collection of passages over a given string, cleaning up and resolving restrictions concerning overlaps, ..."; "Passages are demarcated with constructor-provided ellipsis and start/end marker sequences."
  • The design is nesting. The re-open loop runs bottom-up, the order that keeps nesting, and the slice-point comparator puts the inner marker's end first when two ends fall on one offset ("Shorter end slice points come first", :193). TestPassageSelector#checkOddOverlaps (TestPassageSelector.java:64-72) pins the close-and-re-open shape, foo >bar >baz<<> abc< xyz, with one shared end string, so it cannot see the order.
  • Stated intent. The class's author wrote on LUCENE-9439 (#10479, 2020-07-31) that hit ranges go to the "passage selector and a simple test-only formatter - these clean up potential overlaps and nesting problems (which is useful for HTML, for example)", with constant >/< markers. LUCENE-9461 (#10501) asks for several queries highlighting the same content "with different colors".
  • History. No issue, PR, test or CHANGES entry records the order as intended, and the loop has not changed since it was written.
The devil's-advocate check133 words

Strongest case (a cross-model refute review and an intended-behaviour devil's advocate): no sentence promises well-nested output; the marker strings are opaque; MatchHighlighter calls itself "An example highlighter" (MatchHighlighter.java:38); and every in-tree caller and test shares one end string, so the order cannot be observed in documented usage. A committer could call per-marker end strings over crossing markers unspecified.

Why it fails: the per-marker Function constructor exists to vary the strings and says nothing against it; crossing markers are explicitly allowed; the close-and-re-open step only makes sense for nesting, and its own re-open loop and comparator follow the nesting order; and the fix changes nothing for users with one shared end string. No documented valid-HTML or XML guarantee exists; the case rests on the constructor, the Passage javadoc and the close-and-re-open design.

Suggested fix

Recommended Close innermost first: reverse the close loop (validated)

Close the markers opened after the ending one from the top of the stack down: one changed for line in PassageFormatter.multipleMarkers. Same strings and count, new order only where markers cross (for side-effect-free marker functions); shared-end-string output is byte-identical. Validated on main: the repro passes, all 723 highlighter tests pass, lint is clean.

         case END:
           int markerIndex = markerStack.lastIndexOf(currentMarker);
-          for (int k = markerIndex; k < markerStack.size(); k++) {
+          // Close the markers opened after this one first (innermost first), then this one.
+          for (int k = markerStack.size() - 1; k >= markerIndex; k--) {
             b.append(markerEnd.apply(markerStack.get(k)));
           }
           markerStack.remove(markerIndex);
All options and trade-offs3 options · 554 words

The first option is the recommended, validated fix. The other two (document the restriction; remove crossings before formatting) are listed for completeness and were not run.

Close innermost first: reverse the close loop (validated) (recommended)

In the END branch of multipleMarkers (PassageFormatter.java:154), run the close loop from markerStack.size() - 1 down to markerIndex. The re-open loop stays bottom-up. The comment line is optional.

         case END:
           int markerIndex = markerStack.lastIndexOf(currentMarker);
-          for (int k = markerIndex; k < markerStack.size(); k++) {
+          // Close the markers opened after this one first (innermost first), then this one.
+          for (int k = markerStack.size() - 1; k >= markerIndex; k--) {
             b.append(markerEnd.apply(markerStack.get(k)));
           }
           markerStack.remove(markerIndex);

Trade-offs

  • None found for marker functions without side effects (every in-tree one, and every one in the probes). The same strings are appended the same number of times; only their order changes, and only where markers cross and end strings differ.
  • Output with one shared end string, the String constructor and every in-tree use, is byte-identical, so no in-tree caller or test sees a change.
  • The order in which markerEnd is called at a crossing changes too; only a stateful marker function could notice.
  • The regression test's exact expected string pins this option's close-and-re-open shape; removing crossings before formatting would need a different expected string.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --offline, seed DEADBEEF. Stock: the repro fails with -Ptests.asserts=true and false. With the fix: it passes with both.
  • :lucene:highlighter:test --tests 'org.apache.lucene.search.matchhighlight.*' (TestMatchHighlighter, TestMatchRegionRetriever, TestPassageSelector and the repro): 49 tests, 0 failures, with asserts true and false.
  • :lucene:highlighter:test, the whole module (35 suites, asserts true): 723 tests, 0 failures, 0 skipped.
  • Lint (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest on the highlighter module, the repro test included): BUILD SUCCESSFUL.
  • Earlier: a reference copy of the formatter with only this loop reversed had 0 nesting failures in 1,671,813 exhaustive calls (every ordered list of 1-3 markers on values up to 9 characters, constant, per-marker and per-query labels: chains of three crossing markers, shared starts and ends, identical ranges with different labels); the fixed output was well formed in all 95,919 passages of the random probe.
  • Edge cases on the real class (assertions on and off): the three-marker chain [0,5), [2,7), [4,9) comes out well nested with the fix; two identical ranges with different labels are well nested stock and fixed, and with a third range crossing them only this bug shows, which the fix nests.
  • Not run: the module with asserts false, other modules, x86.

Document the restriction instead (not validated)

State in the javadoc that crossing markers come out with crossed end strings unless all end strings are equal.

Trade-offs

  • No behaviour change, but per-marker end strings stay unusable for overlapping matches, the use case LUCENE-9461 names, and the close-and-re-open step keeps doing work that no longer achieves anything.

Remove crossings before formatting (not validated)

Merge crossing markers, as LUCENE-8446 (#9492) did for the unified highlighter's DefaultPassageFormatter, or emit non-overlapping fragments, the (AAA)(C)(BBB) shape Dawid Weiss sketched on LUCENE-8401 (#9448, 2018).

Trade-offs

  • A larger change: it would also change the output for constant markers, whose close-and-re-open shape TestPassageSelector#checkOddOverlaps pins, and merging into today's single-range markers loses which query matched the overlap unless the merged marker keeps both.

Downstream note

No use of the matchhighlight package was found in Solr, Elasticsearch or OpenSearch; an application that puts these passages into XML or XHTML gets output that a strict parser rejects.

How it was found

An exhaustive small-input check: every passage of a value up to 10 characters with 1-3 highlights, against a reference formatter. Text, coverage and balance never failed; nesting failed in 500,214 of 1,228,656 calls with one tag per highlight (one of three label schemes), and never in a copy with the close loop reversed. Then cut to a public-API JUnit.

The method in full251 words
  • Method. Bounded-exhaustive translation validation of the real formatter on the stock jars: every passage of a value of up to 10 characters and every ordered list of 1-3 markers inside it (overlapping, nested, duplicate), under three label schemes (constant strings, one label per marker, one label per query). Five relations were checked against an exact reference: F1 stripping the markers gives the passage text, F2 every end string closes the most recently opened still-open marker, F3 the open labels at each character equal the markers covering it, F4 (soft) no empty element, F5 nothing left open.
  • Result. At length up to 10, 3,685,968 calls (three label schemes, 1,228,656 each): F1, F3 and F5 never failed; F2 failed 500,214 times (one label per marker) and 369,226 times (one label per query). A copy of the formatter with only the close loop reversed had 0 F2 failures in 1,671,813 calls (length up to 9), which shows both that the check can pass and that the one-line fix is enough for nesting. An end-to-end tier (1,641 documents x 21 queries through MatchHighlighter) showed the same nesting failure in 168,206 and 159,602 passages (position gap 0 and 1), over 5,684,424 highlighted document results each.
  • Origin. The close order was first suspected by reading the code while writing the reference; the exhaustive check then witnessed it. The hunt classes this as semi-formal.
  • Reduction. The hunt reduced it to a deterministic public-API JUnit (2026-10-26); the minimal-repro step cut that to the one-method test shown here (2026-10-29).

Prior reports, history and checks

Prior reports

The searches found no issue, PR, JIRA entry or list mail that reports it (searched 2026-10-26, refreshed 2026-11-01); the nearest items are design statements, and upstream main still has the loop.

Searches5 searches

The nearest items are design context, not reports: LUCENE-9439 (#10479), where the class's author says the formatter cleans up "overlaps and nesting problems"; LUCENE-9461 (#10501), "with different colors"; LUCENE-8401 (#9448, open, 2018), interleaved hits before the class existed; LUCENE-8446 (#9492), a fixed overlap-formatting defect in a different class, the unified highlighter's DefaultPassageFormatter. Code search finds the loop in 17 files, Lucene's own and 16 copies in other repositories, and no fork that reverses it. Limits: GitHub search is token-based and best effort; list search covers mail bodies only partly.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr), downstream repositories, JIRA archive, Lucene lists, web2026-10-26the scripted prior-report search in two runs (100 queries: class and method names, symptom phrases such as mis-nested, not well-formed, overlapping or closing tags, different colors), plus the finder's 43 and a verifier's 39 queriesno report; 29 hand-checked hits, none the same bug
GitHub, manual2026-10-2664 issue/PR/commit searches, 10 code searches (including markerStack.lastIndexOf), changed files of all 407 open PRsno report; only #16 and #16847 touch matchhighlight, neither this file
JIRA archive and mailing lists2026-10-2626 JQL queries with 15 issues read in full and the LUCENE-9439 patch; 74 list searches over dev@, java-user@, issues@, commits@, general@ and the Solr listsdesign statements only (LUCENE-9439, LUCENE-9461, LUCENE-8401)
git history2026-10-26git log -S on the loop and on markerStack.lastIndexOf; commit-message grepintroduced in 150a8dacb59, never changed
GitHub, refresh2026-11-01gh search issues --repo apache/lucene PassageFormatter sorted by update; gh api GETs of the file and its commits on upstream mainnothing newer than 2022; upstream main 2f424fd428da still has the loop

History

Introduced with the class in 9.0.0 by LUCENE-9463 (150a8dacb59, 2020-08-14) and never changed: every release from 9.0.0 to 10.6.0 and main has it (six releases run, the rest have identical code). Not fixed upstream.

Versions tested8 rows

150a8dacb59 (apache/lucene-solr#1750, merged into master 2020-08-14) added PassageFormatter with the Function constructor and the bottom-up close loop; the loop is already in the LUCENE-9439 proof-of-concept patch. The three later commits to the file (978446e4fdc, a range assertion; 6faa4f98e04, reformatting; ab453b9541a, unnamed lambda parameters) leave the END branch alone. The END block is byte-identical in all 34 9.x and 10.x release tags and at 645d4fe14a20. The end-to-end MatchHighlighter path came in e2f3f626ee4 (LUCENE-9464, 2020-09-10), also first released in 9.0.0. The release branches branch_11x (603af870375) and branch_10_6 (7e5aec4370e) have the same loop (local clone as last fetched, newest commits 2026-10-29), so the fix applies to both if backported.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (standalone and gradle); passes with the fix
10.6.0 affectedminimal test fails, assertions on and off
main a9779ef3c193 affectedthe hunt's test and an independent test fail, assertions on and off; pass with the fix
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe hunt's test fails; the minimal test also fails on 10.5.2 and 9.1.0 (fact check)
9.0.0 (first release) affectedthe hunt's test and the minimal test fail, assertions on and off (copies importing the old test-framework package; as written the minimal test does not compile there)
the other 28 releases from 9.0.0 to 10.5.2 affectednot run; END block byte-identical
introducing commit 150a8dacb59 affectedits three classes over the 9.0.0 jars fail; with only the loop reversed they pass
8.11.4 (last 8.x) not affectedno matchhighlight classes: the test does not compile

Independent checks

An independent reproducer with its own XML-parser oracle, an intended-behaviour devil's advocate and an adjudication confirmed it; read-only OpenAI GPT-5.6 reviews agreed on the root cause (one gave a procedural "not real" because it could not run code); the fix was validated on 645d4fe14a20.

Checks and verdicts260 words

Every execution came from the hunt, the independent reproducer, the devil's advocate, the adjudication, the follow-up and the later re-runs; the cross-model reviews were read-only.

Each independent check and its verdict
CheckVerdictWhat it found
Hunt candidateExhaustive formatter check (nesting failures only), reduced to a public-API JUnit; fails on main with assertions on and off and on 10.5.2, 10.0.0, 9.12.3; passes with the fix, and 48 matchhighlight tests pass with it.
Refute review (OpenAI GPT-5.6, read-only) not real, low confidence (procedural)Could not run code; agreed on the root cause and that the inputs are legal. A separate run re-ran the JUnit: 2 of 2 fail.
Intended-behaviour review (OpenAI GPT-5.6, read-only) real, mediumA separate run re-ran the JUnit on main and 10.5.2 (fail) and with the fix (pass).
Independent reproducer realOwn JUnit with an XML well-formedness oracle: 3 of 4 fail on main, 10.5.2 and 9.12.3 (direct call, passage selector, MatchHighlighter with two phrase queries); the control passes; 4 of 4 pass with the loop reversed.
Devil's advocate real, minorHigh confidence it is not intended; its probe shows the upstream shared-</span> pattern well nested and per-query em/strong end tags crossed.
Adjudication confirmed, high; minorRe-ran the independent JUnit (3 of 4 fail, assertions on and off) and the fix (4 of 4 pass).
Minimal repro and this doc's runs realOne-method test fails on main 645d4fe14a20 and 10.6.0 with assertions on and off; passes with the fix; module tests and lint pass with the fix.

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

Finding record

LFV-609, snapshot of 2026-11-06

Registry title
matchhighlight PassageFormatter closes crossing markers in opening order instead of innermost first, so per-marker end strings come out mis-nested (e.g. '<a>one <b>two</a></b><b> three</b>' instead of '<a>one <b>two</b></a><b> three</b>')
Area
highlighter / matchhighlight: PassageFormatter multi-marker formatting (crossing markers with per-marker end strings; also end to end through MatchHighlighter, FieldValueHighlighters.highlighted and MatchHighlighter.QueryOffsetRange)
Priority
Low, rank 1048: Matchhighlight's PassageFormatter mis-nests crossing markers only when per-marker end strings differ and two matches cross in this little-used package, so it is cosmetic output only.
Impact class
minor
Impact note
Hits applications that use the matchhighlight PassageFormatter through its public PassageFormatter(String, Function, Function) constructor with end strings that differ per marker (for example one tag per query via MatchHighlighter.QueryOffsetRange, the multi-query 'different colors' use case named in LUCENE-9461) whenever two crossing (overlapping, not nested) markers fall in one passage, for example the phrase queries 'one two' and 'two three' over 'one two three'; default codec, writer, analyzer and searcher suffice, no scale is needed, and -ea and -da behave the same; the text, the covered characters and the start/end balance stay correct and only the order of end strings at one offset is wrong, so strict XML/XHTML parsers reject the passage, while HTML parsing recovers the intended highlighting when every marker has its own tag name but can drop part of one highlight when two crossing markers share a tag name (135 and 309 of about 19,000 random passages in two such setups, HTML-standard parser, no browser run); with constant end strings (the String constructor, every in-tree caller and upstream test, including TestMatchHighlighter's per-query "<span class='...'>" example with a shared '</span>') the output is byte-identical to the correct one, and no use of the package was found in Solr, Elasticsearch or OpenSearch.
Summary
You format the passage 'one two three' with two crossing markers, [0,7) written as <a>...</a> and [4,13) as <b>...</b>, using PassageFormatter's Function constructor, or you highlight the crossing phrase queries 'one two' and 'two three' with MatchHighlighter.highlight(topDocs, q1, q2) and one tag per query. You expect well-nested output, '<a>one <b>two</b></a><b> three</b>', because when a marker ends the formatter closes and re-opens the markers opened after it precisely to resolve the overlap into nested markup (Passage javadoc: markers 'can be overlapping or nested'). Instead it closes those markers in opening order rather than innermost first and returns '<a>one <b>two</a></b><b> three</b>' (end to end '<q1>one <q2>two</q1></q2><q2> three</q2>'), which an XML parser rejects.
Introduced
150a8dacb59712c731e66db1a56068326a0439f4 'LUCENE-9463: Query match region retrieval component, passage scoring and formatting (#1750)' (apache/lucene-solr#1750, author dweiss, merged into master only; LUCENE-9463 migrated as apache/lucene#10503), which created PassageFormatter together with its public Function-based constructor and the ascending close loop; the same loop is already in the LUCENE-9439 proof-of-concept patch (2020-07-31) [read: git blame puts every line of the END branch at 150a8dacb59; git log --follow lists 4 commits on PassageFormatter.java and the later three (978446e4fdc LUCENE-9521 range assertion, 6faa4f98e04 LUCENE-9570 reformatting, ab453b9541a #14308 lambda parameters) leave the END branch alone; exec: 150a8dacb59's PassageFormatter, Passage and OffsetRange shadowed over the 9.0.0 jars fail the direct test 1/1, and the same sources with only the close loop reversed pass 1/1]. The end-to-end MatchHighlighter path came in e2f3f626ee4 (LUCENE-9464, lucene-solr#1820, 2020-09-10), also first released in 9.0.0. (2020-08-14 (merged into master))
First affected version
9.0.0 [exec: 2 of 2 fail with -ea and with -da, using a copy of the repro that imports the 9.0.0 test framework's org.apache.lucene.util.LuceneTestCase], the first release with the matchhighlight package; the work never reached 8.x (8.11.4 has no matchhighlight classes: compile error, API absent [exec])
Affected versions
every release from 9.0.0 through 10.6.0 (34 releases) and main a9779ef3c193; 10.6.0 [exec 2026-11-01]: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da. [exec, JDK 25, seed DEADBEEF] the finder's JUnit fails 2 of 2 on 9.0.0 (-ea and -da, old-test-framework copy), 9.1.0, 9.12.3, 10.0.0 and 10.5.2 (-ea) and on main (-ea and -da); the independent reproduction's JUnit fails 3 of 4 (its control passes) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's usage-pattern probe fails its per-marker-end-string case on main (-ea and -da) and 10.5.2. [read] the END block is byte-identical in all 33 9.x and 10.x release tags up to 10.5.2 and in main. Not fixed upstream: the loop is unchanged on upstream main a95c754ee003, branch_10x and branch_10_6, and no open PR touches PassageFormatter.java (407 open PRs scanned by changed file, 2026-10-26).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-26, read-only: the finder's scripted prior-report search with 43 queries, the intended-behaviour check's run with 39 queries, and a later search, verdict new, 318 queries and reads: 100 scripted prior-report search queries over apache/lucene issues, PRs and commits, downstream repositories, four Lucene lists and the JIRA archive, 64 manual GitHub searches, 10 GitHub code searches, 26 JIRA queries and 16 full reads, 74 list searches over dev@, java-user@, issues@, commits@, general@ and the Solr lists, a changed-file scan of all 407 open apache/lucene PRs, git log -S on the loop, and 5 web searches): no issue, PR, review comment, commit, JIRA entry, list mail or web page says that matchhighlight PassageFormatter closes crossing markers in opening order or emits mis-nested end strings. Nearest items, all design context rather than reports: LUCENE-9439 (apache/lucene#10479), comment 17168613 by the class's author, says the selector and formatter 'clean up potential overlaps and nesting problems (which is useful for HTML, for example)', written with constant '>'/'<' markers; LUCENE-9461 (#10501) lists highlighting several queries 'with different colors' as a requirement; LUCENE-8401 (#9448, open, 2018) discusses interleaved hits before the class existed; LUCENE-8446 (#9492) fixed overlapping-match tags in a different class (the unified highlighter's DefaultPassageFormatter). GitHub code search finds the loop in 17 files, Lucene's own and 16 copies in other repositories, and no fork that reverses it. Not fixed: unchanged on upstream main a95c754ee003, branch_10x and branch_10_6. Limits: GitHub search is token-based and best effort and lists.apache.org covers mail bodies only partly.
Lucene commit tested
main 645d4fe14a20: the minimal test as a standalone JUnit run and through Lucene's gradle build, assertions on and off, and the fix validation (the matchhighlight tests, the whole highlighter module, lint); 10.6.0 release jars, assertions on and off. Earlier, during the hunt and its verification on main a9779ef3c193 (its highlighter module is identical to 645d4fe14a20): 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2, the introducing commit 150a8dacb59 (its three classes compiled over the 9.0.0 jars) and 8.11.4 (no matchhighlight classes).

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageFormatter.java:28-34 (at a9779ef3c193) (class javadoc: formats passages 'cleaning up and resolving restrictions concerning overlaps', 'demarcated with constructor-provided ellipsis and start/end marker sequences'), :42-53 (String constructor delegates to constant lambdas; public Function<OffsetRange,String> markerStart/markerEnd constructor), :137 ('Handle multiple markers, possibly overlapping or nested.'), :152-161 (END: markerStack.lastIndexOf, close loop k = markerIndex .. size-1 in opening order, remove, re-open loop), :175-201 (slice-point order: 'Longer start slice points come first', 'Shorter end slice points come first')
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/Passage.java:21-25 (at a9779ef3c193) (javadoc: 'The markers can be overlapping or nested, but they're always contained within the passage')
  • lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchHighlighter.java:734-742 (at a9779ef3c193) (upstream per-query example: per-query "<span class='...'>" start string, constant '</span>' end string)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageFormatter.java:42-53, (at 645d4fe14a20) 110-135, 137-173, 175-213
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageFormatter.java:42-53; (at 645d4fe14a20) FieldValueHighlighters.java:59, 91-115
  • lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchHighlighter.java:683, (at 645d4fe14a20) 734-742
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageFormatter.java:28-34, (at 645d4fe14a20) 46-53, 128, 137, 152-161, 189-194; Passage.java:21-25; MatchHighlighter.java:38
  • lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestPassageSelector.java:64-72 (at 645d4fe14a20)
History
  • a Lucene clone: git show origin/branch_11x and origin/branch_10_6 of PassageFormatter.java (read-only)

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

Repro files

About the diagrams

Made in parallel with this doc, from the code at 645d4fe14a20 and the minimal repro's scenario.

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.