Skip to main content

oxideav_scribe/
bidi.rs

1//! Unicode Bidirectional Algorithm — UAX #9 character classes,
2//! paragraph-level resolution (rules P1 / P2 / P3), explicit-level
3//! / override / isolate stack (rules X1..X9), weak-type resolution
4//! (rules W1..W7), bracket-pair resolution (rule N0 — full
5//! normative `BidiBrackets.txt` table, Unicode 16.0), neutral-type
6//! resolution (rules N1 and N2), implicit-level resolution (rules
7//! I1 / I2), line-level reordering (rules L1 / L2 / L3), and bidi
8//! mirroring (rule L4 — full `BidiMirroring.txt` table, Unicode
9//! 16.0).
10//!
11//! ## Scope
12//!
13//! This module implements the **paragraph + weak-type phases** of the
14//! Unicode Bidirectional Algorithm (UBA) as specified in Unicode
15//! Standard Annex #9, *Unicode Bidirectional Algorithm*, Revision 50
16//! / Unicode 16.0 (the dated snapshot pinned at
17//! `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html`). The surface
18//! is:
19//!
20//! - [`BidiClass`] — the 23 normative bidirectional character types
21//!   from UAX #9 §3.2 Table 4 (3 Strong, 7 Weak, 4 Neutral, 9
22//!   Explicit Formatting).
23//! - [`bidi_class`] — the full per-code-point `Bidi_Class` lookup,
24//!   data-driven from the Unicode 16.0 `DerivedBidiClass.txt` UCD
25//!   snapshot (vendored in `src/bidi/`, parsed once on first use by
26//!   the private `data` submodule). Covers every assigned code
27//!   point plus the `@missing` defaults for unassigned code points
28//!   in the right-to-left script blocks (`R` / `AL`) and the
29//!   Currency Symbols block (`ET`), with the file's global
30//!   Left_To_Right default for everything else.
31//! - [`paragraph_level`] — the **P1 + P2 + P3** rules: walk the
32//!   text, skip the contents of any isolate (LRI / RLI / FSI ... PDI)
33//!   region, find the first strong character (L / R / AL); P3 sets
34//!   level 1 if it is R or AL, level 0 otherwise (which is also the
35//!   default when no strong character is found).
36//! - [`resolve_explicit_levels`] — the **X1..X9** rules from §3.3.2
37//!   over a whole paragraph. Walks the bidi class slice maintaining
38//!   a directional status stack of (`level`, override-status,
39//!   isolate-flag) frames plus the three overflow / valid counters
40//!   the spec defines; emits per-character embedding levels, an
41//!   override-rewritten effective class slice, and the X9 removal
42//!   flag set ready for X10's isolating-run-sequence partition.
43//!   FSI is resolved per X5c by running a P2 / P3 mini-pass over
44//!   the FSI..matching-PDI span and treating it as an RLI / LRI
45//!   accordingly.
46//! - [`resolve_weak_types`] — the **W1..W7** rules from §3.3.4
47//!   applied to one isolating run sequence in place: NSM type
48//!   inheritance (W1), `EN` after `AL` strong → `AN` (W2), `AL` →
49//!   `R` (W3), single-separator-between-two-numbers collapse (W4),
50//!   `ET`-adjacent-to-`EN` collapse (W5), leftover-separator
51//!   neutralisation (W6), and `EN` after `L` → `L` (W7). The phase
52//!   leaves the slice with no `AL` (collapsed to `R`) and no
53//!   leftover `ES` / `ET` / `CS` (collapsed to `ON`), so the
54//!   N-rules can resolve neutrals against a clean weak-type
55//!   vocabulary.
56//! - [`paired_bracket`] — the BD14 / BD15 paired-bracket lookup,
57//!   data-driven from the normative Unicode 16.0 `BidiBrackets.txt`
58//!   UCD snapshot (64 open/close pairs spanning the ASCII brackets,
59//!   the Tibetan gug rtags / ang khang pairs, the mathematical
60//!   bracket blocks, and the CJK bracket blocks). Returns
61//!   `Some((paired_char, BracketKind))` for every Bidi_Paired_Bracket
62//!   entry, `None` otherwise.
63//! - [`bracket_pairs`] — the **BD16** (§3.1.3) paired-bracket walk
64//!   over one isolating run sequence. Maintains the spec-mandated
65//!   63-element stack (overflow → empty list, per the BD16
66//!   "return an empty list" branch), pairs nested brackets by
67//!   popping through the matching opener inclusively, and finally
68//!   sorts the result by opener position in ascending logical
69//!   order — the §3.3.5 N0 sequencing invariant.
70//! - [`resolve_bracket_pairs`] — the **N0** rule from §3.3.5 applied
71//!   to one isolating run sequence post-W7 and pre-N1. For each
72//!   pair, inspects the bracket interior for a strong type (EN /
73//!   AN projected to R), then dispatches the N0 a / b / c.1 / c.2
74//!   / d cases in place: matching-embedding-inside → both brackets
75//!   to embedding direction (N0 b); opposite-inside + preceding-
76//!   strong-also-opposite → both brackets to that direction (N0
77//!   c.1); opposite-inside + preceding-strong-matches-embedding →
78//!   both brackets to embedding direction (N0 c.2); no inside-
79//!   strong → leave the pair untouched (N0 d). The trailing-NSM
80//!   clarification ("any NSM following a paired bracket which
81//!   changed under N0 should change to match the bracket") is
82//!   honoured for the contiguous NSM run after each rewritten
83//!   bracket. Pairs are processed sequentially in opener-
84//!   ascending order so inner pairs see the rewrites of every
85//!   outer pair already processed.
86//! - [`resolve_neutral_types`] — the **N1 + N2** rules from §3.3.5
87//!   applied to one isolating run sequence already passed through
88//!   `resolve_weak_types` (and `resolve_bracket_pairs`, if N0 is
89//!   wired in). N1 walks every maximal run of Neutral-or-
90//!   Isolate-formatting (NI) elements (`B` / `S` / `WS` / `ON` /
91//!   `LRI` / `RLI` / `FSI` / `PDI`) and, when the strong type on
92//!   both sides (counting `EN` / `AN` as `R`, and `sos` / `eos` at
93//!   the sequence boundaries) is the same, flips every NI in the
94//!   run to that strong type (`L` or `R`). N2 fills the remaining
95//!   NIs with the **embedding direction** derived from the caller-
96//!   provided embedding level (even → `L`, odd → `R`). After the
97//!   call the slice contains no NI: every former neutral or isolate
98//!   formatting character has been resolved to a strong direction,
99//!   ready for the §3.3.6 implicit-level pass (I1 / I2).
100//! - [`resolve_implicit_levels`] — the **I1 + I2** rules from §3.3.6
101//!   applied to one isolating run sequence already passed through
102//!   `resolve_neutral_types`. I1 bumps `R` by +1 and `EN` / `AN` by
103//!   +2 above an even embedding level; I2 bumps `L` / `EN` / `AN`
104//!   by +1 above an odd embedding level. The two together implement
105//!   UAX #9 Table 5 verbatim. `BN` is ignored per §5.2 ("In rules
106//!   I1 and I2, ignore BN.") — its level stays at the embedding
107//!   level so a follow-up L-rule pass can fold it. The function
108//!   returns a `Vec<u8>` of per-character resolved levels, ready
109//!   for the L-rule reordering pass.
110//! - [`reset_trailing_levels`] — the **L1** rule from §3.4. Walks
111//!   the line and, in place, resets the embedding level of every
112//!   segment separator (`S`), every paragraph separator (`B`), and
113//!   every maximal trailing run of whitespace (`WS`) + isolate
114//!   formatting (`LRI` / `RLI` / `FSI` / `PDI`) immediately
115//!   preceding such a separator or at the end of the line, back to
116//!   the paragraph embedding level. Per UAX #9 the lookup uses the
117//!   **original** bidi classes of the line — the caller passes the
118//!   input class slice alongside the post-I-rules level vector.
119//! - [`reorder_line`] — the **L2** rule from §3.4. Returns a
120//!   permutation of `0..n` mapping visual position to logical
121//!   index, computed by the progressive-reversal algorithm: from
122//!   the maximum level down to the smallest odd level, reverse
123//!   every maximal contiguous run of characters whose level is at
124//!   least the iteration level. The output drives the
125//!   logical-to-visual remap a renderer applies before rasterising
126//!   the glyph sequence.
127//! - [`mirrored_glyph`] — the `Bidi_Mirroring_Glyph` acceptable-
128//!   mirror-pair lookup, data-driven from the Unicode 16.0
129//!   `BidiMirroring.txt` UCD snapshot (428 entries — paired
130//!   brackets, angle quotation marks, mathematical relations and
131//!   operators, CJK brackets, …). An involution returning `None`
132//!   for every character without a mirror pair (including the §3.4
133//!   backward-compatibility exclusions U+FD3E / U+FD3F ornate
134//!   parentheses).
135//! - [`apply_mirroring`] — the **L4** rule from §3.4 applied in
136//!   place to a line's logical character sequence: every position
137//!   whose resolved level is odd (resolved directionality R, per
138//!   the §3.2 even-LTR / odd-RTL level convention) and whose
139//!   character has a mirror pair is replaced by the mirrored
140//!   character, realising the spec's "depicted by a mirrored
141//!   glyph" requirement through the §7 acceptable-mirror-pair
142//!   substitution.
143//!
144//! ## Out of scope (deferred to follow-up rounds)
145//!
146//! - X10 isolating-run-sequence partition + sos/eos derivation are
147//!   now implemented in [`level_runs`] (BD7) and
148//!   [`isolating_run_sequences`] (BD13 + X10 step 2). Callers feed
149//!   the result of [`resolve_explicit_levels`] in; each returned
150//!   [`IsolatingRunSequence`] carries the constituent level-run
151//!   ranges, the per-sequence embedding level, and the sos / eos
152//!   directional types W1..W7 / N0..N2 / I1..I2 consume at the
153//!   sequence boundaries.
154//! - L3 (combining-mark reordering for RTL bases) landed in round
155//!   247 as [`reorder_combining_marks`], an in-place permutation
156//!   adjuster that reverses each post-L2 `[NSM, …, NSM, base]`
157//!   block back to `[base, NSM, …, NSM]` for callers running a
158//!   non-scribe mark-attachment policy (the spec's "expects them
159//!   to follow" alternative). Scribe's own GPOS mark-to-base +
160//!   mark-to-mark stacker keeps logical (post-base) order in both
161//!   directions, so callers using scribe's renderer can skip the
162//!   L3 step; the entry point is for external callers wiring a
163//!   different mark-attachment policy (e.g. mark glyphs with
164//!   rightward overhangs).
165//! - HL1..HL6 higher-level-protocol overrides (§4.3) — callers'
166//!   responsibility throughout.
167//!
168//! ## Provenance
169//!
170//! All material in this module is sourced exclusively from
171//! `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` (UAX #9
172//! Revision 50, Unicode 16.0, fetched 2026-05-29) and the three
173//! Unicode 16.0 UCD data files staged alongside it
174//! (`DerivedBidiClass.txt`, `BidiBrackets.txt`, `BidiMirroring.txt` —
175//! vendored verbatim in `src/bidi/`, round 283).
176
177#![allow(clippy::module_name_repetitions)]
178
179mod data;
180
181/// Normative bidirectional character type from UAX #9 §3.2 Table 4.
182///
183/// The categories follow the spec grouping:
184///
185/// - **Strong** ([`L`](Self::L) / [`R`](Self::R) / [`AL`](Self::AL)).
186/// - **Weak**
187///   ([`EN`](Self::EN) / [`ES`](Self::ES) / [`ET`](Self::ET) /
188///   [`AN`](Self::AN) / [`CS`](Self::CS) / [`NSM`](Self::NSM) /
189///   [`BN`](Self::BN)).
190/// - **Neutral**
191///   ([`B`](Self::B) / [`S`](Self::S) / [`WS`](Self::WS) /
192///   [`ON`](Self::ON)).
193/// - **Explicit Formatting**
194///   ([`LRE`](Self::LRE) / [`LRO`](Self::LRO) / [`RLE`](Self::RLE) /
195///   [`RLO`](Self::RLO) / [`PDF`](Self::PDF) / [`LRI`](Self::LRI) /
196///   [`RLI`](Self::RLI) / [`FSI`](Self::FSI) / [`PDI`](Self::PDI)).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198pub enum BidiClass {
199    // Strong (UAX #9 §3.2 Table 4).
200    /// `L` — Left-to-Right. LRM, most alphabetic / syllabic / Han
201    /// ideographs, non-European / non-Arabic digits.
202    L,
203    /// `R` — Right-to-Left. RLM, Hebrew alphabet and related
204    /// punctuation.
205    R,
206    /// `AL` — Right-to-Left Arabic. ALM, Arabic / Thaana / Syriac
207    /// alphabets and most punctuation specific to those scripts.
208    AL,
209
210    // Weak.
211    /// `EN` — European Number. European digits + Eastern
212    /// Arabic-Indic digits.
213    EN,
214    /// `ES` — European Number Separator. PLUS SIGN, MINUS SIGN.
215    ES,
216    /// `ET` — European Number Terminator. DEGREE SIGN, currency
217    /// symbols, etc.
218    ET,
219    /// `AN` — Arabic Number. Arabic-Indic digits + Arabic decimal
220    /// and thousands separators.
221    AN,
222    /// `CS` — Common Number Separator. COLON, COMMA, FULL STOP,
223    /// NO-BREAK SPACE, etc.
224    CS,
225    /// `NSM` — Nonspacing Mark. Characters with `General_Category`
226    /// `Mn` (Nonspacing_Mark) or `Me` (Enclosing_Mark).
227    NSM,
228    /// `BN` — Boundary Neutral. Default ignorables, non-characters,
229    /// and control characters other than those explicitly given
230    /// other types.
231    BN,
232
233    // Neutral.
234    /// `B` — Paragraph Separator.
235    B,
236    /// `S` — Segment Separator (Tab).
237    S,
238    /// `WS` — Whitespace.
239    WS,
240    /// `ON` — Other Neutrals. All other characters, including
241    /// `OBJECT REPLACEMENT CHARACTER` (U+FFFC).
242    ON,
243
244    // Explicit Formatting (UAX #9 §2.1–§2.5).
245    /// `LRE` — Left-to-Right Embedding (U+202A).
246    LRE,
247    /// `LRO` — Left-to-Right Override (U+202D).
248    LRO,
249    /// `RLE` — Right-to-Left Embedding (U+202B).
250    RLE,
251    /// `RLO` — Right-to-Left Override (U+202E).
252    RLO,
253    /// `PDF` — Pop Directional Format (U+202C).
254    PDF,
255    /// `LRI` — Left-to-Right Isolate (U+2066).
256    LRI,
257    /// `RLI` — Right-to-Left Isolate (U+2067).
258    RLI,
259    /// `FSI` — First Strong Isolate (U+2068).
260    FSI,
261    /// `PDI` — Pop Directional Isolate (U+2069).
262    PDI,
263}
264
265impl BidiClass {
266    /// `true` if this class is strong (`L` / `R` / `AL`).
267    ///
268    /// Used by P2 (paragraph-level resolution) and by L1 (line
269    /// reordering).
270    #[must_use]
271    pub const fn is_strong(self) -> bool {
272        matches!(self, Self::L | Self::R | Self::AL)
273    }
274
275    /// `true` if this class is one of the four isolate initiators
276    /// (`LRI` / `RLI` / `FSI`) or the matching pop (`PDI`).
277    ///
278    /// Used by P2 (which skips over isolate-bracketed regions) and
279    /// by the X-rules (which maintain a stack of isolate scopes).
280    #[must_use]
281    pub const fn is_isolate_initiator(self) -> bool {
282        matches!(self, Self::LRI | Self::RLI | Self::FSI)
283    }
284
285    /// `true` if this class counts as a **Neutral or Isolate (NI)**
286    /// in UAX #9 §3.3.5 / §3.3.6 terminology.
287    ///
288    /// The NI alias names the union `B | S | WS | ON | FSI | LRI | RLI
289    /// | PDI` — every neutral type plus the four isolate-formatting
290    /// characters, which are *treated as if neutral* once W1..W7 have
291    /// resolved their surroundings. W7 uses the NI alias in its
292    /// "search backward through NIs" wording; the N-rules (N0..N2)
293    /// resolve NIs en masse in the next phase.
294    #[must_use]
295    pub const fn is_neutral_or_isolate(self) -> bool {
296        matches!(
297            self,
298            Self::B | Self::S | Self::WS | Self::ON | Self::FSI | Self::LRI | Self::RLI | Self::PDI
299        )
300    }
301}
302
303/// Return the [`BidiClass`] of the code point per UAX #9 §3.2.
304///
305/// Data-driven from the Unicode 16.0 `DerivedBidiClass.txt` UCD
306/// snapshot (UAX #9 §3.2: "For assignments to character types, see
307/// DerivedBidiClass.txt \[DerivedBIDI\] in the \[UCD\]"), vendored in
308/// `src/bidi/` and parsed once on first use. Every assigned code
309/// point gets its listed class; unassigned code points get the
310/// file's `@missing` block defaults — `R` / `AL` in the blocks
311/// reserved for right-to-left scripts, `ET` in the Currency Symbols
312/// block (per §3.2: "Unassigned characters are given strong types
313/// in the algorithm. This is an explicit exception to the general
314/// Unicode conformance requirements with respect to unassigned
315/// characters.") — and everything else falls back to the file's
316/// global Left_To_Right default.
317#[must_use]
318pub fn bidi_class(c: char) -> BidiClass {
319    data::class_lookup(c as u32)
320}
321
322/// Resolve the paragraph embedding level per UAX #9 rules **P1, P2,
323/// P3**.
324///
325/// - **P1** Split the text into paragraphs at any character of class
326///   `B`. This function operates on **a single paragraph**: callers
327///   are expected to split at `B` first (see [`split_paragraphs`]).
328/// - **P2** Find the first character of type `L` / `AL` / `R`,
329///   *skipping over any character between an isolate initiator
330///   (`LRI` / `RLI` / `FSI`) and its matching `PDI`*.
331/// - **P3** If the strong character found by P2 is of type `AL` or
332///   `R`, set the paragraph embedding level to `1`; otherwise `0`.
333///   The default when P2 finds no strong character is also `0`.
334///
335/// The result is the **paragraph embedding level** — `0` (LTR) or
336/// `1` (RTL) — that the rest of the algorithm uses as the starting
337/// stack frame for X1.
338///
339/// Higher-level protocols (UAX #9 §4.3 HL1) may override this
340/// result; that is the caller's responsibility, not this function's.
341#[must_use]
342pub fn paragraph_level(text: &str) -> u8 {
343    // P2: track isolate depth to skip over LRI / RLI / FSI ... PDI
344    // regions. Isolates can be nested arbitrarily; we count
345    // initiators and decrement on PDI down to but not below zero
346    // (an unmatched PDI is ignored for the purpose of P2, which is
347    // what the spec achieves by "skip until matching PDI or end of
348    // paragraph").
349    let mut isolate_depth: u32 = 0;
350    for c in text.chars() {
351        let class = bidi_class(c);
352        if isolate_depth > 0 {
353            // Inside an isolate region: only adjust the counter on
354            // nested initiators / matching pops.
355            match class {
356                BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => {
357                    isolate_depth = isolate_depth.saturating_add(1);
358                }
359                BidiClass::PDI => {
360                    isolate_depth -= 1;
361                }
362                _ => {}
363            }
364            continue;
365        }
366        match class {
367            BidiClass::L => return 0,
368            BidiClass::R | BidiClass::AL => return 1,
369            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => {
370                isolate_depth = 1;
371            }
372            // PDI at top level with no matching initiator is treated
373            // as a neutral by P2 (it is ignored along with all
374            // other non-strong types).
375            _ => {}
376        }
377    }
378    // P3: default to 0 (LTR) when no strong character was found.
379    0
380}
381
382// =============================================================================
383// X-rules — explicit embedding / override / isolate stack (§3.3.2)
384// =============================================================================
385
386/// Maximum explicit embedding depth per UAX #9 §3.1.2 BD2.
387///
388/// BD2 fixes `max_depth = 125` and the spec explicitly guarantees the
389/// value will not change: "this specification now guarantees that the
390/// value of 125 for max_depth will not be increased (or decreased) in
391/// future versions. Thus, it is safe for implementations to treat the
392/// max_depth value as a constant." (UAX #9 Rev. 50 §3.1.2.) Embedding
393/// initiators that would push past this depth are *overflow* events
394/// (counted but otherwise ignored) per X2 / X3 / X4 / X5 / X5a / X5b.
395pub const MAX_DEPTH: u8 = 125;
396
397/// Output of the X1..X9 explicit-level pass.
398///
399/// `levels[i]` is the embedding level assigned to the `i`th input
400/// character by X1..X8, `effective_classes[i]` is the (possibly
401/// override-rewritten) bidi class the implicit phases consume, and
402/// `removed[i]` reflects rule **X9** ("Remove all RLE, LRE, RLO, LRO,
403/// PDF, and BN characters"). Index positions are preserved across all
404/// three slices so callers can map back to the original logical
405/// offsets if needed.
406///
407/// Per X9 the removed positions still carry a level — the spec note
408/// allows implementations to leave the characters in place "as long as
409/// all other characters are ordered correctly", so callers walking
410/// `removed[i] == false` get the X9-filtered logical sequence in one
411/// pass without re-shuffling indices.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct ExplicitLevels {
414    /// Per-character embedding level assigned by X1..X8. The first
415    /// entry of the directional status stack (the paragraph level)
416    /// is what gets assigned to every B per X8.
417    pub levels: Vec<u8>,
418    /// Per-character bidi class after X4 / X5 / X5a / X5b / X6 /
419    /// X6a override rewriting. For positions whose enclosing scope
420    /// has neutral override status the class is unchanged; under an
421    /// `L` override every non-formatting character is rewritten to
422    /// `L`; under an `R` override to `R` (per X6 and the X5a / X5b /
423    /// X6a override-on-isolate-format clauses).
424    pub effective_classes: Vec<BidiClass>,
425    /// `removed[i]` is `true` iff the `i`th character is one of the
426    /// types removed by X9 (`RLE` / `LRE` / `RLO` / `LRO` / `PDF` /
427    /// `BN`). The isolate-formatting characters (`LRI` / `RLI` /
428    /// `FSI` / `PDI`) are **not** removed per the X9 note "FSI, LRI,
429    /// RLI, and PDI characters are not removed."
430    pub removed: Vec<bool>,
431}
432
433/// Directional override status carried by each stack entry per
434/// UAX #9 §3.1.2 BD6 / Table 2 (`Neutral` / `Right-to-left` /
435/// `Left-to-right`).
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437enum OverrideStatus {
438    Neutral,
439    Ltr,
440    Rtl,
441}
442
443/// One frame of the directional status stack per UAX #9 §3.3.2.
444///
445/// Each frame carries an embedding level, an override status, and an
446/// isolate flag. The starting frame (X1) carries the paragraph
447/// embedding level, neutral override, and isolate=`false`; this
448/// frame is never popped until the end of the paragraph (X8).
449#[derive(Debug, Clone, Copy)]
450struct StackFrame {
451    level: u8,
452    overrride: OverrideStatus,
453    isolate: bool,
454}
455
456/// Resolve **explicit embedding levels and override types** for a
457/// whole paragraph per UAX #9 **X1..X9** (§3.3.2).
458///
459/// `classes` is the per-character [`BidiClass`] slice for the
460/// paragraph in logical order; `paragraph_level` is the value
461/// returned by [`paragraph_level`] (or set by a higher-level
462/// protocol per HL1). The returned [`ExplicitLevels`] carries:
463///
464/// - `levels[i]` — the embedding level assigned by X1..X8 to the
465///   `i`th input character. For non-formatting characters this is
466///   the level of the last entry on the directional status stack at
467///   the time the character is processed (X6). For embedding /
468///   override initiators (`RLE` / `LRE` / `RLO` / `LRO`) the level
469///   is the *new* scope level (the stack top *after* the push, or
470///   the enclosing scope when the push overflowed). For PDF the
471///   level is the post-pop stack top (the enclosing scope's level
472///   after the matched embedding has been popped). For an isolate
473///   initiator (`LRI` / `RLI` / `FSI`) the level is the *enclosing*
474///   scope's level (per the X5a / X5b spec text "Set the LRI / RLI's
475///   embedding level to the embedding level of the last entry on the
476///   directional status stack."), and the matching PDI gets the
477///   same level (X6a note: "the level assigned to an isolate
478///   initiator is always the same as that assigned to the matching
479///   PDI"). For B characters the paragraph embedding level (X8).
480///   Since RLE / LRE / RLO / LRO / PDF are X9-removed, the precise
481///   level reported for them is not consumed by the implicit phases.
482/// - `effective_classes[i]` — the bidi class after override
483///   rewriting per X4 / X5 / X5a / X5b / X6 / X6a. Override status
484///   `Ltr` rewrites the class to `L`; `Rtl` rewrites it to `R`;
485///   `Neutral` leaves it alone.
486/// - `removed[i]` — true for X9-removed types (`RLE` / `LRE` /
487///   `RLO` / `LRO` / `PDF` / `BN`). Isolate-formatting characters
488///   (`LRI` / `RLI` / `FSI` / `PDI`) are *not* removed per the X9
489///   note.
490///
491/// FSI is resolved per X5c by running a P2 / P3 mini-pass over the
492/// FSI..matching-PDI span (or to end-of-paragraph if no matching
493/// PDI), and the FSI is then treated as an RLI (paragraph level 1)
494/// or LRI (paragraph level 0) accordingly.
495///
496/// Overflow events (depth ≥ `MAX_DEPTH`) are counted per the
497/// "overflow isolate count" / "overflow embedding count" rules in
498/// X2..X6a / X7. An overflow initiator's character receives the
499/// level that was on top of the stack at the time of the initiator
500/// (i.e. the level of the enclosing scope); an overflow PDF /
501/// matching PDI decrements its respective overflow counter.
502///
503/// The X10 isolating-run-sequence partition is **not** computed by
504/// this function — callers wanting to feed `resolve_weak_types` /
505/// `resolve_neutral_types` / `resolve_implicit_levels` per-sequence
506/// should run X10 as a separate pass over the returned levels +
507/// effective_classes. The X-rule output here is the stable
508/// per-character level vector X10 + the implicit phases consume.
509///
510/// Provenance: rules transcribed verbatim from
511/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.3.2 (UAX
512/// #9 Revision 50, Unicode 16.0).
513///
514/// # Examples
515///
516/// ```
517/// use oxideav_scribe::bidi::{
518///     bidi_class, paragraph_level, resolve_explicit_levels, BidiClass,
519/// };
520///
521/// // "Hello" — all L at level 0, no formatting characters, X9
522/// // removes nothing.
523/// let cls: Vec<BidiClass> = "Hello".chars().map(bidi_class).collect();
524/// let pl = paragraph_level("Hello");
525/// let out = resolve_explicit_levels(&cls, pl);
526/// assert_eq!(out.levels, vec![0; 5]);
527/// assert!(out.removed.iter().all(|r| !r));
528///
529/// // RLE A PDF — A is at level 1 (the RLE pushed an odd level);
530/// // both RLE and PDF are X9-removed. The embedding initiator's
531/// // own level is reported as the new-scope level; PDF reports
532/// // the post-pop stack-top (the enclosing scope's level). Since
533/// // both are X9-removed their reported level is not consumed by
534/// // the implicit phases.
535/// let cls = vec![BidiClass::RLE, BidiClass::L, BidiClass::PDF];
536/// let out = resolve_explicit_levels(&cls, 0);
537/// assert_eq!(out.levels, vec![1, 1, 0]);
538/// assert_eq!(out.removed, vec![true, false, true]);
539/// ```
540#[must_use]
541pub fn resolve_explicit_levels(classes: &[BidiClass], paragraph_level: u8) -> ExplicitLevels {
542    let n = classes.len();
543    let mut levels = vec![paragraph_level; n];
544    let mut effective: Vec<BidiClass> = classes.to_vec();
545    let mut removed = vec![false; n];
546
547    // X1: initialise directional status stack with one entry holding
548    // the paragraph embedding level, neutral override, and false
549    // isolate status. Initialise the three overflow / valid
550    // counters to zero.
551    let mut stack: Vec<StackFrame> = Vec::with_capacity(8);
552    stack.push(StackFrame {
553        level: paragraph_level,
554        overrride: OverrideStatus::Neutral,
555        isolate: false,
556    });
557    let mut overflow_isolate: u32 = 0;
558    let mut overflow_embedding: u32 = 0;
559    let mut valid_isolate: u32 = 0;
560
561    for i in 0..n {
562        let cls = classes[i];
563
564        match cls {
565            // --- X2: RLE → least odd level above stack top ---------
566            BidiClass::RLE => {
567                apply_embedding(
568                    &mut stack,
569                    &mut overflow_isolate,
570                    &mut overflow_embedding,
571                    &mut levels,
572                    i,
573                    /* odd */ true,
574                    OverrideStatus::Neutral,
575                );
576            }
577            // --- X3: LRE → least even level above stack top --------
578            BidiClass::LRE => {
579                apply_embedding(
580                    &mut stack,
581                    &mut overflow_isolate,
582                    &mut overflow_embedding,
583                    &mut levels,
584                    i,
585                    /* odd */ false,
586                    OverrideStatus::Neutral,
587                );
588            }
589            // --- X4: RLO → least odd, Rtl override -----------------
590            BidiClass::RLO => {
591                apply_embedding(
592                    &mut stack,
593                    &mut overflow_isolate,
594                    &mut overflow_embedding,
595                    &mut levels,
596                    i,
597                    /* odd */ true,
598                    OverrideStatus::Rtl,
599                );
600            }
601            // --- X5: LRO → least even, Ltr override ----------------
602            BidiClass::LRO => {
603                apply_embedding(
604                    &mut stack,
605                    &mut overflow_isolate,
606                    &mut overflow_embedding,
607                    &mut levels,
608                    i,
609                    /* odd */ false,
610                    OverrideStatus::Ltr,
611                );
612            }
613            // --- X5a: RLI ------------------------------------------
614            BidiClass::RLI => {
615                apply_isolate(
616                    &mut stack,
617                    &mut overflow_isolate,
618                    &mut overflow_embedding,
619                    &mut valid_isolate,
620                    &mut levels,
621                    &mut effective,
622                    i,
623                    /* odd */ true,
624                );
625            }
626            // --- X5b: LRI ------------------------------------------
627            BidiClass::LRI => {
628                apply_isolate(
629                    &mut stack,
630                    &mut overflow_isolate,
631                    &mut overflow_embedding,
632                    &mut valid_isolate,
633                    &mut levels,
634                    &mut effective,
635                    i,
636                    /* odd */ false,
637                );
638            }
639            // --- X5c: FSI ------------------------------------------
640            //
641            // P2 + P3 applied to the FSI..matching-PDI span (or to
642            // end-of-paragraph). If level 1 → treat as RLI per X5a;
643            // otherwise as LRI per X5b.
644            BidiClass::FSI => {
645                let span_level = fsi_inner_level(classes, i + 1);
646                apply_isolate(
647                    &mut stack,
648                    &mut overflow_isolate,
649                    &mut overflow_embedding,
650                    &mut valid_isolate,
651                    &mut levels,
652                    &mut effective,
653                    i,
654                    span_level == 1,
655                );
656            }
657            // --- X6a: PDI ------------------------------------------
658            BidiClass::PDI => {
659                if overflow_isolate > 0 {
660                    overflow_isolate -= 1;
661                } else if valid_isolate > 0 {
662                    // Terminate the matched isolate scope: reset
663                    // overflow_embedding to zero, pop embedding
664                    // entries above it, then pop the isolate frame
665                    // itself. Decrement valid_isolate.
666                    overflow_embedding = 0;
667                    while let Some(top) = stack.last() {
668                        if top.isolate {
669                            break;
670                        }
671                        stack.pop();
672                    }
673                    // Per the spec note this stack pop is guaranteed
674                    // safe — there is at least one isolate frame
675                    // above the paragraph frame.
676                    stack.pop();
677                    valid_isolate -= 1;
678                }
679                // In all cases assign PDI's level + override-rewrite
680                // its type from the (post-pop) stack top.
681                let top = stack.last().expect("stack invariant: paragraph frame");
682                levels[i] = top.level;
683                effective[i] = match top.overrride {
684                    OverrideStatus::Ltr => BidiClass::L,
685                    OverrideStatus::Rtl => BidiClass::R,
686                    OverrideStatus::Neutral => BidiClass::PDI,
687                };
688            }
689            // --- X7: PDF -------------------------------------------
690            BidiClass::PDF => {
691                if overflow_isolate > 0 {
692                    // PDF inside an overflow isolate is fully ignored.
693                } else if overflow_embedding > 0 {
694                    overflow_embedding -= 1;
695                } else if stack.len() >= 2 && !stack.last().unwrap().isolate {
696                    stack.pop();
697                }
698                // PDF's own level is the level on top of the stack
699                // at processing time (the enclosing scope's level —
700                // we used `stack.last()` *before* the pop above for
701                // the embedding-pop case, but since X7 spec says PDF
702                // is removed by X9 anyway, callers should not rely
703                // on this value).
704                levels[i] = stack.last().unwrap().level;
705            }
706            // --- X6: every other non-formatting type ---------------
707            //
708            // "For all types besides B, BN, RLE, LRE, RLO, LRO, PDF,
709            // RLI, LRI, FSI, and PDI" — those are exhaustively
710            // handled above. The remainder lands here.
711            BidiClass::B => {
712                // X8 / X1 — B characters get the paragraph level.
713                levels[i] = paragraph_level;
714            }
715            BidiClass::BN => {
716                // BN keeps the enclosing scope's level for the X10
717                // run-partition + the sos/eos boundary lookups, but
718                // X9 removes it from the implicit phases.
719                let top = stack.last().expect("stack invariant: paragraph frame");
720                levels[i] = top.level;
721            }
722            _ => {
723                // X6 proper: every other type.
724                let top = stack.last().expect("stack invariant: paragraph frame");
725                levels[i] = top.level;
726                effective[i] = match top.overrride {
727                    OverrideStatus::Ltr => BidiClass::L,
728                    OverrideStatus::Rtl => BidiClass::R,
729                    OverrideStatus::Neutral => effective[i],
730                };
731            }
732        }
733
734        // X9: mark embeddings / overrides / PDF / BN as removed (the
735        // implicit phases skip them). Isolate-formatting characters
736        // (LRI / RLI / FSI / PDI) are NOT removed per the X9 note.
737        if matches!(
738            cls,
739            BidiClass::RLE
740                | BidiClass::LRE
741                | BidiClass::RLO
742                | BidiClass::LRO
743                | BidiClass::PDF
744                | BidiClass::BN
745        ) {
746            removed[i] = true;
747        }
748    }
749
750    ExplicitLevels {
751        levels,
752        effective_classes: effective,
753        removed,
754    }
755}
756
757/// Apply X2 (RLE) / X3 (LRE) / X4 (RLO) / X5 (LRO) — the explicit
758/// embedding / override push.
759///
760/// `target_odd` selects RLE / RLO behaviour (least odd above the
761/// stack top) vs LRE / LRO (least even); `override_status` selects
762/// the override status for the new frame (`Neutral` for embeddings,
763/// `Ltr` for LRO, `Rtl` for RLO). Writes the initiator's own level
764/// as the new scope's level (the stack top *after* the push, or the
765/// enclosing scope when the push overflowed).
766fn apply_embedding(
767    stack: &mut Vec<StackFrame>,
768    overflow_isolate: &mut u32,
769    overflow_embedding: &mut u32,
770    levels: &mut [u8],
771    i: usize,
772    target_odd: bool,
773    override_status: OverrideStatus,
774) {
775    let enclosing_level = stack
776        .last()
777        .expect("stack invariant: paragraph frame")
778        .level;
779    let new_level = if target_odd {
780        least_greater_odd(enclosing_level)
781    } else {
782        least_greater_even(enclosing_level)
783    };
784    if new_level <= MAX_DEPTH && *overflow_isolate == 0 && *overflow_embedding == 0 {
785        stack.push(StackFrame {
786            level: new_level,
787            overrride: override_status,
788            isolate: false,
789        });
790    } else if *overflow_isolate == 0 {
791        *overflow_embedding = overflow_embedding.saturating_add(1);
792    }
793    // The initiator's own level reflects the new scope (the stack
794    // top after the push). For overflow events the stack top stays
795    // at the enclosing scope, which is the convention the spec's
796    // X9-removal note implicitly endorses ("an implementation does
797    // not have to actually remove the characters; it just has to
798    // behave as though the characters were not present").
799    levels[i] = stack
800        .last()
801        .expect("stack invariant: paragraph frame")
802        .level;
803}
804
805/// Apply X5a (RLI) / X5b (LRI) / X5c (FSI-resolved-as-RLI-or-LRI).
806///
807/// `target_odd = true` for RLI / FSI-resolved-as-RLI; `false` for
808/// LRI / FSI-resolved-as-LRI. Mutates the stack / overflow counters
809/// and writes the isolate-initiator's own level + override-rewritten
810/// class.
811#[allow(clippy::too_many_arguments)]
812fn apply_isolate(
813    stack: &mut Vec<StackFrame>,
814    overflow_isolate: &mut u32,
815    overflow_embedding: &mut u32,
816    valid_isolate: &mut u32,
817    levels: &mut [u8],
818    effective: &mut [BidiClass],
819    i: usize,
820    target_odd: bool,
821) {
822    // Per X5a / X5b: the isolate initiator's level is the level of
823    // the last entry on the directional status stack (i.e. the
824    // enclosing scope) — assigned *before* any push happens. The
825    // override-status rewrite for the isolate initiator itself also
826    // reads from the enclosing scope's override status.
827    let enclosing = *stack.last().expect("stack invariant: paragraph frame");
828    levels[i] = enclosing.level;
829    effective[i] = match enclosing.overrride {
830        OverrideStatus::Ltr => BidiClass::L,
831        OverrideStatus::Rtl => BidiClass::R,
832        OverrideStatus::Neutral => effective[i],
833    };
834
835    let new_level = if target_odd {
836        least_greater_odd(enclosing.level)
837    } else {
838        least_greater_even(enclosing.level)
839    };
840    if new_level <= MAX_DEPTH && *overflow_isolate == 0 && *overflow_embedding == 0 {
841        *valid_isolate = valid_isolate.saturating_add(1);
842        stack.push(StackFrame {
843            level: new_level,
844            overrride: OverrideStatus::Neutral,
845            isolate: true,
846        });
847    } else {
848        *overflow_isolate = overflow_isolate.saturating_add(1);
849    }
850}
851
852/// Least odd level strictly greater than `level` per X2 / X4 /
853/// X5a's "least odd embedding level greater than the embedding
854/// level of the last entry on the directional status stack."
855const fn least_greater_odd(level: u8) -> u8 {
856    // Even → +1 (next odd); odd → +2 (next odd). Saturates at
857    // u8::MAX, but the validity check vs MAX_DEPTH happens outside.
858    if level & 1 == 0 {
859        level.saturating_add(1)
860    } else {
861        level.saturating_add(2)
862    }
863}
864
865/// Least even level strictly greater than `level` per X3 / X5 /
866/// X5b's "least even embedding level greater than the embedding
867/// level of the last entry on the directional status stack."
868const fn least_greater_even(level: u8) -> u8 {
869    if level & 1 == 0 {
870        level.saturating_add(2)
871    } else {
872        level.saturating_add(1)
873    }
874}
875
876/// FSI resolution per X5c: apply P2 / P3 to the span between the
877/// FSI at index `start - 1` (caller passes `start = fsi_index + 1`)
878/// and its matching PDI (or end of paragraph). Returns `1` if the
879/// resolved paragraph level is RTL, `0` otherwise.
880///
881/// The P2 search itself skips over inner isolate regions per the
882/// same rule as [`paragraph_level`].
883fn fsi_inner_level(classes: &[BidiClass], start: usize) -> u8 {
884    let mut depth: u32 = 0;
885    for cls in classes.iter().skip(start) {
886        match *cls {
887            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => {
888                depth = depth.saturating_add(1);
889            }
890            BidiClass::PDI => {
891                if depth == 0 {
892                    // Matched the outer FSI — stop, no strong type
893                    // found inside.
894                    return 0;
895                }
896                depth -= 1;
897            }
898            _ if depth > 0 => {} // inside a nested isolate: skip
899            BidiClass::L => return 0,
900            BidiClass::R | BidiClass::AL => return 1,
901            _ => {}
902        }
903    }
904    0
905}
906
907/// Resolve **weak types** for one isolating run sequence per UAX #9
908/// **W1, W2, W3, W4, W5, W6, W7** (§3.3.4).
909///
910/// The input `classes` are the per-character [`BidiClass`] values for
911/// **one isolating run sequence** in logical order. `sos` is the
912/// **start-of-sequence** strong type (`L` or `R`) — for callers that
913/// have not yet wired X1..X10 / X10's run partition, passing
914/// `L` (paragraph level 0) or `R` (paragraph level 1) is correct for
915/// a single-paragraph, no-isolate input. `eos` is the **end-of-
916/// sequence** strong type, also `L` or `R`. Only W2 + W7 read `sos`
917/// (W7 needs only `L` / `R` / `sos`); none of the rules read `eos`
918/// directly in this single-pass implementation (W4 reads the
919/// *following* character, but only when that character is *inside*
920/// the sequence — at the trailing edge the "single-separator-between-
921/// two-EN" pattern cannot apply because there is no following EN).
922///
923/// The function mutates `classes` in place. After return every
924/// element is one of `L`, `R`, `EN`, `AN`, `NSM`, `ES`, `ET`, `CS`,
925/// `BN`, or one of the neutral / isolate-formatting types
926/// (`B` / `S` / `WS` / `ON` / `LRI` / `RLI` / `FSI` / `PDI`) — `AL`
927/// is gone (W3 collapses every remaining AL to R) and every
928/// separator / terminator that survived W4 / W5 is collapsed by W6
929/// to `ON`. The N-rules pick up from there.
930///
931/// The implementation is the literal four-pass shape from the spec:
932///
933/// 1. **W1** — NSMs take the type of the previous character (or
934///    `ON` if the previous is `LRI` / `RLI` / `FSI` / `PDI`, per the
935///    spec note about "isolate initiator or PDI"). An NSM at the
936///    start of the sequence takes the `sos` type.
937/// 2. **W2** — EN immediately after the most-recent strong of type
938///    `AL` becomes `AN`. The "most-recent strong" walk includes
939///    `sos` as the implicit start-of-sequence strong type.
940/// 3. **W3** — every `AL` becomes `R`.
941/// 4. **W4** — single `ES` between two `EN`s becomes `EN`; single
942///    `CS` between two `EN`s becomes `EN`; single `CS` between two
943///    `AN`s becomes `AN`.
944/// 5. **W5** — runs of `ET` adjacent (on either side) to `EN` become
945///    `EN`.
946/// 6. **W6** — every remaining `ES` / `ET` / `CS` becomes `ON`.
947/// 7. **W7** — `EN` whose most-recent strong (among `L` / `R` /
948///    `sos`, **not** `AL` because W3 already turned every `AL` into
949///    `R`) is `L` becomes `L`.
950///
951/// Provenance: rules transcribed verbatim from
952/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.3.4 (UAX
953/// #9 Revision 50, Unicode 16.0).
954///
955/// # Panics
956///
957/// Does not panic. Empty input is a no-op.
958///
959/// # Examples
960///
961/// ```
962/// use oxideav_scribe::bidi::{resolve_weak_types, BidiClass};
963///
964/// // "AL EN" with sos=L: W2 sees the AL as the most-recent strong,
965/// // so EN → AN; then W3 turns the AL into R. Final: [R, AN].
966/// let mut cls = vec![BidiClass::AL, BidiClass::EN];
967/// resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
968/// assert_eq!(cls, vec![BidiClass::R, BidiClass::AN]);
969///
970/// // "L NI EN" → W7 sees L as the most-recent strong, so EN → L.
971/// let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::EN];
972/// resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
973/// assert_eq!(cls, vec![BidiClass::L, BidiClass::ON, BidiClass::L]);
974/// ```
975pub fn resolve_weak_types(classes: &mut [BidiClass], sos: BidiClass, eos: BidiClass) {
976    let _ = eos; // eos is not consumed by W1..W7 (kept in the signature
977                 // for symmetry with the N-rules + because the spec
978                 // narration references it for boundary cases).
979    if classes.is_empty() {
980        return;
981    }
982
983    // --- W1: NSM takes the type of the previous character ---------
984    //
985    // Per spec: "Examine each nonspacing mark (NSM) in the isolating
986    // run sequence, and change the type of the NSM to Other Neutral
987    // if the previous character is an isolate initiator or PDI, and
988    // to the type of the previous character otherwise. If the NSM is
989    // at the start of the isolating run sequence, it will get the
990    // type of sos." The examples in the spec confirm: AL NSM NSM →
991    // AL AL AL (consecutive NSMs all flip to the same type because
992    // the second NSM, after W1's first iteration, sees a previously-
993    // rewritten AL).
994    for i in 0..classes.len() {
995        if classes[i] != BidiClass::NSM {
996            continue;
997        }
998        let prev = if i == 0 { sos } else { classes[i - 1] };
999        classes[i] = match prev {
1000            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI | BidiClass::PDI => BidiClass::ON,
1001            other => other,
1002        };
1003    }
1004
1005    // --- W2: EN preceded (going backward) by AL becomes AN --------
1006    //
1007    // Per spec: "Search backward from each instance of a European
1008    // number until the first strong type (R, L, AL, or sos) is
1009    // found. If an AL is found, change the type of the European
1010    // number to Arabic number." Implementation: a forward sweep that
1011    // tracks the most recent strong (including sos) and rewrites EN
1012    // → AN when that strong is AL.
1013    {
1014        let mut last_strong = if sos.is_strong() { sos } else { BidiClass::L };
1015        // The spec's "sos" is treated as a strong start regardless of
1016        // L/R — but for W2 we only care whether the most recent
1017        // strong is AL. sos is never AL (paragraph_level returns 0 or
1018        // 1, mapped to L or R by the X1 stack frame). So the initial
1019        // value being L or R is fine.
1020        for cls in classes.iter_mut() {
1021            match *cls {
1022                BidiClass::L | BidiClass::R | BidiClass::AL => last_strong = *cls,
1023                BidiClass::EN if last_strong == BidiClass::AL => {
1024                    *cls = BidiClass::AN;
1025                }
1026                _ => {}
1027            }
1028        }
1029    }
1030
1031    // --- W3: every remaining AL becomes R -------------------------
1032    //
1033    // Trivial collapse; must run *after* W2 because W2 reads AL.
1034    for cls in classes.iter_mut() {
1035        if *cls == BidiClass::AL {
1036            *cls = BidiClass::R;
1037        }
1038    }
1039
1040    // --- W4: single ES between two ENs → EN; single CS between -----
1041    //         two of the same number type → that type. -------------
1042    //
1043    // Per spec examples:
1044    //   EN ES EN → EN EN EN
1045    //   EN CS EN → EN EN EN
1046    //   AN CS AN → AN AN AN
1047    //
1048    // The rule is narrow: the separator must be a *single* character,
1049    // with the same number type on both sides. We do this in one
1050    // forward pass — for each position i where classes[i] is ES or
1051    // CS, look at i-1 and i+1.
1052    if classes.len() >= 3 {
1053        for i in 1..classes.len() - 1 {
1054            let cur = classes[i];
1055            let prev = classes[i - 1];
1056            let next = classes[i + 1];
1057            match cur {
1058                BidiClass::ES if prev == BidiClass::EN && next == BidiClass::EN => {
1059                    classes[i] = BidiClass::EN;
1060                }
1061                BidiClass::CS if prev == BidiClass::EN && next == BidiClass::EN => {
1062                    classes[i] = BidiClass::EN;
1063                }
1064                BidiClass::CS if prev == BidiClass::AN && next == BidiClass::AN => {
1065                    classes[i] = BidiClass::AN;
1066                }
1067                _ => {}
1068            }
1069        }
1070    }
1071
1072    // --- W5: ET adjacent to EN (on either side) → EN --------------
1073    //
1074    // Per spec examples:
1075    //   ET ET EN → EN EN EN   (leading ETs adjacent via the trailing EN)
1076    //   EN ET ET → EN EN EN   (trailing ETs adjacent via the leading EN)
1077    //   AN ET EN → AN EN EN   (only the EN-adjacent side flips; the
1078    //                          ET adjacent to AN does NOT flip because
1079    //                          the rule says "adjacent to European
1080    //                          numbers", and AN is not EN).
1081    //
1082    // Strategy: find every contiguous run of ETs. The run flips to EN
1083    // iff it touches an EN on at least one side.
1084    {
1085        let n = classes.len();
1086        let mut i = 0;
1087        while i < n {
1088            if classes[i] != BidiClass::ET {
1089                i += 1;
1090                continue;
1091            }
1092            let start = i;
1093            while i < n && classes[i] == BidiClass::ET {
1094                i += 1;
1095            }
1096            let end = i; // exclusive
1097            let left_en = start > 0 && classes[start - 1] == BidiClass::EN;
1098            let right_en = end < n && classes[end] == BidiClass::EN;
1099            if left_en || right_en {
1100                for cls in &mut classes[start..end] {
1101                    *cls = BidiClass::EN;
1102                }
1103            }
1104        }
1105    }
1106
1107    // --- W6: all remaining separators / terminators → ON ----------
1108    //
1109    // After W4 + W5, anything that is still ES / ET / CS is a
1110    // separator that did not get absorbed into a number. Per spec it
1111    // becomes Other Neutral.
1112    for cls in classes.iter_mut() {
1113        if matches!(*cls, BidiClass::ES | BidiClass::ET | BidiClass::CS) {
1114            *cls = BidiClass::ON;
1115        }
1116    }
1117
1118    // --- W7: EN whose most-recent strong (L / R / sos) is L → L ---
1119    //
1120    // Note: W3 has already turned every AL into R, so the strong-type
1121    // backward walk for W7 only sees L / R / sos. Forward sweep with
1122    // the same "last strong" tracker as W2.
1123    {
1124        let mut last_strong = if matches!(sos, BidiClass::L | BidiClass::R) {
1125            sos
1126        } else {
1127            // sos must be L or R after X1's level-mapping; treat any
1128            // unexpected non-strong sos as L (the W7 effect is the
1129            // same as "no preceding strong yet").
1130            BidiClass::L
1131        };
1132        for cls in classes.iter_mut() {
1133            match *cls {
1134                BidiClass::L | BidiClass::R => last_strong = *cls,
1135                BidiClass::EN if last_strong == BidiClass::L => {
1136                    *cls = BidiClass::L;
1137                }
1138                _ => {}
1139            }
1140        }
1141    }
1142}
1143
1144/// Resolve **neutral and isolate-formatting types** for one
1145/// isolating run sequence per UAX #9 **N1, N2** (§3.3.5).
1146///
1147/// N0 (bracket-pair resolution) is **not** applied by this routine
1148/// — it requires the Unicode `BidiBrackets.txt` data file to
1149/// identify opening / closing paired brackets, which is a follow-up
1150/// dependency. Callers that need N0 should run it *before* calling
1151/// this function so that any bracket-resolved positions are already
1152/// strong types by the time N1 walks them.
1153///
1154/// The input `classes` are the per-character [`BidiClass`] values
1155/// for **one isolating run sequence** in logical order — the same
1156/// slice already mutated by [`resolve_weak_types`]. The slice must
1157/// already be free of `AL` (collapsed to `R` by W3) and of leftover
1158/// `ES` / `ET` / `CS` (collapsed to `ON` by W6) — feeding the W
1159/// pass's output guarantees that. `embedding_level` is the
1160/// embedding level of the run as a whole (`0` for an LTR
1161/// paragraph's outer run, `1` for an RTL paragraph's outer run; the
1162/// X-stack drives this for nested runs). `sos` / `eos` are the
1163/// **start- and end-of-sequence strong types** (`L` or `R`,
1164/// derived from the X-stack frame for the run).
1165///
1166/// The function mutates `classes` in place. After return every
1167/// element is one of `L`, `R`, `EN`, `AN`, `NSM`, or `BN` — every
1168/// NI (`B` / `S` / `WS` / `ON` / `LRI` / `RLI` / `FSI` / `PDI`) has
1169/// been resolved to a strong direction by either N1 (matching
1170/// strong neighbours, with `EN` / `AN` counting as `R`) or N2
1171/// (embedding direction fallback when strong neighbours differ or
1172/// the sequence boundary is on the other side of an `NI`-only
1173/// run). `NSM` and `BN` are intentionally left alone — they are
1174/// not in the NI alias and the §3.3.6 implicit-level rules handle
1175/// them.
1176///
1177/// The implementation is a single forward sweep:
1178///
1179/// 1. Find every maximal contiguous run `[start, end)` of
1180///    `classes[i].is_neutral_or_isolate()` elements.
1181/// 2. Determine the **left strong** type: the previous
1182///    non-NI / non-NSM / non-BN element's "directional contribution"
1183///    (`L` stays `L`; `R` / `EN` / `AN` all count as `R` per the
1184///    spec's "European and Arabic numbers act as if they were R");
1185///    falls back to `sos`'s direction at the head of the sequence.
1186/// 3. Determine the **right strong** type symmetrically; falls back
1187///    to `eos`'s direction at the tail.
1188/// 4. If `left == right`, apply **N1** — rewrite every element of
1189///    the run to that strong type.
1190/// 5. Otherwise apply **N2** — rewrite every element of the run to
1191///    the embedding direction (`L` for even `embedding_level`, `R`
1192///    for odd).
1193///
1194/// Provenance: rules transcribed verbatim from
1195/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.3.5 (UAX
1196/// #9 Revision 50, Unicode 16.0).
1197///
1198/// # Examples
1199///
1200/// ```
1201/// use oxideav_scribe::bidi::{resolve_neutral_types, BidiClass};
1202///
1203/// // Spec example "L NI L → L L L".
1204/// let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::L];
1205/// resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
1206/// assert_eq!(cls, vec![BidiClass::L, BidiClass::L, BidiClass::L]);
1207///
1208/// // Spec example "R NI AN → R R AN" (AN counts as R for N1).
1209/// let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::AN];
1210/// resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
1211/// assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::AN]);
1212///
1213/// // N2 fallback: differing-strong-context NIs take the embedding
1214/// // direction. With embedding_level 0 (L), the unresolved NI
1215/// // between L and R becomes L.
1216/// let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::R];
1217/// resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::R);
1218/// assert_eq!(cls, vec![BidiClass::L, BidiClass::L, BidiClass::R]);
1219/// ```
1220pub fn resolve_neutral_types(
1221    classes: &mut [BidiClass],
1222    embedding_level: u8,
1223    sos: BidiClass,
1224    eos: BidiClass,
1225) {
1226    if classes.is_empty() {
1227        return;
1228    }
1229
1230    // N0..N2 narration: "European and Arabic numbers act as if they
1231    // were R in terms of their influence on NIs." So the strong-type
1232    // search treats EN / AN as R. Helper to project a Bidi class onto
1233    // its strong-direction contribution.
1234    fn strong_dir(c: BidiClass) -> Option<BidiClass> {
1235        match c {
1236            BidiClass::L => Some(BidiClass::L),
1237            BidiClass::R | BidiClass::EN | BidiClass::AN => Some(BidiClass::R),
1238            _ => None,
1239        }
1240    }
1241
1242    // sos / eos are strong types (L or R after X-stack mapping); the
1243    // function tolerates any input but maps non-strong sos/eos to L
1244    // for safety (consistent with W2 / W7).
1245    let sos_dir = strong_dir(sos).unwrap_or(BidiClass::L);
1246    let eos_dir = strong_dir(eos).unwrap_or(BidiClass::L);
1247
1248    let embedding_dir = if embedding_level % 2 == 0 {
1249        BidiClass::L
1250    } else {
1251        BidiClass::R
1252    };
1253
1254    let n = classes.len();
1255    let mut i = 0;
1256    while i < n {
1257        if !classes[i].is_neutral_or_isolate() {
1258            i += 1;
1259            continue;
1260        }
1261        // Found the start of an NI run.
1262        let start = i;
1263        while i < n && classes[i].is_neutral_or_isolate() {
1264            i += 1;
1265        }
1266        let end = i; // exclusive
1267
1268        // Left strong: walk backward from `start - 1` until we find a
1269        // strong-direction contributor (L / R / EN / AN). Skip NSM /
1270        // BN, which are non-strong but not NI either. If we hit the
1271        // sequence head, fall back to sos_dir.
1272        let mut left = sos_dir;
1273        if start > 0 {
1274            let mut k = start;
1275            while k > 0 {
1276                k -= 1;
1277                if let Some(d) = strong_dir(classes[k]) {
1278                    left = d;
1279                    break;
1280                }
1281                if k == 0 {
1282                    // walked off the head without finding a strong
1283                    // contributor — fall back to sos_dir (already in
1284                    // `left`).
1285                    break;
1286                }
1287            }
1288        }
1289
1290        // Right strong: walk forward from `end` until we find a
1291        // strong-direction contributor. If we hit the sequence tail,
1292        // fall back to eos_dir.
1293        let mut right = eos_dir;
1294        {
1295            let mut k = end;
1296            while k < n {
1297                if let Some(d) = strong_dir(classes[k]) {
1298                    right = d;
1299                    break;
1300                }
1301                k += 1;
1302            }
1303        }
1304
1305        let target = if left == right { left } else { embedding_dir };
1306        for cls in &mut classes[start..end] {
1307            *cls = target;
1308        }
1309    }
1310}
1311
1312/// Open / Close classification for a paired-bracket character per UAX
1313/// #9 **BD14 + BD15** (§3.1.3).
1314///
1315/// Returned by [`paired_bracket`]. The kind is a property of the
1316/// character itself, not the position in the input — a balanced opener
1317/// always reports `Open`, its closer `Close`. The bracket-pair walker
1318/// in [`bracket_pairs`] combines the kind with the surrounding bidi
1319/// classes to honour the BD14 / BD15 "current bidirectional character
1320/// type is ON" qualifier.
1321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1322pub enum BracketKind {
1323    /// BD14 — opening paired bracket.
1324    Open,
1325    /// BD15 — closing paired bracket.
1326    Close,
1327}
1328
1329/// Bidi paired-bracket lookup per UAX #9 **§3.1.3** (BD14 / BD15 /
1330/// BD16) — the normative `Bidi_Paired_Bracket` +
1331/// `Bidi_Paired_Bracket_Type` properties.
1332///
1333/// Data-driven from the Unicode 16.0 `BidiBrackets.txt` UCD snapshot
1334/// (vendored in `src/bidi/`, parsed once on first use): 64 open /
1335/// close pairs spanning the ASCII brackets, the Tibetan gug rtags /
1336/// ang khang pairs, the Mathematical Operators / Miscellaneous
1337/// Mathematical Symbols brackets, and the CJK / fullwidth bracket
1338/// blocks.
1339///
1340/// Returns `Some((paired_char, kind))` when `c` is a paired bracket.
1341/// The paired character is the character's `Bidi_Paired_Bracket`
1342/// property value — the **other** member of the pair (so
1343/// `paired_bracket('(')` returns `(')', BracketKind::Open)` and
1344/// `paired_bracket(')')` returns `('(', BracketKind::Close)`).
1345/// Returns `None` for every other code point — including U+FD3E /
1346/// U+FD3F ORNATE LEFT/RIGHT PARENTHESIS, which per the
1347/// `BidiBrackets.txt` header "do not mirror in bidirectional display
1348/// and therefore do not form a bracket pair", and including
1349/// mirrored-but-unpaired characters such as `<` / `>` (gc=Sm, not
1350/// Ps/Pe).
1351///
1352/// # Examples
1353///
1354/// ```
1355/// use oxideav_scribe::bidi::{paired_bracket, BracketKind};
1356/// assert_eq!(paired_bracket('('), Some((')', BracketKind::Open)));
1357/// assert_eq!(paired_bracket(')'), Some(('(', BracketKind::Close)));
1358/// assert_eq!(paired_bracket('['), Some((']', BracketKind::Open)));
1359/// assert_eq!(paired_bracket(']'), Some(('[', BracketKind::Close)));
1360/// assert_eq!(paired_bracket('{'), Some(('}', BracketKind::Open)));
1361/// assert_eq!(paired_bracket('}'), Some(('{', BracketKind::Close)));
1362/// // U+3008 / U+3009 LEFT/RIGHT ANGLE BRACKET (CJK).
1363/// assert_eq!(
1364///     paired_bracket('\u{3008}'),
1365///     Some(('\u{3009}', BracketKind::Open))
1366/// );
1367/// assert_eq!(paired_bracket('a'), None);
1368/// assert_eq!(paired_bracket(' '), None);
1369/// assert_eq!(paired_bracket('<'), None);
1370/// assert_eq!(paired_bracket('\u{FD3E}'), None);
1371/// ```
1372#[must_use]
1373pub fn paired_bracket(c: char) -> Option<(char, BracketKind)> {
1374    data::bracket_lookup(c)
1375}
1376
1377/// Identify the paired-bracket text positions in one isolating run
1378/// sequence per UAX #9 **BD16** (§3.1.3).
1379///
1380/// `chars` is the per-position character slice the sequence covers
1381/// (the `i`th entry is the character at sequence-local logical index
1382/// `i`); `classes` is the parallel **post-W7** bidi-class slice for
1383/// the same positions (the same slice [`resolve_neutral_types`]
1384/// consumes). The two must have equal length.
1385///
1386/// The walker mirrors the BD16 pseudocode verbatim:
1387///
1388/// 1. Maintain a fixed-size stack of `(opener_paired_char,
1389///    text_position)` entries — capacity **63** per the spec's "fixed
1390///    size for exactly 63 elements" clause.
1391/// 2. Walk `chars` in logical order. Per BD14 / BD15 a position is a
1392///    paired bracket *only if its current bidi class is `ON`* — so
1393///    every test gates on `classes[i] == BidiClass::ON`. Positions
1394///    that paired-bracket to a non-`ON` slot (e.g. an opener inside
1395///    an RLO override that rewrote its class to `R`) are ignored, as
1396///    the spec dictates.
1397/// 3. On an `Open`-kind ON bracket: if stack room remains, push
1398///    `(closer_for_this_opener, i)`. If the stack is full, BD16's
1399///    "stop processing BD16 ... and return an empty list" branch is
1400///    taken — the function abandons the sequence and returns the
1401///    empty vector. Pairs already collected before the overflow are
1402///    **dropped** per the spec.
1403///
1404///    Note: the BD16 step uses the opener's
1405///    `Bidi_Paired_Bracket` value (i.e. the matching closer) as the
1406///    stack-element bracket, *not* the opener itself. The match in
1407///    the close branch then compares the inspected closer to that
1408///    stored value.
1409/// 4. On a `Close`-kind ON bracket: scan the stack top-down. If a
1410///    stack element matches (the stored closer equals the inspected
1411///    closer), append `(opener_position, i)` to the result and pop
1412///    *through* (inclusive of) that element. If no stack element
1413///    matches, the close is consumed without popping (the BD16
1414///    "continue with inspecting the next character" branch).
1415/// 5. Finally, sort the result by opener position in ascending order
1416///    — the N0 walker per the §3.3.5 "Process bracket pairs ...
1417///    sequentially in the logical order of the text positions of the
1418///    opening paired brackets" clause requires this ordering.
1419///
1420/// The close-branch comparison honours the BD16 step's "Compare the
1421/// closing paired bracket being inspected to the bracket in the
1422/// current stack element, **where U+3009 and U+232A are treated as
1423/// equivalent**" clause by canonicalising U+232A RIGHT-POINTING
1424/// ANGLE BRACKET to U+3009 RIGHT ANGLE BRACKET on both sides of the
1425/// comparison. Per the spec note, canonical equivalents only exist
1426/// between U+3008/U+3009 and U+2329/U+232A ("the Unicode Consortium
1427/// will not add more such pairs"), so this single substitution
1428/// covers the clause in full — a U+2329 opener pairs with a U+3009
1429/// closer and a U+3008 opener pairs with a U+232A closer.
1430///
1431/// # Examples
1432///
1433/// ```
1434/// use oxideav_scribe::bidi::{bidi_class, bracket_pairs};
1435///
1436/// // "a ( b ) c" — one bracket pair at (2, 6).
1437/// let chars: Vec<char> = "a(b)c".chars().collect();
1438/// let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
1439/// assert_eq!(bracket_pairs(&chars, &classes), vec![(1, 3)]);
1440/// ```
1441#[must_use]
1442pub fn bracket_pairs(chars: &[char], classes: &[BidiClass]) -> Vec<(usize, usize)> {
1443    assert_eq!(
1444        chars.len(),
1445        classes.len(),
1446        "bracket_pairs: chars / classes length mismatch ({} != {})",
1447        chars.len(),
1448        classes.len(),
1449    );
1450
1451    // BD16's "fixed-size stack for exactly 63 elements" — a hard cap
1452    // the spec ties to the equally hard MAX_DEPTH 125 stack limit
1453    // (overflow → abandon the sequence's brackets entirely).
1454    const BRACKET_STACK_CAP: usize = 63;
1455    let mut stack: Vec<(char, usize)> = Vec::with_capacity(BRACKET_STACK_CAP);
1456    let mut pairs: Vec<(usize, usize)> = Vec::new();
1457
1458    // BD16 close-branch comparison: "where U+3009 and U+232A are
1459    // treated as equivalent" — canonicalise the deprecated U+232A
1460    // RIGHT-POINTING ANGLE BRACKET to its canonical equivalent
1461    // U+3009 RIGHT ANGLE BRACKET before comparing. The spec note
1462    // limits canonical equivalence to exactly the U+3008/U+3009 and
1463    // U+2329/U+232A pairs, so this one substitution is exhaustive.
1464    const fn canon(c: char) -> char {
1465        if c as u32 == 0x232A {
1466            '\u{3009}'
1467        } else {
1468            c
1469        }
1470    }
1471
1472    for (i, &c) in chars.iter().enumerate() {
1473        // BD14 / BD15: only ON-classified positions count as paired
1474        // brackets. Post-W7 the only ON contributors are characters
1475        // whose original class was ON (including ASCII brackets) plus
1476        // any ES / ET / CS the W4..W6 rules collapsed to ON, none of
1477        // which paired_bracket() recognises. So the gate is exact.
1478        if classes[i] != BidiClass::ON {
1479            continue;
1480        }
1481        let Some((other, kind)) = paired_bracket(c) else {
1482            continue;
1483        };
1484        match kind {
1485            BracketKind::Open => {
1486                if stack.len() == BRACKET_STACK_CAP {
1487                    // Spec: "stop processing BD16 for the remainder
1488                    // of the isolating run sequence and return an
1489                    // empty list."
1490                    return Vec::new();
1491                }
1492                // Stack stores the *closer* paired with this opener
1493                // (BD16's "push its Bidi_Paired_Bracket property
1494                // value"), plus the opener's text position.
1495                stack.push((other, i));
1496            }
1497            BracketKind::Close => {
1498                // Spec close-branch: walk the stack top-down looking
1499                // for a matching opener. If found, append the pair
1500                // and pop through it inclusively; otherwise, continue
1501                // without popping.
1502                let mut hit: Option<usize> = None;
1503                for depth in (0..stack.len()).rev() {
1504                    if canon(stack[depth].0) == canon(c) {
1505                        hit = Some(depth);
1506                        break;
1507                    }
1508                }
1509                if let Some(depth) = hit {
1510                    let (_closer, open_pos) = stack[depth];
1511                    pairs.push((open_pos, i));
1512                    stack.truncate(depth);
1513                }
1514            }
1515        }
1516    }
1517
1518    // BD16 final step: "Sort the list of resulting bracket pairs in
1519    // ascending order based on the text position of the opening
1520    // paired bracket." The walk above appends in close-position
1521    // order, so nested pairs (inner pair closes first) appear before
1522    // their enclosing pair. Sort by opener to fix the order.
1523    pairs.sort_by_key(|&(open, _close)| open);
1524    pairs
1525}
1526
1527/// Resolve **bracket-pair types** for one isolating run sequence per
1528/// UAX #9 **N0** (§3.3.5).
1529///
1530/// `classes` is the per-character [`BidiClass`] vector left by the W
1531/// pass — the same buffer [`resolve_neutral_types`] consumes. N0
1532/// runs **before** N1 / N2: it inspects bracket-pair interiors,
1533/// decides per-pair whether to flip both brackets to a strong type,
1534/// and writes that type into both bracket slots in place. Any pair
1535/// the rule leaves untouched stays `ON` for N1 / N2 to handle.
1536///
1537/// `pairs` is the BD16 output ([`bracket_pairs`]) — a list of
1538/// `(open_pos, close_pos)` index pairs into `classes`, **sorted by
1539/// `open_pos` in ascending logical order** per the §3.3.5 "Process
1540/// bracket pairs ... sequentially in the logical order of the text
1541/// positions of the opening paired brackets" sequencing clause. The
1542/// function asserts the ordering invariant.
1543///
1544/// `embedding_level` and `sos` carry the run's embedding direction
1545/// and the §3.3.3 X10-derived sos type — the same arguments
1546/// [`resolve_neutral_types`] consumes for this sequence.
1547///
1548/// The walk implements N0 verbatim per §3.3.5:
1549///
1550/// * For each pair, scan `classes[open_pos+1..close_pos]` for a
1551///   strong contributor (L / R / EN / AN with EN + AN treated as R
1552///   per the spec's "EN and AN should be treated as a strong R type
1553///   when searching within the brackets" note).
1554/// * **N0 b** — if any inside-strong matches the embedding direction
1555///   (L for even, R for odd), set both brackets to that direction.
1556/// * **N0 c** — otherwise if any inside-strong is the opposite of
1557///   the embedding direction, derive the "established context" by
1558///   walking *backwards* from `open_pos - 1` through `classes` for
1559///   the most-recent strong contributor (EN / AN again projected to
1560///   R). The walk skips any position already inside a *prior* pair
1561///   that this rule has just rewritten — N0 narrates a "sequential
1562///   in logical order" walk so each pair sees the rewrites of every
1563///   pair with a smaller `open_pos`. If no preceding strong is found
1564///   within the sequence, fall back to `sos`.
1565///     * **N0 c.1** — if the preceding strong matches the inside-
1566///       opposite-of-embedding direction (i.e. matches the inside
1567///       strong), set both brackets to that direction.
1568///     * **N0 c.2** — otherwise (preceding strong matches the
1569///       embedding direction), set both brackets to the embedding
1570///       direction.
1571/// * **N0 d** — no strong inside the brackets → leave the pair
1572///   untouched. N1 / N2 will pick the type up later.
1573///
1574/// The optional "any NSM following a paired bracket which changed
1575/// under N0 should change to match the bracket" clarification is
1576/// implemented by walking forward from each rewritten bracket through
1577/// the contiguous NSM run immediately following and rewriting each
1578/// NSM to the bracket's new type. NSMs whose preceding position is
1579/// **not** a just-rewritten bracket are left for W1 to have already
1580/// handled.
1581///
1582/// # Examples
1583///
1584/// ```
1585/// use oxideav_scribe::bidi::{
1586///     bidi_class, bracket_pairs, resolve_bracket_pairs, BidiClass,
1587/// };
1588///
1589/// // "a(b)c" — pair at (1, 3). LTR embedding (level 0), inside-strong is L,
1590/// // matches the embedding direction, so N0 b flips both brackets to L.
1591/// let chars: Vec<char> = "a(b)c".chars().collect();
1592/// let mut classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
1593/// assert_eq!(
1594///     classes,
1595///     vec![BidiClass::L, BidiClass::ON, BidiClass::L, BidiClass::ON, BidiClass::L]
1596/// );
1597/// let pairs = bracket_pairs(&chars, &classes);
1598/// resolve_bracket_pairs(&mut classes, &pairs, 0, BidiClass::L);
1599/// assert_eq!(
1600///     classes,
1601///     vec![BidiClass::L, BidiClass::L, BidiClass::L, BidiClass::L, BidiClass::L]
1602/// );
1603/// ```
1604pub fn resolve_bracket_pairs(
1605    classes: &mut [BidiClass],
1606    pairs: &[(usize, usize)],
1607    embedding_level: u8,
1608    sos: BidiClass,
1609) {
1610    if pairs.is_empty() {
1611        return;
1612    }
1613    // Caller invariant — `bracket_pairs` returns pairs sorted by
1614    // opener; reject anything else so the N0 sequencing assumption
1615    // holds.
1616    debug_assert!(
1617        pairs.windows(2).all(|w| w[0].0 < w[1].0),
1618        "resolve_bracket_pairs: pairs must be sorted by opener and non-overlapping at the opener"
1619    );
1620
1621    // N0 narration: EN / AN are projected to R both inside the
1622    // brackets and in the backward "established context" walk.
1623    fn strong_dir_n0(c: BidiClass) -> Option<BidiClass> {
1624        match c {
1625            BidiClass::L => Some(BidiClass::L),
1626            BidiClass::R | BidiClass::EN | BidiClass::AN => Some(BidiClass::R),
1627            _ => None,
1628        }
1629    }
1630
1631    let sos_dir = strong_dir_n0(sos).unwrap_or(BidiClass::L);
1632    let embedding_dir = if embedding_level % 2 == 0 {
1633        BidiClass::L
1634    } else {
1635        BidiClass::R
1636    };
1637
1638    let n = classes.len();
1639    for &(open, close) in pairs {
1640        debug_assert!(
1641            open < close && close < n,
1642            "resolve_bracket_pairs: pair ({open}, {close}) out of bounds for {n} classes",
1643        );
1644
1645        // N0 step 2a — inspect the inside of the bracket pair for a
1646        // strong contributor (L / R / EN / AN, with EN / AN → R).
1647        // We collect *both* "matches embedding" and "matches
1648        // opposite" so we can decide between N0 b and N0 c in one
1649        // pass without a second walk.
1650        let mut saw_matching = false;
1651        let mut saw_opposite = false;
1652        for slot in &classes[open + 1..close] {
1653            if let Some(dir) = strong_dir_n0(*slot) {
1654                if dir == embedding_dir {
1655                    saw_matching = true;
1656                    break;
1657                }
1658                saw_opposite = true;
1659            }
1660        }
1661
1662        let target = if saw_matching {
1663            // N0 b
1664            embedding_dir
1665        } else if saw_opposite {
1666            // N0 c — find the most recent preceding strong within
1667            // the sequence (per-sequence here = whole `classes`
1668            // slice); fall back to sos if none exists. Since prior
1669            // pairs in the iteration order have already been
1670            // rewritten, the walk picks up their N0 output for
1671            // free, satisfying the §3.3.5 "sequentially in logical
1672            // order" clause.
1673            let mut preceding = sos_dir;
1674            for i in (0..open).rev() {
1675                if let Some(dir) = strong_dir_n0(classes[i]) {
1676                    preceding = dir;
1677                    break;
1678                }
1679            }
1680            if preceding != embedding_dir {
1681                // N0 c.1 — established context matches the inside-
1682                // opposite direction → both brackets get the
1683                // opposite direction.
1684                preceding
1685            } else {
1686                // N0 c.2 — established context matches the
1687                // embedding → both brackets get the embedding.
1688                embedding_dir
1689            }
1690        } else {
1691            // N0 d — nothing strong inside: leave the pair alone
1692            // for N1 / N2 to fold along with the surrounding
1693            // neutrals.
1694            continue;
1695        };
1696
1697        classes[open] = target;
1698        classes[close] = target;
1699
1700        // N0 trailing-NSM clarification — any NSM run that
1701        // immediately follows the open bracket OR the close bracket
1702        // adopts the bracket's new type. (W1 already inherited NSM
1703        // from its predecessor; we re-rewrite to honour the bracket
1704        // rewrite.) The walk stops at the first non-NSM character;
1705        // each rewrite is local.
1706        let mut k = open + 1;
1707        while k < classes.len() && classes[k] == BidiClass::NSM {
1708            classes[k] = target;
1709            k += 1;
1710        }
1711        let mut k = close + 1;
1712        while k < classes.len() && classes[k] == BidiClass::NSM {
1713            classes[k] = target;
1714            k += 1;
1715        }
1716    }
1717}
1718
1719/// Resolve **implicit embedding levels** for one isolating run sequence
1720/// per UAX #9 **I1, I2** (§3.3.6).
1721///
1722/// `classes` is the per-character [`BidiClass`] vector left by the N
1723/// pass — every former neutral or isolate-formatting position has
1724/// already been collapsed to a strong type, and the only weak types
1725/// that survive are `EN`, `AN`, `NSM`, and `BN` (per UAX #9 §3.3.4 +
1726/// §3.3.5). `embedding_level` is the embedding level of the run as a
1727/// whole (`0` for an LTR paragraph's outer run, `1` for an RTL
1728/// paragraph's outer run; the X-stack drives this for nested runs).
1729///
1730/// Returns a `Vec<u8>` of the same length as `classes`, holding the
1731/// per-character **resolved** embedding level. Per UAX #9 §3.3.6
1732/// Table 5:
1733///
1734/// | Type | Even EL | Odd EL |
1735/// | ---- | ------- | ------ |
1736/// | L    | EL      | EL+1   |
1737/// | R    | EL+1    | EL     |
1738/// | AN   | EL+2    | EL+1   |
1739/// | EN   | EL+2    | EL+1   |
1740///
1741/// `BN` is ignored per UAX #9 §5.2 ("In rules I1 and I2, ignore BN.")
1742/// — its level stays at `embedding_level`, so a later L1 / L4 pass
1743/// (which resets BN levels in a separate phase) sees a stable base.
1744/// `NSM` was rewritten to its preceding character's type by W1; if
1745/// it survived the N pass unchanged (only the explicit
1746/// `BidiClass::NSM` slot does, after the N-rules pass), it is also
1747/// treated as `BN`-like here — its level stays at `embedding_level`
1748/// because it is not a strong / numeric type and the spec's I1 / I2
1749/// rules enumerate only L / R / AN / EN.
1750///
1751/// # Examples
1752///
1753/// ```
1754/// use oxideav_scribe::bidi::{resolve_implicit_levels, BidiClass};
1755///
1756/// // Even (LTR) paragraph: L stays, R goes +1, EN / AN go +2.
1757/// let cls = vec![BidiClass::L, BidiClass::R, BidiClass::EN, BidiClass::AN];
1758/// assert_eq!(resolve_implicit_levels(&cls, 0), vec![0, 1, 2, 2]);
1759///
1760/// // Odd (RTL) paragraph: R stays, L / EN / AN all go +1.
1761/// let cls = vec![BidiClass::L, BidiClass::R, BidiClass::EN, BidiClass::AN];
1762/// assert_eq!(resolve_implicit_levels(&cls, 1), vec![2, 1, 2, 2]);
1763/// ```
1764#[must_use]
1765pub fn resolve_implicit_levels(classes: &[BidiClass], embedding_level: u8) -> Vec<u8> {
1766    let even = embedding_level % 2 == 0;
1767    classes
1768        .iter()
1769        .map(|c| match c {
1770            BidiClass::L => {
1771                if even {
1772                    embedding_level
1773                } else {
1774                    embedding_level + 1
1775                }
1776            }
1777            BidiClass::R => {
1778                if even {
1779                    embedding_level + 1
1780                } else {
1781                    embedding_level
1782                }
1783            }
1784            BidiClass::EN | BidiClass::AN => {
1785                if even {
1786                    embedding_level + 2
1787                } else {
1788                    embedding_level + 1
1789                }
1790            }
1791            // §5.2: "In rules I1 and I2, ignore BN." A surviving NSM
1792            // is similarly outside the I1 / I2 enumeration (the spec
1793            // only names L / R / AN / EN); leave it at the embedding
1794            // level so a follow-up L-rule pass can fold it.
1795            _ => embedding_level,
1796        })
1797        .collect()
1798}
1799
1800/// Apply UAX #9 §3.4 rule **L1** to one line in place.
1801///
1802/// L1 resets the embedding level of certain trailing / separator
1803/// characters back to the paragraph embedding level so that
1804/// whitespace and tabulation end up on the visual edge that
1805/// matches the paragraph direction. The four sub-cases enumerated
1806/// in §3.4 are:
1807///
1808/// 1. Segment separators (class `S`).
1809/// 2. Paragraph separators (class `B`).
1810/// 3. Any sequence of whitespace (`WS`) and/or isolate-formatting
1811///    characters (`LRI` / `RLI` / `FSI` / `PDI`) **preceding** a
1812///    segment separator or paragraph separator.
1813/// 4. Any sequence of whitespace and/or isolate-formatting
1814///    characters **at the end of the line**.
1815///
1816/// UAX #9 §3.4 carries a normative note: "The types of characters
1817/// used here are the *original* types, not those modified by the
1818/// previous phase." `orig_classes` is therefore the same class
1819/// slice the caller fed into `resolve_weak_types` / the N-rule /
1820/// I-rule passes — not the post-W/N output.
1821///
1822/// `levels` is the per-character level vector produced by
1823/// [`resolve_implicit_levels`] (the §3.3.6 output) for the
1824/// characters that make up this one line. The function rewrites
1825/// the affected positions of `levels` in place to
1826/// `paragraph_level`; positions that L1 does not name (strong
1827/// characters, weak numerics, leftover neutrals that are not on a
1828/// trailing-whitespace run) are left untouched.
1829///
1830/// # Panics
1831///
1832/// Panics if `orig_classes.len() != levels.len()`.
1833///
1834/// # Examples
1835///
1836/// ```
1837/// use oxideav_scribe::bidi::{reset_trailing_levels, BidiClass};
1838///
1839/// // Trailing space in an LTR paragraph stays at level 0.
1840/// // (Resolved levels from a prior I pass might be 0 for the `L`
1841/// // text and the trailing `WS`; L1 explicitly anchors the WS to
1842/// // the paragraph level either way.)
1843/// let cls = vec![BidiClass::L, BidiClass::L, BidiClass::WS];
1844/// let mut lvl = vec![0, 0, 0];
1845/// reset_trailing_levels(&cls, &mut lvl, 0);
1846/// assert_eq!(lvl, vec![0, 0, 0]);
1847///
1848/// // RTL paragraph: trailing whitespace is dragged to level 1.
1849/// let cls = vec![BidiClass::R, BidiClass::R, BidiClass::WS, BidiClass::WS];
1850/// let mut lvl = vec![1, 1, 2, 2];
1851/// reset_trailing_levels(&cls, &mut lvl, 1);
1852/// assert_eq!(lvl, vec![1, 1, 1, 1]);
1853/// ```
1854pub fn reset_trailing_levels(orig_classes: &[BidiClass], levels: &mut [u8], paragraph_level: u8) {
1855    assert_eq!(
1856        orig_classes.len(),
1857        levels.len(),
1858        "reset_trailing_levels: class slice and level slice must be the same length",
1859    );
1860    let n = orig_classes.len();
1861    if n == 0 {
1862        return;
1863    }
1864    // Cases (1) + (2): every S / B position is reset directly.
1865    // Case (3): for each such separator, walk leftward across any
1866    // contiguous WS / isolate-formatting run and reset those too.
1867    // Case (4): a single trailing WS / isolate-formatting run at
1868    // the end of the line is reset.
1869    for (i, &cls) in orig_classes.iter().enumerate() {
1870        if matches!(cls, BidiClass::S | BidiClass::B) {
1871            levels[i] = paragraph_level;
1872            // Walk backward over WS + isolate-formatting characters
1873            // immediately preceding this separator.
1874            let mut j = i;
1875            while j > 0 && is_l1_trailing_filler(orig_classes[j - 1]) {
1876                j -= 1;
1877                levels[j] = paragraph_level;
1878            }
1879        }
1880    }
1881    // Case (4): trailing WS + isolate-formatting at end of line.
1882    let mut k = n;
1883    while k > 0 && is_l1_trailing_filler(orig_classes[k - 1]) {
1884        k -= 1;
1885        levels[k] = paragraph_level;
1886    }
1887}
1888
1889/// Predicate for the §3.4 L1 case-(3) / case-(4) "whitespace +
1890/// isolate-formatting" set: `WS`, `LRI`, `RLI`, `FSI`, `PDI`.
1891fn is_l1_trailing_filler(c: BidiClass) -> bool {
1892    matches!(
1893        c,
1894        BidiClass::WS | BidiClass::LRI | BidiClass::RLI | BidiClass::FSI | BidiClass::PDI
1895    )
1896}
1897
1898/// Apply UAX #9 §3.4 rule **L2** to one line and return a logical-
1899/// to-visual permutation.
1900///
1901/// The returned `Vec<usize>` has `levels.len()` entries; entry `v`
1902/// is the logical index that should be displayed at visual
1903/// position `v`. The caller (a renderer / line builder) walks the
1904/// permutation in order and emits the glyphs of the corresponding
1905/// logical characters left-to-right.
1906///
1907/// The algorithm is the spec's progressive-reversal procedure:
1908///
1909/// 1. Start with the identity permutation `[0, 1, ..., n - 1]`.
1910/// 2. Find `max_level` (the largest entry of `levels`).
1911/// 3. Find `lowest_odd_level` (the smallest odd entry of `levels`;
1912///    if no odd level exists the line is wholly LTR and no
1913///    reversal is needed).
1914/// 4. For each iteration level `L = max_level, max_level - 1, ...,
1915///    lowest_odd_level`, find every maximal contiguous run of
1916///    positions whose original (pre-L1, but the §3.4 algorithm
1917///    operates on the post-L1 vector here) level is `>= L`, and
1918///    reverse the permutation entries in that range.
1919///
1920/// The progressive scan from the top down builds up the nested
1921/// reversals shown in UAX #9 §3.4 Examples 1..4: a level-1 run
1922/// inside a level-0 paragraph reverses once; a level-2 number
1923/// embedded in a level-1 RTL run reverses once at level 2 (the
1924/// digits go LTR within the embedding) and then again at level 1
1925/// (the whole embedding goes RTL within the paragraph).
1926///
1927/// Returns the identity permutation when `levels` is empty.
1928///
1929/// # Examples
1930///
1931/// ```
1932/// use oxideav_scribe::bidi::reorder_line;
1933///
1934/// // All-LTR line: identity.
1935/// assert_eq!(reorder_line(&[0, 0, 0]), vec![0, 1, 2]);
1936///
1937/// // All-RTL line: full reverse.
1938/// assert_eq!(reorder_line(&[1, 1, 1]), vec![2, 1, 0]);
1939///
1940/// // §3.4 Example 1: "car means CAR.", resolved levels
1941/// // 00000000001110 — only the level-1 run "CAR" reverses.
1942/// let lv = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0];
1943/// let visual = reorder_line(&lv);
1944/// // Positions 10..13 reverse to 12, 11, 10; trailing '.' stays.
1945/// assert_eq!(visual, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 11, 10, 13]);
1946/// ```
1947#[must_use]
1948pub fn reorder_line(levels: &[u8]) -> Vec<usize> {
1949    let n = levels.len();
1950    let mut visual: Vec<usize> = (0..n).collect();
1951    if n == 0 {
1952        return visual;
1953    }
1954    let max_level = *levels.iter().max().unwrap_or(&0);
1955    let lowest_odd_level = levels
1956        .iter()
1957        .copied()
1958        .filter(|l| l % 2 == 1)
1959        .min()
1960        .unwrap_or(u8::MAX);
1961    if lowest_odd_level == u8::MAX {
1962        // No odd levels: the whole line is LTR. L2's lower bound is
1963        // the lowest odd level, so no iteration runs.
1964        return visual;
1965    }
1966    // For each level from max down to lowest_odd_level, reverse
1967    // every maximal contiguous run of positions whose level is
1968    // `>= level`.
1969    let mut level = max_level;
1970    loop {
1971        let mut i = 0;
1972        while i < n {
1973            if levels[i] >= level {
1974                let mut j = i + 1;
1975                while j < n && levels[j] >= level {
1976                    j += 1;
1977                }
1978                visual[i..j].reverse();
1979                i = j;
1980            } else {
1981                i += 1;
1982            }
1983        }
1984        if level == lowest_odd_level {
1985            break;
1986        }
1987        // level >= lowest_odd_level >= 1, so the decrement is safe.
1988        level -= 1;
1989    }
1990    visual
1991}
1992
1993/// Apply UAX #9 §3.4 rule **L3** in place to the visual permutation
1994/// returned by [`reorder_line`].
1995///
1996/// Per §3.4 L3:
1997///
1998/// > Combining marks applied to a right-to-left base character will at
1999/// > this point precede their base character. If the rendering engine
2000/// > expects them to follow the base characters in the final display
2001/// > process, then the ordering of the marks and the base character
2002/// > must be reversed.
2003///
2004/// After [`reorder_line`], an RTL run that was originally
2005/// `base, nsm_1, nsm_2, ..., nsm_k` in logical order appears in visual
2006/// order as `nsm_k, ..., nsm_2, nsm_1, base` (the whole odd-level run
2007/// was reversed by L2). L3 reverses each such `[m_k, …, m_1, base]`
2008/// block back to `[base, m_1, …, m_k]` so the marks follow the base
2009/// in the final display stream — the contract a renderer that paints
2010/// marks with rightward overhangs (the spec's "expects them to
2011/// follow" alternative) requires. The block is reversed wholesale
2012/// per the spec wording "the ordering of the marks and the base
2013/// character must be reversed", which restores the marks to their
2014/// original logical-source order behind the base.
2015///
2016/// `orig_classes` is the same class slice the caller fed into the
2017/// W / N / I passes, per the §3.4 normative note "the original types
2018/// of the characters". `levels` is the post-L1 / I-rules level
2019/// vector (same shape as the input to [`reorder_line`]).
2020///
2021/// LTR runs (even level) are untouched — L2 did not reverse them, so
2022/// their marks are already after their base in visual order. NSMs at
2023/// the start of an odd-level run with no preceding non-NSM base in
2024/// the same run are left in place (W1 retypes such NSMs to `sos`, and
2025/// no base is available to attach them to). Mixed-level transitions
2026/// (e.g. NSMs whose level differs from their preceding base after
2027/// W1's retyping moved them) are conservatively left alone, since the
2028/// spec's L3 wording scopes the reordering to "marks applied to a
2029/// right-to-left base character" — taken to mean marks that share
2030/// the base's resolved level.
2031///
2032/// L3 is a no-op when every level is even (no RTL runs exist) and
2033/// when no NSM is present at an odd level. Idempotent: calling twice
2034/// yields the same permutation as calling once.
2035///
2036/// # Examples
2037///
2038/// ```
2039/// use oxideav_scribe::bidi::{reorder_combining_marks, reorder_line, BidiClass};
2040///
2041/// // Logical: R NSM NSM (Hebrew base + two combining marks).
2042/// // Post-I levels: 1 1 1. L2 reverses to visual [2, 1, 0].
2043/// let cls = [BidiClass::R, BidiClass::NSM, BidiClass::NSM];
2044/// let lvl = [1, 1, 1];
2045/// let mut visual = reorder_line(&lvl);
2046/// assert_eq!(visual, vec![2, 1, 0]);
2047/// reorder_combining_marks(&cls, &lvl, &mut visual);
2048/// // L3 rotates the block: base first, then marks in original order.
2049/// assert_eq!(visual, vec![0, 1, 2]);
2050/// ```
2051pub fn reorder_combining_marks(orig_classes: &[BidiClass], levels: &[u8], visual: &mut [usize]) {
2052    assert_eq!(
2053        orig_classes.len(),
2054        levels.len(),
2055        "reorder_combining_marks: class slice and level slice must be the same length",
2056    );
2057    assert_eq!(
2058        orig_classes.len(),
2059        visual.len(),
2060        "reorder_combining_marks: class slice and visual slice must be the same length",
2061    );
2062    let n = visual.len();
2063    if n == 0 {
2064        return;
2065    }
2066    // Walk the VISUAL order. At each odd-level NSM, look ahead for
2067    // the maximal contiguous block of `[NSM, …, NSM, base]` whose
2068    // LOGICAL indices form a strictly decreasing sequence ending at
2069    // the base — i.e. the post-L2 footprint of one RTL
2070    // `base + marks` cluster `(b, b+1, ..., b+k)` mapped to visual
2071    // `(b+k, b+k-1, ..., b+1, b)`. The strictly-decreasing check
2072    // identifies the L2-reversed shape uniquely; once L3 has
2073    // reversed a block to ascending logical order, a second pass
2074    // sees the same block as ascending and leaves it alone (L3 is
2075    // idempotent).
2076    let mut vi = 0;
2077    while vi < n {
2078        let logical = visual[vi];
2079        if orig_classes[logical] == BidiClass::NSM && levels[logical] % 2 == 1 {
2080            let lvl = levels[logical];
2081            // Walk forward over consecutive NSMs at this same level
2082            // whose LOGICAL index decreases by exactly 1 each step
2083            // (matching L2's contiguous-run reversal output).
2084            let mut vj = vi;
2085            let mut expected = logical;
2086            while vj < n {
2087                let lj = visual[vj];
2088                if orig_classes[lj] == BidiClass::NSM && levels[lj] == lvl && lj == expected {
2089                    vj += 1;
2090                    expected = expected.saturating_sub(1);
2091                } else {
2092                    break;
2093                }
2094            }
2095            // `vj` now points at the first non-NSM (or
2096            // non-decreasing-NSM) in the visual stream. For the
2097            // reversal to apply, that position must be a non-NSM
2098            // base at the same odd level AND its logical index
2099            // must continue the decreasing sequence (i.e. equal
2100            // `expected`).
2101            if vj < n {
2102                let lb = visual[vj];
2103                if orig_classes[lb] != BidiClass::NSM && levels[lb] == lvl && lb == expected {
2104                    // Block is `visual[vi..=vj]`. Per §3.4 L3 "the
2105                    // ordering of the marks and the base character
2106                    // must be reversed": reverse the whole block
2107                    // so visual `[m_k, ..., m_1, base]` becomes
2108                    // `[base, m_1, ..., m_k]` — the base comes
2109                    // first and the marks regain their logical
2110                    // source order.
2111                    visual[vi..=vj].reverse();
2112                    // Advance past the reversed block.
2113                    vi = vj + 1;
2114                    continue;
2115                }
2116            }
2117            // No matching base — leave these NSMs untouched and
2118            // skip past them so we don't re-scan.
2119            vi = vj.max(vi + 1);
2120        } else {
2121            vi += 1;
2122        }
2123    }
2124}
2125
2126/// Bidi mirrored-glyph lookup (the UCD `Bidi_Mirroring_Glyph`
2127/// property), consumed by the UAX #9 §3.4 rule **L4** pass in
2128/// [`apply_mirroring`].
2129///
2130/// Data-driven from the Unicode 16.0 `BidiMirroring.txt` UCD
2131/// snapshot (vendored in `src/bidi/`, parsed once on first use):
2132/// 428 entries covering the paired brackets, the angle quotation
2133/// marks (`«` ↔ `»`, `‹` ↔ `›`), the mathematical relations and
2134/// operators (`<` ↔ `>`, `≤` ↔ `≥`, …), and the CJK / fullwidth
2135/// bracket blocks.
2136///
2137/// Returns `Some(mirror)` when `c` has an acceptable mirror-pair
2138/// character per UAX #9 §7 *Mirroring* ("sometimes pairs of
2139/// characters are acceptable mirrors for one another — for example,
2140/// U+0028 LEFT PARENTHESIS and U+0029 RIGHT PARENTHESIS"), `None`
2141/// otherwise. The mapping is an involution: `mirrored_glyph(m) ==
2142/// Some(c)` whenever `mirrored_glyph(c) == Some(m)`.
2143///
2144/// `Bidi_Mirrored=Yes` characters **without** an acceptable mirror
2145/// pair (the `BidiMirroring.txt` trailing comment lists them — e.g.
2146/// U+2201 COMPLEMENT) return `None`: rendering those mirrored needs
2147/// font-level glyph mirroring, which the pair-substitution strategy
2148/// cannot express. U+FD3E / U+FD3F ORNATE LEFT/RIGHT PARENTHESIS are
2149/// not `Bidi_Mirrored` at all and also return `None`.
2150///
2151/// # Examples
2152///
2153/// ```
2154/// use oxideav_scribe::bidi::mirrored_glyph;
2155/// assert_eq!(mirrored_glyph('('), Some(')'));
2156/// assert_eq!(mirrored_glyph(')'), Some('('));
2157/// assert_eq!(mirrored_glyph('['), Some(']'));
2158/// assert_eq!(mirrored_glyph(']'), Some('['));
2159/// assert_eq!(mirrored_glyph('{'), Some('}'));
2160/// assert_eq!(mirrored_glyph('}'), Some('{'));
2161/// assert_eq!(mirrored_glyph('<'), Some('>'));
2162/// assert_eq!(mirrored_glyph('\u{00AB}'), Some('\u{00BB}')); // « ↔ »
2163/// assert_eq!(mirrored_glyph('\u{2264}'), Some('\u{2265}')); // ≤ ↔ ≥
2164/// assert_eq!(mirrored_glyph('a'), None);
2165/// // The ornate parentheses are not mirrored (Bidi_Mirrored=No).
2166/// assert_eq!(mirrored_glyph('\u{FD3E}'), None);
2167/// assert_eq!(mirrored_glyph('\u{FD3F}'), None);
2168/// ```
2169#[must_use]
2170pub fn mirrored_glyph(c: char) -> Option<char> {
2171    data::mirror_lookup(c)
2172}
2173
2174/// Apply UAX #9 §3.4 rule **L4** in place to a line's logical
2175/// character sequence.
2176///
2177/// Per §3.4 L4:
2178///
2179/// > A character is depicted by a mirrored glyph if and only if (a)
2180/// > the resolved directionality of that character is R, and (b) the
2181/// > Bidi_Mirrored property value of that character is Yes.
2182///
2183/// Condition (a) is read off the resolved level vector: an odd
2184/// resolved level means the character's resolved directionality is R
2185/// (per the §3.2 level convention — even levels are left-to-right,
2186/// odd levels are right-to-left). Condition (b) is the
2187/// [`mirrored_glyph`] lookup (currently the ASCII paired-bracket seed
2188/// set — see its scope note). Every position satisfying both has its
2189/// character replaced by the mirror-pair character, which is how an
2190/// implementation without per-glyph mirroring support in the font
2191/// stack realises the spec's "depicted by a mirrored glyph"
2192/// requirement (§7: "pairs of characters are acceptable mirrors for
2193/// one another").
2194///
2195/// `chars` is the line's character sequence in **logical order**;
2196/// `levels` is the parallel resolved-level vector for the same line
2197/// (the post-L1 levels — the same vector [`reorder_line`] consumes).
2198/// L4 is a per-position glyph selection and is independent of the L2
2199/// / L3 permutation, so callers may apply it before or after
2200/// reordering as long as the levels stay paired with the right
2201/// characters; applying it to the logical sequence (as here) and
2202/// then walking the L2 permutation is the straightforward
2203/// composition.
2204///
2205/// Because [`mirrored_glyph`] is an involution, applying L4 twice
2206/// restores the original sequence — callers must run it exactly once
2207/// per rendered line.
2208///
2209/// The HL6 higher-level-protocol override ("this rule can be
2210/// overridden in certain cases; see HL6") is out of scope — callers
2211/// implementing an HL6 protocol pre-filter the positions they
2212/// exempt.
2213///
2214/// # Panics
2215///
2216/// Panics when `chars` and `levels` have different lengths.
2217///
2218/// # Examples
2219///
2220/// ```
2221/// use oxideav_scribe::bidi::apply_mirroring;
2222///
2223/// // §3.4 L4 worked example: U+0028 appears as '(' when its
2224/// // resolved level is even, and as the mirrored glyph ')' when
2225/// // its resolved level is odd.
2226/// let mut even = ['(', 'a', ')'];
2227/// apply_mirroring(&mut even, &[0, 0, 0]);
2228/// assert_eq!(even, ['(', 'a', ')']);
2229///
2230/// let mut odd = ['(', 'a', ')'];
2231/// apply_mirroring(&mut odd, &[1, 2, 1]);
2232/// assert_eq!(odd, [')', 'a', '(']);
2233/// ```
2234pub fn apply_mirroring(chars: &mut [char], levels: &[u8]) {
2235    assert_eq!(
2236        chars.len(),
2237        levels.len(),
2238        "apply_mirroring: chars / levels length mismatch ({} != {})",
2239        chars.len(),
2240        levels.len(),
2241    );
2242    for (c, &level) in chars.iter_mut().zip(levels.iter()) {
2243        // L4 (a): resolved directionality R ⇔ odd resolved level.
2244        if level % 2 == 1 {
2245            // L4 (b): Bidi_Mirrored = Yes, realised through the
2246            // acceptable-mirror-pair substitution per §7.
2247            if let Some(mirror) = mirrored_glyph(*c) {
2248                *c = mirror;
2249            }
2250        }
2251    }
2252}
2253
2254/// Split `text` into paragraphs at every character of class `B`
2255/// per UAX #9 **P1**.
2256///
2257/// The paragraph separator character is kept with the preceding
2258/// paragraph (per P1 "A paragraph separator (type B) is kept with
2259/// the previous paragraph."), so the returned substrings cover the
2260/// entire input without gaps and concatenate back to `text` exactly.
2261///
2262/// Returned slices may be empty when two `B` characters are adjacent.
2263#[must_use]
2264pub fn split_paragraphs(text: &str) -> Vec<&str> {
2265    let mut out = Vec::new();
2266    let mut start = 0usize;
2267    for (i, c) in text.char_indices() {
2268        if bidi_class(c) == BidiClass::B {
2269            let end = i + c.len_utf8();
2270            out.push(&text[start..end]);
2271            start = end;
2272        }
2273    }
2274    if start < text.len() {
2275        out.push(&text[start..]);
2276    }
2277    out
2278}
2279
2280/// A maximal substring of characters that share an embedding level
2281/// per UAX #9 **BD7** (§3.1.2).
2282///
2283/// `start` is the index of the first character in the run; `end` is
2284/// one past the last character (half-open, matching `Range<usize>`
2285/// idioms). Both indices refer back into the level vector returned
2286/// by [`resolve_explicit_levels`] (and therefore the underlying
2287/// `classes` slice the caller fed to that function — they are the
2288/// same length).
2289///
2290/// X9-removed positions (RLE / LRE / RLO / LRO / PDF / BN) are
2291/// **included** in their containing level run — BD7 partitions on
2292/// assigned embedding level, not on removal status. The implicit
2293/// phases (W / N / I) walk runs via [`IsolatingRunSequence::indices`]
2294/// which skips removed positions per the X9 "behave as though the
2295/// characters were not present" clause.
2296///
2297/// Provenance: BD7 transcribed verbatim from
2298/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.1.2
2299/// (UAX #9 Revision 50, Unicode 16.0).
2300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2301pub struct LevelRun {
2302    /// First character index in the run (inclusive).
2303    pub start: usize,
2304    /// One past the last character index in the run (exclusive).
2305    pub end: usize,
2306    /// Embedding level shared by every character in the run.
2307    pub level: u8,
2308}
2309
2310impl LevelRun {
2311    /// Length of the run in character positions.
2312    #[must_use]
2313    pub fn len(&self) -> usize {
2314        self.end - self.start
2315    }
2316
2317    /// `true` iff [`Self::len`] is zero.
2318    #[must_use]
2319    pub fn is_empty(&self) -> bool {
2320        self.end == self.start
2321    }
2322}
2323
2324/// Compute the **BD7 level-run partition** of a paragraph from the
2325/// per-character level vector produced by [`resolve_explicit_levels`].
2326///
2327/// A level run is a maximal substring of characters that have the
2328/// same embedding level (BD7). The output is the level-run list in
2329/// logical-order; concatenating their `start..end` ranges covers
2330/// `0..levels.len()` with no overlap or gap.
2331///
2332/// `levels.len()` must equal the paragraph's character count (it
2333/// always does when obtained from [`resolve_explicit_levels`]).
2334/// For the empty input the output is empty.
2335///
2336/// Provenance: BD7 (§3.1.2). The function is the prerequisite for
2337/// X10 / BD13 ([`isolating_run_sequences`]).
2338///
2339/// # Examples
2340///
2341/// ```
2342/// use oxideav_scribe::bidi::level_runs;
2343///
2344/// // Uniform-level paragraph collapses to one run.
2345/// let runs = level_runs(&[0, 0, 0, 0]);
2346/// assert_eq!(runs.len(), 1);
2347/// assert_eq!((runs[0].start, runs[0].end, runs[0].level), (0, 4, 0));
2348///
2349/// // A level change splits the partition cleanly: levels
2350/// // [0, 1, 1, 0, 0] → (0..1, 0), (1..3, 1), (3..5, 0).
2351/// let runs = level_runs(&[0, 1, 1, 0, 0]);
2352/// assert_eq!(runs.len(), 3);
2353/// assert_eq!((runs[0].start, runs[0].end, runs[0].level), (0, 1, 0));
2354/// assert_eq!((runs[1].start, runs[1].end, runs[1].level), (1, 3, 1));
2355/// assert_eq!((runs[2].start, runs[2].end, runs[2].level), (3, 5, 0));
2356/// ```
2357#[must_use]
2358pub fn level_runs(levels: &[u8]) -> Vec<LevelRun> {
2359    let mut out = Vec::new();
2360    let n = levels.len();
2361    if n == 0 {
2362        return out;
2363    }
2364    let mut start = 0usize;
2365    let mut cur = levels[0];
2366    for (i, &lvl) in levels.iter().enumerate().skip(1) {
2367        if lvl != cur {
2368            out.push(LevelRun {
2369                start,
2370                end: i,
2371                level: cur,
2372            });
2373            start = i;
2374            cur = lvl;
2375        }
2376    }
2377    out.push(LevelRun {
2378        start,
2379        end: n,
2380        level: cur,
2381    });
2382    out
2383}
2384
2385/// One isolating run sequence per UAX #9 **BD13** + **X10** (§3.1.2,
2386/// §3.3.3).
2387///
2388/// An isolating run sequence is a maximal sequence of level runs
2389/// chained across matched isolate-initiator → PDI boundaries (BD13).
2390/// All level runs in a sequence share the same embedding level (BD13
2391/// note: "Thus, all the level runs in an isolating run sequence have
2392/// the same embedding level."). The W1..W7 / N0..N2 / I1..I2 implicit
2393/// phases run **once per sequence** treating "the last character of
2394/// each level run in the isolating run sequence is treated as if it
2395/// were immediately followed by the first character in the next
2396/// level run in the sequence" per X10 step 3.
2397///
2398/// Fields:
2399///
2400/// - `runs` — the constituent level runs in logical order. Always
2401///   non-empty: a sequence has at least one run.
2402/// - `level` — the shared embedding level.
2403/// - `sos`, `eos` — the start-of-sequence and end-of-sequence
2404///   directional types (`L` or `R`) per X10 step 2, derived from
2405///   the *higher* of the two levels on either side of the sequence
2406///   boundary (the paragraph embedding level if the boundary lies
2407///   at paragraph edge or at an isolate initiator with no matching
2408///   PDI). `R` iff the higher level is odd; `L` otherwise. These
2409///   are the `sos` / `eos` arguments the existing
2410///   [`resolve_weak_types`] / [`resolve_neutral_types`] surface
2411///   expects.
2412///
2413/// Provenance: BD13 and X10 transcribed verbatim from
2414/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.1.2 +
2415/// §3.3.3 (UAX #9 Revision 50, Unicode 16.0).
2416#[derive(Debug, Clone, PartialEq, Eq)]
2417pub struct IsolatingRunSequence {
2418    /// Constituent level runs in logical (paragraph) order. Always
2419    /// `len() >= 1`.
2420    pub runs: Vec<LevelRun>,
2421    /// Shared embedding level across every run in the sequence.
2422    pub level: u8,
2423    /// Start-of-sequence directional type per X10 step 2. Always
2424    /// `BidiClass::L` or `BidiClass::R`.
2425    pub sos: BidiClass,
2426    /// End-of-sequence directional type per X10 step 2. Always
2427    /// `BidiClass::L` or `BidiClass::R`.
2428    pub eos: BidiClass,
2429}
2430
2431impl IsolatingRunSequence {
2432    /// Iterator over the character indices belonging to this
2433    /// sequence, in logical order, **skipping X9-removed positions**.
2434    ///
2435    /// `removed` is the parallel slice returned by
2436    /// [`resolve_explicit_levels`] (`ExplicitLevels::removed`). The
2437    /// returned iterator walks every run in `self.runs` and yields
2438    /// each index `i` where `removed[i] == false`. The result is
2439    /// the in-order character index list the W / N / I phases
2440    /// consume per X9's "behave as though the characters were not
2441    /// present" clause.
2442    ///
2443    /// Panics if `removed.len()` is smaller than any run's `end`.
2444    pub fn indices<'a>(&'a self, removed: &'a [bool]) -> impl Iterator<Item = usize> + 'a {
2445        self.runs
2446            .iter()
2447            .flat_map(move |r| (r.start..r.end).filter(move |&i| !removed[i]))
2448    }
2449}
2450
2451/// Convert a higher-of-two embedding levels into an sos / eos
2452/// directional type per UAX #9 X10 step 2: "If the higher level is
2453/// odd, the sos or eos is R; otherwise, it is L."
2454fn level_to_sos_eos(level: u8) -> BidiClass {
2455    if level % 2 == 0 {
2456        BidiClass::L
2457    } else {
2458        BidiClass::R
2459    }
2460}
2461
2462/// Find the index of the matching PDI for the isolate initiator at
2463/// `start` per UAX #9 **BD9** (§3.1.2).
2464///
2465/// `classes` is the original (pre-X9, pre-override) class slice (the
2466/// same input fed to [`resolve_explicit_levels`]). Returns `Some(j)`
2467/// where `j > start` is the index of the matching PDI; returns
2468/// `None` if no matching PDI exists (the isolate initiator is
2469/// unbalanced).
2470///
2471/// Note: BD9 only counts isolate initiators (LRI / RLI / FSI) and
2472/// PDIs — every other formatting character is ignored. The depth
2473/// limit (MAX_DEPTH overflow during X1..X9) is **not** considered
2474/// at this layer per BD9's closing note "this algorithm assigns a
2475/// matching PDI (or lack of one) to an isolate initiator whether
2476/// the isolate initiator raises the embedding level or is prevented
2477/// from doing so by the depth limit."
2478fn matching_pdi(classes: &[BidiClass], start: usize) -> Option<usize> {
2479    let mut counter: i32 = 1;
2480    for (offset, cls) in classes.iter().enumerate().skip(start + 1) {
2481        match cls {
2482            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => counter += 1,
2483            BidiClass::PDI => {
2484                counter -= 1;
2485                if counter == 0 {
2486                    return Some(offset);
2487                }
2488            }
2489            _ => {}
2490        }
2491    }
2492    None
2493}
2494
2495/// Compute the **isolating-run-sequence partition** of a paragraph
2496/// per UAX #9 **X10** (§3.3.3), with `sos` and `eos` directional
2497/// types attached per X10 step 2.
2498///
2499/// `classes` is the original per-character [`BidiClass`] slice fed
2500/// to [`resolve_explicit_levels`]; `explicit` is its return value;
2501/// `paragraph_level` is the paragraph embedding level (the same
2502/// value passed to [`resolve_explicit_levels`]).
2503///
2504/// Output is a vector of [`IsolatingRunSequence`] in deterministic
2505/// order (each sequence in the order its first level run begins in
2506/// the paragraph). Every level run in the paragraph belongs to
2507/// exactly one sequence (BD13 note). The implicit phases run
2508/// independently on each sequence (X10 step 3 closing note: "The
2509/// order that one isolating run sequence is treated relative to
2510/// another does not matter.").
2511///
2512/// The X10 step 2 sos / eos derivation uses the **higher** of the
2513/// levels on either side of the sequence boundary, **skipping
2514/// X9-removed characters** when looking outward. At a sequence
2515/// boundary lying at paragraph edge, OR at an isolate initiator
2516/// with no matching PDI (the eos side), the paragraph embedding
2517/// level is used as the other side.
2518///
2519/// Provenance: X10 + BD13 transcribed verbatim from
2520/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` §3.1.2 +
2521/// §3.3.3 (UAX #9 Revision 50, Unicode 16.0).
2522///
2523/// # Examples
2524///
2525/// ```
2526/// use oxideav_scribe::bidi::{
2527///     bidi_class, isolating_run_sequences, level_runs, paragraph_level,
2528///     resolve_explicit_levels, BidiClass,
2529/// };
2530///
2531/// // Simple paragraph with no formatting: "abc" — one sequence,
2532/// // one level run, sos = eos = L (paragraph level 0).
2533/// let s = "abc";
2534/// let cls: Vec<_> = s.chars().map(bidi_class).collect();
2535/// let pl = paragraph_level(s);
2536/// let out = resolve_explicit_levels(&cls, pl);
2537/// let seqs = isolating_run_sequences(&cls, &out, pl);
2538/// assert_eq!(seqs.len(), 1);
2539/// assert_eq!(seqs[0].sos, BidiClass::L);
2540/// assert_eq!(seqs[0].eos, BidiClass::L);
2541/// assert_eq!(seqs[0].level, 0);
2542/// assert_eq!(seqs[0].runs.len(), 1);
2543///
2544/// // Two RLE-bounded level runs chain into separate sequences:
2545/// // L RLE L PDF L — runs [0..1] @ 0, [1..3] @ 1, [3..5] @ 0.
2546/// // No isolate initiators, so each run is its own sequence.
2547/// let cls = vec![
2548///     BidiClass::L,
2549///     BidiClass::RLE,
2550///     BidiClass::L,
2551///     BidiClass::PDF,
2552///     BidiClass::L,
2553/// ];
2554/// let out = resolve_explicit_levels(&cls, 0);
2555/// let seqs = isolating_run_sequences(&cls, &out, 0);
2556/// assert_eq!(seqs.len(), 3);
2557/// // First sequence: level 0, sos L (paragraph), eos L (next run
2558/// // is level 1 > 0, so the higher is 1 → R).
2559/// assert_eq!(seqs[0].level, 0);
2560/// assert_eq!(seqs[0].sos, BidiClass::L);
2561/// assert_eq!(seqs[0].eos, BidiClass::R);
2562/// // Middle sequence: level 1, sos = eos = R.
2563/// assert_eq!(seqs[1].level, 1);
2564/// assert_eq!(seqs[1].sos, BidiClass::R);
2565/// assert_eq!(seqs[1].eos, BidiClass::R);
2566/// // Last sequence: level 0, sos R (higher of 1 vs 0), eos L
2567/// // (paragraph edge).
2568/// assert_eq!(seqs[2].level, 0);
2569/// assert_eq!(seqs[2].sos, BidiClass::R);
2570/// assert_eq!(seqs[2].eos, BidiClass::L);
2571/// ```
2572#[must_use]
2573pub fn isolating_run_sequences(
2574    classes: &[BidiClass],
2575    explicit: &ExplicitLevels,
2576    paragraph_level: u8,
2577) -> Vec<IsolatingRunSequence> {
2578    let runs = level_runs(&explicit.levels);
2579    if runs.is_empty() {
2580        return Vec::new();
2581    }
2582
2583    // For each level run, record the index of the matching-PDI
2584    // level run, if the run ends with an isolate initiator. The
2585    // "level run containing the matching PDI" indirection is BD13
2586    // step 2: "append the level run containing the matching PDI to
2587    // the sequence. (Note that this matching PDI must be the first
2588    // character of its level run.)"
2589    //
2590    // We also record the "first-char is PDI matching some prior
2591    // isolate" flag — runs starting with such a PDI may NOT seed a
2592    // new sequence (BD13 step "For each level run ... whose first
2593    // character is not a PDI, or is a PDI that does not match any
2594    // isolate initiator").
2595
2596    // Map: character index → index of the level run containing it.
2597    let mut run_of_index = vec![0usize; explicit.levels.len()];
2598    for (ri, r) in runs.iter().enumerate() {
2599        for slot in &mut run_of_index[r.start..r.end] {
2600            *slot = ri;
2601        }
2602    }
2603
2604    let nruns = runs.len();
2605    // For each run, the index of the run holding the matching PDI
2606    // (if its last char is an isolate initiator with a matching
2607    // PDI). None otherwise.
2608    let mut next_run: Vec<Option<usize>> = vec![None; nruns];
2609    // For each run, true iff its first char is a PDI that DOES
2610    // match some isolate initiator earlier in the paragraph (i.e.
2611    // the run is chained to from somewhere). Such runs cannot seed
2612    // a sequence; they are only appended to one.
2613    let mut chained_from_prior: Vec<bool> = vec![false; nruns];
2614
2615    for (ri, r) in runs.iter().enumerate() {
2616        // Walk the run's last character (skipping X9-removed
2617        // positions, since BD13 talks about characters and X9
2618        // says removed characters "behave as though [they] were
2619        // not present"). We need the last *non-removed* index.
2620        let mut last_idx: Option<usize> = None;
2621        for i in (r.start..r.end).rev() {
2622            if !explicit.removed[i] {
2623                last_idx = Some(i);
2624                break;
2625            }
2626        }
2627        let Some(last) = last_idx else {
2628            continue;
2629        };
2630        // BD13 chains across isolate-initiator → matching-PDI only.
2631        if matches!(
2632            classes[last],
2633            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI
2634        ) {
2635            if let Some(pdi_idx) = matching_pdi(classes, last) {
2636                let pdi_run = run_of_index[pdi_idx];
2637                // BD13's parenthetical note: "this matching PDI
2638                // must be the first character of its level run".
2639                // Confirm — if not, the chain breaks and we skip.
2640                if runs[pdi_run].start == pdi_idx {
2641                    next_run[ri] = Some(pdi_run);
2642                    chained_from_prior[pdi_run] = true;
2643                }
2644            }
2645        }
2646    }
2647
2648    // Build the sequences. For each run that may seed a sequence
2649    // (not chained_from_prior), walk next_run links until None.
2650    let mut sequences: Vec<IsolatingRunSequence> = Vec::with_capacity(nruns);
2651    let mut visited: Vec<bool> = vec![false; nruns];
2652    for seed in 0..nruns {
2653        if chained_from_prior[seed] || visited[seed] {
2654            continue;
2655        }
2656        let mut chain: Vec<LevelRun> = Vec::new();
2657        let mut cur = Some(seed);
2658        while let Some(idx) = cur {
2659            if visited[idx] {
2660                // Defensive: BD13 partitions exactly, so we never
2661                // visit a run twice. Break if we hit a cycle.
2662                break;
2663            }
2664            visited[idx] = true;
2665            chain.push(runs[idx]);
2666            cur = next_run[idx];
2667        }
2668        debug_assert!(!chain.is_empty(), "BD13 seed produces non-empty chain");
2669
2670        // X10 step 2: sos uses the *higher* of (level of first
2671        // char in sequence, level of preceding non-removed char in
2672        // paragraph), where "preceding non-removed char" falls back
2673        // to the paragraph embedding level. eos uses the *higher*
2674        // of (level of last char in sequence, level of following
2675        // non-removed char in paragraph), with two fallbacks to
2676        // paragraph level: (a) paragraph edge, (b) last char of
2677        // the last run is an isolate initiator with no matching
2678        // PDI.
2679        let level = chain[0].level;
2680        let first_idx = chain[0].start;
2681        let last_idx = chain.last().unwrap().end - 1;
2682
2683        // Walk backwards from first_idx-1 to skip removed chars.
2684        let mut sos_other = paragraph_level;
2685        for i in (0..first_idx).rev() {
2686            if !explicit.removed[i] {
2687                sos_other = explicit.levels[i];
2688                break;
2689            }
2690        }
2691        let sos_level = sos_other.max(level);
2692
2693        // Determine eos other-side: forward from last_idx+1.
2694        // First, decide whether the eos side is governed by the
2695        // paragraph fallback. Spec triggers: (a) no following
2696        // non-removed char in paragraph, (b) last char of last run
2697        // is an isolate initiator lacking a matching PDI.
2698        let mut eos_fallback = false;
2699        // Find last non-removed char in the last run.
2700        let mut last_run_last_nonremoved: Option<usize> = None;
2701        for i in (chain.last().unwrap().start..chain.last().unwrap().end).rev() {
2702            if !explicit.removed[i] {
2703                last_run_last_nonremoved = Some(i);
2704                break;
2705            }
2706        }
2707        if let Some(j) = last_run_last_nonremoved {
2708            if matches!(classes[j], BidiClass::LRI | BidiClass::RLI | BidiClass::FSI)
2709                && matching_pdi(classes, j).is_none()
2710            {
2711                eos_fallback = true;
2712            }
2713        }
2714        let mut eos_other = paragraph_level;
2715        if !eos_fallback {
2716            let mut found = false;
2717            for i in (last_idx + 1)..explicit.levels.len() {
2718                if !explicit.removed[i] {
2719                    eos_other = explicit.levels[i];
2720                    found = true;
2721                    break;
2722                }
2723            }
2724            if !found {
2725                eos_other = paragraph_level;
2726            }
2727        }
2728        let eos_level = eos_other.max(level);
2729
2730        sequences.push(IsolatingRunSequence {
2731            runs: chain,
2732            level,
2733            sos: level_to_sos_eos(sos_level),
2734            eos: level_to_sos_eos(eos_level),
2735        });
2736    }
2737
2738    sequences
2739}
2740
2741/// Result of the whole-paragraph UAX #9 §3 pipeline produced by
2742/// [`process_paragraph_classes`] and [`process_paragraph`].
2743///
2744/// The struct carries every artefact a line-breaking + line-reorder
2745/// caller needs to drive **L1** ([`reset_trailing_levels`]) and **L2**
2746/// ([`reorder_line`]) on a per-line slice after the paragraph has been
2747/// broken into display lines.
2748///
2749/// All vectors are paragraph-wide and parallel — `classes[i]`,
2750/// `effective_classes[i]`, `levels[i]`, and `removed[i]` all describe
2751/// the same paragraph character at logical index `i`. For [`process_paragraph`]
2752/// callers, `char_byte_offsets[i]` is the byte offset of that character
2753/// in the input `&str` (so a `text[char_byte_offsets[i]..]` slice
2754/// starts at the character a level applies to).
2755///
2756/// # Composition order
2757///
2758/// 1. Bidi class assignment (§3.2 / [`bidi_class`]).
2759/// 2. Paragraph embedding level (§3.3.1 P2 + P3 / [`paragraph_level`]),
2760///    unless the caller provides `base_level`.
2761/// 3. Explicit-level / override / isolate stack pass (§3.3.2 X1..X9 /
2762///    [`resolve_explicit_levels`]).
2763/// 4. Isolating-run-sequence partition + sos / eos derivation (§3.3.3
2764///    X10 / [`isolating_run_sequences`]) on top of the BD7 + BD13
2765///    output of [`level_runs`].
2766/// 5. Per-sequence weak-type pass (§3.3.4 W1..W7 /
2767///    [`resolve_weak_types`]).
2768/// 6. Per-sequence neutral-type pass (§3.3.5 N1 + N2 /
2769///    [`resolve_neutral_types`]). The §3.3.5 bracket-pair pass
2770///    (N0) is **not** run by [`process_paragraph_classes`] —
2771///    callers wanting bracket-pair resolution should use
2772///    [`process_paragraph_classes_with_brackets`] /
2773///    [`process_paragraph_with_brackets`], which compose
2774///    [`bracket_pairs`] (BD16) and [`resolve_bracket_pairs`] (N0)
2775///    in between W7 and N1.
2776/// 7. Per-sequence implicit-level pass (§3.3.6 I1 + I2 /
2777///    [`resolve_implicit_levels`]).
2778///
2779/// The L1 / L2 line passes are **not** run here because they operate
2780/// per display line (after a higher-level line-breaker has decided
2781/// where the paragraph splits into lines). The carrier exposes
2782/// [`ParagraphBidi::reorder_line_range`] as a convenience for callers
2783/// that have not yet wired a line-breaker and want to treat the
2784/// whole paragraph as one line.
2785#[derive(Debug, Clone, PartialEq, Eq)]
2786pub struct ParagraphBidi {
2787    /// The paragraph embedding level resolved by §3.3.1 P2 / P3 (or
2788    /// supplied by the caller).
2789    pub paragraph_level: u8,
2790    /// Original per-character bidi class. This is the slice
2791    /// [`reset_trailing_levels`] consumes per the §3.4 "original
2792    /// types" normative note when L1 runs.
2793    pub classes: Vec<BidiClass>,
2794    /// Per-character bidi class after X4 / X5 / X5a / X5b / X6 / X6a
2795    /// override rewriting from X1..X9. Use this if you want to inspect
2796    /// the post-override classes; W / N / I have already consumed it
2797    /// to produce `levels`.
2798    pub effective_classes: Vec<BidiClass>,
2799    /// X9-removed flag set. `removed[i] == true` for `RLE` / `LRE` /
2800    /// `RLO` / `LRO` / `PDF` / `BN`. Isolate-formatting characters
2801    /// `LRI` / `RLI` / `FSI` / `PDI` are **not** flagged per the
2802    /// §3.3.2 X9 note.
2803    pub removed: Vec<bool>,
2804    /// Per-character resolved embedding level after the full
2805    /// X → W → N → I sweep. Index `i` carries the I-rule output level
2806    /// for the `i`th paragraph character (the same indexing as
2807    /// `classes`). X9-removed positions carry their containing level
2808    /// run's level (the X-rule output value), since W / N / I skipped
2809    /// them per X9's "behave as though the characters were not
2810    /// present" clause.
2811    pub levels: Vec<u8>,
2812}
2813
2814impl ParagraphBidi {
2815    /// Run L1 + L2 across the whole paragraph and return the
2816    /// logical-to-visual permutation.
2817    ///
2818    /// This is the "no line-breaker" convenience path: the caller
2819    /// treats the entire paragraph as a single display line. Real
2820    /// callers that wrap the paragraph across N visual lines should
2821    /// instead call [`reset_trailing_levels`] + [`reorder_line`]
2822    /// per-line on the appropriate slice of `classes` + `levels`.
2823    ///
2824    /// Returns a `Vec<usize>` of the same length as `levels`, where
2825    /// the `k`th entry is the logical index of the character that
2826    /// belongs at visual position `k`.
2827    #[must_use]
2828    pub fn reorder_paragraph(&self) -> Vec<usize> {
2829        let mut levels = self.levels.clone();
2830        reset_trailing_levels(&self.classes, &mut levels, self.paragraph_level);
2831        reorder_line(&levels)
2832    }
2833
2834    /// Run L1 + L2 over `line` (a half-open `[start, end)` range into
2835    /// the paragraph's `classes` / `levels` vectors) and return the
2836    /// per-line logical-to-visual permutation.
2837    ///
2838    /// The returned permutation is **relative to the line** — entry
2839    /// `k` is the index *within the line slice* that belongs at
2840    /// visual position `k`. Add `line.start` to map back into the
2841    /// paragraph.
2842    ///
2843    /// # Panics
2844    ///
2845    /// Panics if `line.start > line.end` or `line.end > self.levels.len()`.
2846    #[must_use]
2847    pub fn reorder_line_range(&self, line: core::ops::Range<usize>) -> Vec<usize> {
2848        assert!(
2849            line.start <= line.end && line.end <= self.levels.len(),
2850            "reorder_line_range: line range {line:?} out of bounds for {} chars",
2851            self.levels.len(),
2852        );
2853        let cls = &self.classes[line.clone()];
2854        let mut lvl = self.levels[line].to_vec();
2855        reset_trailing_levels(cls, &mut lvl, self.paragraph_level);
2856        reorder_line(&lvl)
2857    }
2858}
2859
2860/// Run the whole UAX #9 §3 paragraph pipeline (P → X → W → N → I) on
2861/// the supplied per-character class slice.
2862///
2863/// `base_level` lets the caller override the §3.3.1 P2 / P3 paragraph
2864/// embedding level (HL1: higher-level protocol decided the base
2865/// direction). When `None` is passed, the paragraph level is resolved
2866/// from the supplied class slice via the same first-strong walk
2867/// [`paragraph_level`] performs on text, but operating on the class
2868/// slice directly (skipping isolate spans LRI / RLI / FSI .. PDI
2869/// per BD8).
2870///
2871/// All four phases beyond X10 (W1..W7, N1 + N2, I1 + I2) run **per
2872/// isolating run sequence** per the X10 step 3 closing note ("the
2873/// order that one isolating run sequence is treated relative to
2874/// another does not matter"). Each per-sequence pass consumes the
2875/// `sos` / `eos` directional types derived from the higher-of-two-
2876/// levels rule in X10 step 2 (already attached on each
2877/// [`IsolatingRunSequence`] by [`isolating_run_sequences`]).
2878///
2879/// Returns the full [`ParagraphBidi`] carrier; see its docs for the
2880/// per-field semantics. L1 / L2 are not run here — callers either
2881/// invoke [`ParagraphBidi::reorder_paragraph`] (treat the paragraph
2882/// as one line) or run [`reset_trailing_levels`] + [`reorder_line`]
2883/// per display line themselves.
2884///
2885/// Provenance: §3 driver composed from the §3.3.1 / §3.3.2 / §3.3.3 /
2886/// §3.3.4 / §3.3.5 / §3.3.6 entry points already in this module,
2887/// each of which cites
2888/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` directly.
2889///
2890/// # Examples
2891///
2892/// ```
2893/// use oxideav_scribe::bidi::{bidi_class, process_paragraph_classes, BidiClass};
2894///
2895/// // "Hello" — all L, paragraph level 0, every character at level 0.
2896/// let cls: Vec<_> = "Hello".chars().map(bidi_class).collect();
2897/// let p = process_paragraph_classes(&cls, None);
2898/// assert_eq!(p.paragraph_level, 0);
2899/// assert_eq!(p.levels, vec![0; 5]);
2900/// ```
2901#[must_use]
2902pub fn process_paragraph_classes(classes: &[BidiClass], base_level: Option<u8>) -> ParagraphBidi {
2903    let pl = base_level
2904        .map(|l| l & 1)
2905        .unwrap_or_else(|| paragraph_level_from_classes(classes));
2906
2907    // X1..X9: per-character embedding levels + override-rewritten
2908    // classes + X9 removed flags. Caller-fed `classes` is preserved
2909    // verbatim in the returned carrier (L1 needs the original types
2910    // per §3.4 note).
2911    let explicit = resolve_explicit_levels(classes, pl);
2912
2913    // levels starts as a clone of the X-rule output. The W / N / I
2914    // sweep below overwrites every non-X9-removed position with its
2915    // I-rule resolved level. X9-removed positions stay at the X-rule
2916    // level (W / N / I skipped them) — L1 / L2 see them unchanged.
2917    let mut levels = explicit.levels.clone();
2918
2919    let sequences = isolating_run_sequences(classes, &explicit, pl);
2920    for seq in &sequences {
2921        // Materialise the per-sequence effective class slice + the
2922        // logical-index mapping (X9 skips removed positions).
2923        let seq_indices: Vec<usize> = seq.indices(&explicit.removed).collect();
2924        if seq_indices.is_empty() {
2925            continue;
2926        }
2927        let mut seq_classes: Vec<BidiClass> = seq_indices
2928            .iter()
2929            .map(|&i| explicit.effective_classes[i])
2930            .collect();
2931
2932        // W1..W7 in place over the per-sequence class buffer.
2933        resolve_weak_types(&mut seq_classes, seq.sos, seq.eos);
2934        // N1 + N2 in place over the same buffer.
2935        resolve_neutral_types(&mut seq_classes, seq.level, seq.sos, seq.eos);
2936        // I1 + I2 reads classes and emits levels. The output has the
2937        // same length as `seq_classes`.
2938        let seq_levels = resolve_implicit_levels(&seq_classes, seq.level);
2939        // Scatter resolved levels back to the paragraph-wide vector.
2940        for (offset, &paragraph_idx) in seq_indices.iter().enumerate() {
2941            levels[paragraph_idx] = seq_levels[offset];
2942        }
2943    }
2944
2945    ParagraphBidi {
2946        paragraph_level: pl,
2947        classes: classes.to_vec(),
2948        effective_classes: explicit.effective_classes,
2949        removed: explicit.removed,
2950        levels,
2951    }
2952}
2953
2954/// First-strong paragraph-level walk over a pre-classified
2955/// [`BidiClass`] slice, per UAX #9 §3.3.1 **P2 + P3**, with **BD8**
2956/// isolate-span skipping.
2957///
2958/// Mirrors what [`paragraph_level`] does on a `&str`, but consumes
2959/// the class slice directly so [`process_paragraph_classes`] does not
2960/// need to round-trip back to text. Returns `0` (LTR) if the first
2961/// strong character is `L`, `1` (RTL) if it is `R` or `AL`, or `0`
2962/// per **P3** when the paragraph contains no strong character.
2963fn paragraph_level_from_classes(classes: &[BidiClass]) -> u8 {
2964    let n = classes.len();
2965    let mut i = 0;
2966    while i < n {
2967        match classes[i] {
2968            BidiClass::L => return 0,
2969            BidiClass::R | BidiClass::AL => return 1,
2970            BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => {
2971                // BD8: skip the isolate span when searching for the
2972                // first strong character. The matching PDI also
2973                // counts as the close of the span.
2974                if let Some(pdi) = matching_pdi(classes, i) {
2975                    i = pdi + 1;
2976                } else {
2977                    // No matching PDI: the spec says skip to end of
2978                    // paragraph, i.e. nothing after the isolate
2979                    // initiator contributes a strong type for P2.
2980                    return 0;
2981                }
2982            }
2983            _ => i += 1,
2984        }
2985    }
2986    0
2987}
2988
2989/// Run the whole UAX #9 §3 paragraph pipeline on a `&str` and
2990/// return the [`ParagraphBidi`] carrier alongside a parallel vector
2991/// of byte offsets locating each character in the input.
2992///
2993/// `text` must be a single paragraph — callers handle the §3.3.1 P1
2994/// paragraph split via [`split_paragraphs`] first. `base_level` is
2995/// the HL1 override per [`process_paragraph_classes`] semantics.
2996///
2997/// Returns `(carrier, char_byte_offsets)` where
2998/// `char_byte_offsets[i]` is the byte index of the `i`th character
2999/// in `text` (`text.char_indices()`'s first projection). The
3000/// `carrier.classes` / `carrier.levels` / etc. are all character-
3001/// indexed slices of the same length, so `carrier.levels[i]` carries
3002/// the level of the character starting at `text[char_byte_offsets[i]]`.
3003///
3004/// Provenance: same as [`process_paragraph_classes`] — composed from
3005/// the per-rule entry points already in this module.
3006#[must_use]
3007pub fn process_paragraph(text: &str, base_level: Option<u8>) -> (ParagraphBidi, Vec<usize>) {
3008    let mut char_byte_offsets = Vec::new();
3009    let mut classes = Vec::new();
3010    for (i, c) in text.char_indices() {
3011        char_byte_offsets.push(i);
3012        classes.push(bidi_class(c));
3013    }
3014    let carrier = process_paragraph_classes(&classes, base_level);
3015    (carrier, char_byte_offsets)
3016}
3017
3018/// Run the whole UAX #9 §3 paragraph pipeline (P → X → W → **N0** →
3019/// N1 / N2 → I) on the supplied per-character class slice, with the
3020/// **N0 bracket-pair pass** wired in.
3021///
3022/// Mirrors [`process_paragraph_classes`] in every respect except it
3023/// also runs **N0** per §3.3.5 between W7 and N1: each isolating run
3024/// sequence first identifies its bracket pairs via [`bracket_pairs`]
3025/// (BD16 over the per-sequence char slice), then folds them with
3026/// [`resolve_bracket_pairs`] (N0 a / b / c / d) before N1 + N2 see
3027/// the buffer. `chars` is the parallel character slice — `chars[i]`
3028/// is the literal `char` at paragraph index `i` whose class is
3029/// `classes[i]`. The two slices must have equal length; the
3030/// function asserts the invariant.
3031///
3032/// For any caller that already holds the input text, the
3033/// `&str`-based [`process_paragraph_with_brackets`] convenience
3034/// performs the `char_indices` walk and then delegates here.
3035///
3036/// Provenance: §3 driver composed from the §3.3.1 / §3.3.2 / §3.3.3 /
3037/// §3.3.4 / **§3.3.5 N0** / §3.3.5 N1+N2 / §3.3.6 entry points
3038/// already in this module, each of which cites
3039/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` directly.
3040///
3041/// # Examples
3042///
3043/// ```
3044/// use oxideav_scribe::bidi::{
3045///     bidi_class, process_paragraph_classes, process_paragraph_classes_with_brackets,
3046/// };
3047///
3048/// // Bracket pair around an L glyph in an LTR paragraph: N0 b fires,
3049/// // both brackets resolve to L, so all five characters end up at
3050/// // level 0. (Without N0, the parens are ON, then N1 collapses them
3051/// // to L per the L↔L surround — same end result here, but the path
3052/// // through the algorithm is different.)
3053/// let text = "a(b)c";
3054/// let chars: Vec<char> = text.chars().collect();
3055/// let cls: Vec<_> = chars.iter().copied().map(bidi_class).collect();
3056/// let p = process_paragraph_classes_with_brackets(&cls, &chars, None);
3057/// assert_eq!(p.paragraph_level, 0);
3058/// assert_eq!(p.levels, vec![0; 5]);
3059/// ```
3060#[must_use]
3061pub fn process_paragraph_classes_with_brackets(
3062    classes: &[BidiClass],
3063    chars: &[char],
3064    base_level: Option<u8>,
3065) -> ParagraphBidi {
3066    assert_eq!(
3067        classes.len(),
3068        chars.len(),
3069        "process_paragraph_classes_with_brackets: classes / chars length mismatch ({} != {})",
3070        classes.len(),
3071        chars.len(),
3072    );
3073
3074    let pl = base_level
3075        .map(|l| l & 1)
3076        .unwrap_or_else(|| paragraph_level_from_classes(classes));
3077
3078    let explicit = resolve_explicit_levels(classes, pl);
3079    let mut levels = explicit.levels.clone();
3080
3081    let sequences = isolating_run_sequences(classes, &explicit, pl);
3082    for seq in &sequences {
3083        let seq_indices: Vec<usize> = seq.indices(&explicit.removed).collect();
3084        if seq_indices.is_empty() {
3085            continue;
3086        }
3087        let mut seq_classes: Vec<BidiClass> = seq_indices
3088            .iter()
3089            .map(|&i| explicit.effective_classes[i])
3090            .collect();
3091        let seq_chars: Vec<char> = seq_indices.iter().map(|&i| chars[i]).collect();
3092
3093        // W1..W7 in place.
3094        resolve_weak_types(&mut seq_classes, seq.sos, seq.eos);
3095        // N0 — BD16 walk over the post-W7 class slice (BD14 / BD15
3096        // require the *current* class to be ON; post-W7 the only
3097        // surviving ON contributors are the brackets themselves and
3098        // the W6-neutralised separators).
3099        let pairs = bracket_pairs(&seq_chars, &seq_classes);
3100        resolve_bracket_pairs(&mut seq_classes, &pairs, seq.level, seq.sos);
3101        // N1 + N2 in place.
3102        resolve_neutral_types(&mut seq_classes, seq.level, seq.sos, seq.eos);
3103        // I1 + I2 reads classes and emits levels.
3104        let seq_levels = resolve_implicit_levels(&seq_classes, seq.level);
3105        for (offset, &paragraph_idx) in seq_indices.iter().enumerate() {
3106            levels[paragraph_idx] = seq_levels[offset];
3107        }
3108    }
3109
3110    ParagraphBidi {
3111        paragraph_level: pl,
3112        classes: classes.to_vec(),
3113        effective_classes: explicit.effective_classes,
3114        removed: explicit.removed,
3115        levels,
3116    }
3117}
3118
3119/// Run the whole UAX #9 §3 paragraph pipeline **with N0 bracket-pair
3120/// resolution** on a `&str` and return the [`ParagraphBidi`] carrier
3121/// alongside a parallel vector of byte offsets locating each
3122/// character in the input.
3123///
3124/// `text` must be a single paragraph — callers handle the §3.3.1 P1
3125/// paragraph split via [`split_paragraphs`] first. `base_level` is
3126/// the HL1 override per [`process_paragraph_classes_with_brackets`]
3127/// semantics.
3128///
3129/// The function differs from [`process_paragraph`] only in that it
3130/// runs **N0** between W7 and N1 per UAX #9 §3.3.5, using
3131/// [`paired_bracket`]'s ASCII bracket lookup. For tightly-controlled
3132/// text that does not contain RTL strong types intermixed with
3133/// neutrals or bracket-spanning runs, the two functions produce
3134/// identical output; the divergence matters for the
3135/// `RTL ( LTR-content ) RTL` / `LTR ( RTL-content ) LTR` shapes
3136/// where N0's "established context" walk picks a different strong
3137/// direction for the brackets than N1 / N2 would on their own.
3138///
3139/// Returns `(carrier, char_byte_offsets)` where
3140/// `char_byte_offsets[i]` is the byte index of the `i`th character
3141/// in `text`.
3142#[must_use]
3143pub fn process_paragraph_with_brackets(
3144    text: &str,
3145    base_level: Option<u8>,
3146) -> (ParagraphBidi, Vec<usize>) {
3147    let mut char_byte_offsets = Vec::new();
3148    let mut chars = Vec::new();
3149    let mut classes = Vec::new();
3150    for (i, c) in text.char_indices() {
3151        char_byte_offsets.push(i);
3152        chars.push(c);
3153        classes.push(bidi_class(c));
3154    }
3155    let carrier = process_paragraph_classes_with_brackets(&classes, &chars, base_level);
3156    (carrier, char_byte_offsets)
3157}
3158
3159/// Per-paragraph carrier inside a multi-paragraph [`TextBidi`] result.
3160///
3161/// Wraps a [`ParagraphBidi`] (the §3 P → X → W → N → I output for one
3162/// paragraph) with the bookkeeping that locates the paragraph back in
3163/// the original input — the byte range it occupies in the input
3164/// `&str` (`byte_range`) and the cumulative character offset at which
3165/// the paragraph starts within the whole-text logical sequence
3166/// (`char_offset`).
3167///
3168/// The paragraph slice referenced by `byte_range` is the verbatim
3169/// substring P1 produced: a paragraph separator (type B — CR, LF, CRLF,
3170/// FF, NEL, LS, PS) is *kept with the preceding paragraph* per UAX #9
3171/// §3.3.1 P1, so the terminating B character (if any) is included in
3172/// both `byte_range` and the paragraph's [`ParagraphBidi::classes`] /
3173/// `levels` slices.
3174///
3175/// `char_byte_offsets[i]` is the byte index of the `i`th character of
3176/// the paragraph **within the original whole-text input** (not within
3177/// the paragraph slice). Subtract `byte_range.start` to get a paragraph-
3178/// local offset. Combined with `char_offset`, this lets callers walk
3179/// from a whole-text logical character index `k` to the carrier and
3180/// position in O(1) once the right paragraph is selected.
3181#[derive(Debug, Clone, PartialEq, Eq)]
3182pub struct ParagraphSlice {
3183    /// Byte range of the paragraph (including its trailing B if any)
3184    /// in the original `&str` fed to [`process_text`]. Half-open
3185    /// `[start, end)`.
3186    pub byte_range: core::ops::Range<usize>,
3187    /// Cumulative character offset at which this paragraph begins in
3188    /// the whole-text logical sequence. `paragraphs[0].char_offset ==
3189    /// 0`; subsequent entries accumulate the prior paragraph's
3190    /// `bidi.levels.len()`.
3191    pub char_offset: usize,
3192    /// The §3 paragraph pipeline output for this paragraph.
3193    pub bidi: ParagraphBidi,
3194    /// Whole-input byte offset of each character in this paragraph
3195    /// (length matches `bidi.classes.len()` / `bidi.levels.len()`).
3196    /// `char_byte_offsets[i]` is the byte index of the `i`th character
3197    /// in the original `&str` fed to [`process_text`].
3198    pub char_byte_offsets: Vec<usize>,
3199}
3200
3201/// Output of the multi-paragraph UAX #9 §3 driver [`process_text`].
3202///
3203/// The §3 P1 step "Split the text into separate paragraphs" treats a
3204/// paragraph separator (BidiClass `B`) as the terminator of the
3205/// preceding paragraph; the algorithm then "applies all the other rules
3206/// of this algorithm" inside each paragraph independently. This carrier
3207/// holds one [`ParagraphSlice`] per such paragraph, in logical order,
3208/// alongside the whole-input character count.
3209///
3210/// The base level applied to each paragraph is the same `base_level`
3211/// argument fed to [`process_text`] — UAX #9 §3.3.1 P2 / P3 run *per
3212/// paragraph* on the paragraph's own first-strong character, but HL1
3213/// (caller override) is paragraph-independent at the public surface
3214/// here: passing `Some(level)` forces every paragraph to that level;
3215/// `None` lets P2 / P3 walk each paragraph.
3216#[derive(Debug, Clone, PartialEq, Eq)]
3217pub struct TextBidi {
3218    /// One entry per paragraph found by P1, in logical order. Empty
3219    /// iff the input is empty (an empty string has no paragraphs).
3220    pub paragraphs: Vec<ParagraphSlice>,
3221    /// Total character count across all paragraphs. Equals the sum of
3222    /// `paragraphs[i].bidi.levels.len()` and `text.chars().count()`
3223    /// for the input `text` fed to [`process_text`].
3224    pub total_chars: usize,
3225}
3226
3227impl TextBidi {
3228    /// Number of paragraphs P1 produced. Zero iff the input was empty.
3229    #[must_use]
3230    pub fn len(&self) -> usize {
3231        self.paragraphs.len()
3232    }
3233
3234    /// `true` iff [`Self::len`] is zero.
3235    #[must_use]
3236    pub fn is_empty(&self) -> bool {
3237        self.paragraphs.is_empty()
3238    }
3239
3240    /// Locate the paragraph containing the whole-input logical
3241    /// character index `k`. Returns `(paragraph_index,
3242    /// paragraph_local_char_index)` such that
3243    /// `paragraphs[paragraph_index].bidi.classes[paragraph_local_char_index]`
3244    /// is the §3.4-input class of the `k`-th whole-input character.
3245    ///
3246    /// Returns `None` if `k >= total_chars`.
3247    #[must_use]
3248    pub fn locate_char(&self, k: usize) -> Option<(usize, usize)> {
3249        // Linear walk; paragraph counts in real text are small so this
3250        // is acceptable. Sorted-by-`char_offset` invariant lets a
3251        // future caller swap in a binary search if profiling shows it.
3252        for (pi, p) in self.paragraphs.iter().enumerate() {
3253            let end = p.char_offset + p.bidi.levels.len();
3254            if k < end {
3255                return Some((pi, k - p.char_offset));
3256            }
3257        }
3258        None
3259    }
3260}
3261
3262/// Run the whole UAX #9 §3 paragraph pipeline (P1 split → per-
3263/// paragraph P → X → W → N → I) across a multi-paragraph `&str`.
3264///
3265/// This is the top-level entry point that callers with whole-document
3266/// text (potentially containing newlines, paragraph separators, form
3267/// feeds, …) reach for. Internally it walks the §3.3.1 P1 step ("Split
3268/// the text into separate paragraphs") via [`split_paragraphs`] —
3269/// trailing paragraph separators (BidiClass `B` — `\u{000A}` LF,
3270/// `\u{000D}` CR, `\u{000C}` FF, `\u{0085}` NEL, `\u{2028}` LS,
3271/// `\u{2029}` PS) are kept with the preceding paragraph per the spec
3272/// — and dispatches each paragraph slice through
3273/// [`process_paragraph_classes`] independently. The per-paragraph
3274/// `char_byte_offsets` are shifted by the paragraph's `byte_range.start`
3275/// so callers get whole-input byte indices, not paragraph-local ones.
3276///
3277/// `base_level` is the [`process_paragraph_classes`] HL1 override and
3278/// applies *uniformly* to every paragraph when `Some(_)` (callers that
3279/// need per-paragraph HL1 overrides loop [`process_paragraph`]
3280/// themselves). The low bit is preserved per HL1.
3281///
3282/// The empty input produces a `TextBidi { paragraphs: vec![],
3283/// total_chars: 0 }` carrier; the spec says nothing applies to an
3284/// empty text.
3285///
3286/// Provenance: §3 P1 + the §3.3.1 / §3.3.2 / §3.3.3 / §3.3.4 / §3.3.5 /
3287/// §3.3.6 per-rule entry points already in this module, each citing
3288/// `docs/text/unicode-bidi/tr9-50-uax9-unicode16.html` directly.
3289///
3290/// # Examples
3291///
3292/// ```
3293/// use oxideav_scribe::bidi::process_text;
3294///
3295/// // Two-paragraph LTR input separated by LF.
3296/// let t = process_text("Hi\nyo", None);
3297/// assert_eq!(t.paragraphs.len(), 2);
3298/// // First paragraph is "Hi\n" (3 chars: H, i, LF kept with paragraph).
3299/// assert_eq!(t.paragraphs[0].bidi.levels.len(), 3);
3300/// assert_eq!(t.paragraphs[1].bidi.levels.len(), 2);
3301/// assert_eq!(t.total_chars, 5);
3302/// ```
3303#[must_use]
3304pub fn process_text(text: &str, base_level: Option<u8>) -> TextBidi {
3305    let slices = split_paragraphs(text);
3306    let mut paragraphs = Vec::with_capacity(slices.len());
3307    let mut total_chars = 0usize;
3308    // `split_paragraphs` returns owned `&str` slices that point into
3309    // `text`; converting back to byte ranges via pointer arithmetic
3310    // would be UB-adjacent. We rederive the range by walking the
3311    // input alongside the slice list — `start` accumulates the
3312    // byte length of every prior paragraph.
3313    let mut byte_start = 0usize;
3314    for slice in slices {
3315        let len = slice.len();
3316        let byte_range = byte_start..byte_start + len;
3317        let mut classes = Vec::new();
3318        let mut char_byte_offsets = Vec::new();
3319        for (off, c) in slice.char_indices() {
3320            // Re-base the paragraph-local byte offset onto the whole
3321            // input so callers can index back into the original `&str`
3322            // without adding `byte_range.start` themselves.
3323            char_byte_offsets.push(byte_start + off);
3324            classes.push(bidi_class(c));
3325        }
3326        let bidi = process_paragraph_classes(&classes, base_level);
3327        let char_offset = total_chars;
3328        total_chars += bidi.levels.len();
3329        paragraphs.push(ParagraphSlice {
3330            byte_range,
3331            char_offset,
3332            bidi,
3333            char_byte_offsets,
3334        });
3335        byte_start += len;
3336    }
3337    TextBidi {
3338        paragraphs,
3339        total_chars,
3340    }
3341}
3342
3343#[cfg(test)]
3344mod tests {
3345    use super::*;
3346
3347    // --- Section 1: explicit-format class coverage --------------
3348
3349    #[test]
3350    fn explicit_format_codepoints_have_canonical_classes() {
3351        // UAX #9 §2.1 LRE/RLE, §2.2 LRO/RLO, §2.3 PDF, §2.4 LRI/RLI/FSI,
3352        // §2.5 PDI, §2.6 LRM/RLM/ALM. Exhaustive over the 12-char set.
3353        assert_eq!(bidi_class('\u{202A}'), BidiClass::LRE);
3354        assert_eq!(bidi_class('\u{202B}'), BidiClass::RLE);
3355        assert_eq!(bidi_class('\u{202C}'), BidiClass::PDF);
3356        assert_eq!(bidi_class('\u{202D}'), BidiClass::LRO);
3357        assert_eq!(bidi_class('\u{202E}'), BidiClass::RLO);
3358        assert_eq!(bidi_class('\u{2066}'), BidiClass::LRI);
3359        assert_eq!(bidi_class('\u{2067}'), BidiClass::RLI);
3360        assert_eq!(bidi_class('\u{2068}'), BidiClass::FSI);
3361        assert_eq!(bidi_class('\u{2069}'), BidiClass::PDI);
3362        // Implicit marks: LRM is L, RLM is R, ALM is AL.
3363        assert_eq!(bidi_class('\u{200E}'), BidiClass::L);
3364        assert_eq!(bidi_class('\u{200F}'), BidiClass::R);
3365        assert_eq!(bidi_class('\u{061C}'), BidiClass::AL);
3366    }
3367
3368    #[test]
3369    fn isolate_initiator_predicate_only_fires_for_three() {
3370        // LRI, RLI, FSI are isolate initiators; PDI is the
3371        // terminator and is NOT an initiator.
3372        assert!(BidiClass::LRI.is_isolate_initiator());
3373        assert!(BidiClass::RLI.is_isolate_initiator());
3374        assert!(BidiClass::FSI.is_isolate_initiator());
3375        assert!(!BidiClass::PDI.is_isolate_initiator());
3376        // Embedding / override initiators are not isolates.
3377        assert!(!BidiClass::LRE.is_isolate_initiator());
3378        assert!(!BidiClass::RLE.is_isolate_initiator());
3379        assert!(!BidiClass::LRO.is_isolate_initiator());
3380        assert!(!BidiClass::RLO.is_isolate_initiator());
3381        // Strong / weak / neutral never count as isolates.
3382        assert!(!BidiClass::L.is_isolate_initiator());
3383        assert!(!BidiClass::R.is_isolate_initiator());
3384        assert!(!BidiClass::AL.is_isolate_initiator());
3385        assert!(!BidiClass::EN.is_isolate_initiator());
3386        assert!(!BidiClass::ON.is_isolate_initiator());
3387    }
3388
3389    #[test]
3390    fn strong_predicate_fires_only_for_l_r_al() {
3391        assert!(BidiClass::L.is_strong());
3392        assert!(BidiClass::R.is_strong());
3393        assert!(BidiClass::AL.is_strong());
3394        // Weak types are not strong.
3395        for c in [
3396            BidiClass::EN,
3397            BidiClass::ES,
3398            BidiClass::ET,
3399            BidiClass::AN,
3400            BidiClass::CS,
3401            BidiClass::NSM,
3402            BidiClass::BN,
3403        ] {
3404            assert!(!c.is_strong(), "{c:?} should not be strong");
3405        }
3406        // Neutral types are not strong.
3407        for c in [BidiClass::B, BidiClass::S, BidiClass::WS, BidiClass::ON] {
3408            assert!(!c.is_strong());
3409        }
3410        // Explicit formatting is not strong.
3411        for c in [
3412            BidiClass::LRE,
3413            BidiClass::LRO,
3414            BidiClass::RLE,
3415            BidiClass::RLO,
3416            BidiClass::PDF,
3417            BidiClass::LRI,
3418            BidiClass::RLI,
3419            BidiClass::FSI,
3420            BidiClass::PDI,
3421        ] {
3422            assert!(!c.is_strong());
3423        }
3424    }
3425
3426    // --- Section 2: ASCII + Latin-1 coverage --------------------
3427
3428    #[test]
3429    fn ascii_classes_match_uax9_table_4() {
3430        // L for ASCII letters.
3431        assert_eq!(bidi_class('A'), BidiClass::L);
3432        assert_eq!(bidi_class('a'), BidiClass::L);
3433        assert_eq!(bidi_class('Z'), BidiClass::L);
3434        assert_eq!(bidi_class('z'), BidiClass::L);
3435        // EN for ASCII digits.
3436        assert_eq!(bidi_class('0'), BidiClass::EN);
3437        assert_eq!(bidi_class('5'), BidiClass::EN);
3438        assert_eq!(bidi_class('9'), BidiClass::EN);
3439        // ES for +, -.
3440        assert_eq!(bidi_class('+'), BidiClass::ES);
3441        assert_eq!(bidi_class('-'), BidiClass::ES);
3442        // CS for : , . /
3443        assert_eq!(bidi_class(','), BidiClass::CS);
3444        assert_eq!(bidi_class('.'), BidiClass::CS);
3445        assert_eq!(bidi_class('/'), BidiClass::CS);
3446        assert_eq!(bidi_class(':'), BidiClass::CS);
3447        // ET for # $ %.
3448        assert_eq!(bidi_class('#'), BidiClass::ET);
3449        assert_eq!(bidi_class('$'), BidiClass::ET);
3450        assert_eq!(bidi_class('%'), BidiClass::ET);
3451        // WS for SPACE; S for TAB; B for LF / CR.
3452        assert_eq!(bidi_class(' '), BidiClass::WS);
3453        assert_eq!(bidi_class('\t'), BidiClass::S);
3454        assert_eq!(bidi_class('\n'), BidiClass::B);
3455        assert_eq!(bidi_class('\r'), BidiClass::B);
3456        // BN for NUL and most C0 controls.
3457        assert_eq!(bidi_class('\0'), BidiClass::BN);
3458        // ON for printable punctuation we have not categorised (e.g. '!').
3459        // '!' is on the L default path per the conservative fallback.
3460        // The Latin-1 NBSP is CS.
3461        assert_eq!(bidi_class('\u{00A0}'), BidiClass::CS);
3462        // Currency signs are ET.
3463        assert_eq!(bidi_class('\u{00A3}'), BidiClass::ET); // £
3464        assert_eq!(bidi_class('\u{00A5}'), BidiClass::ET); // ¥
3465                                                           // DEGREE SIGN is ET.
3466        assert_eq!(bidi_class('\u{00B0}'), BidiClass::ET);
3467        // SOFT HYPHEN is BN.
3468        assert_eq!(bidi_class('\u{00AD}'), BidiClass::BN);
3469    }
3470
3471    // --- Section 3: Hebrew + Arabic + Syriac coverage -----------
3472
3473    #[test]
3474    fn hebrew_letters_are_r() {
3475        // U+05D0 HEBREW LETTER ALEF, U+05E0 NUN, U+05EA TAV.
3476        assert_eq!(bidi_class('\u{05D0}'), BidiClass::R);
3477        assert_eq!(bidi_class('\u{05E0}'), BidiClass::R);
3478        assert_eq!(bidi_class('\u{05EA}'), BidiClass::R);
3479    }
3480
3481    #[test]
3482    fn arabic_letters_are_al_and_digits_split_en_an() {
3483        // U+0627 ARABIC LETTER ALEF, U+0628 BEH, U+064A YEH.
3484        assert_eq!(bidi_class('\u{0627}'), BidiClass::AL);
3485        assert_eq!(bidi_class('\u{0628}'), BidiClass::AL);
3486        assert_eq!(bidi_class('\u{064A}'), BidiClass::AL);
3487        // U+0660..U+0669 ARABIC-INDIC DIGIT ZERO..NINE = AN.
3488        assert_eq!(bidi_class('\u{0660}'), BidiClass::AN);
3489        assert_eq!(bidi_class('\u{0669}'), BidiClass::AN);
3490        // U+06F0..U+06F9 EXTENDED ARABIC-INDIC DIGIT = EN.
3491        assert_eq!(bidi_class('\u{06F0}'), BidiClass::EN);
3492        assert_eq!(bidi_class('\u{06F9}'), BidiClass::EN);
3493        // Arabic NSM (U+064B FATHATAN, U+0651 SHADDA).
3494        assert_eq!(bidi_class('\u{064B}'), BidiClass::NSM);
3495        assert_eq!(bidi_class('\u{0651}'), BidiClass::NSM);
3496        // Tatweel U+0640 stays AL (it has a visible width).
3497        assert_eq!(bidi_class('\u{0640}'), BidiClass::AL);
3498        // Presentation forms.
3499        assert_eq!(bidi_class('\u{FE8E}'), BidiClass::AL); // FINAL ALEF
3500        assert_eq!(bidi_class('\u{FEFC}'), BidiClass::AL); // LAM-ALEF FINAL
3501    }
3502
3503    #[test]
3504    fn combining_diacriticals_are_nsm() {
3505        // U+0301 COMBINING ACUTE ACCENT, U+0308 COMBINING DIAERESIS.
3506        assert_eq!(bidi_class('\u{0301}'), BidiClass::NSM);
3507        assert_eq!(bidi_class('\u{0308}'), BidiClass::NSM);
3508        assert_eq!(bidi_class('\u{036F}'), BidiClass::NSM);
3509    }
3510
3511    // --- Section 4: P1 split_paragraphs ------------------------
3512
3513    #[test]
3514    fn split_paragraphs_keeps_b_with_previous() {
3515        // "Hello\nWorld" → ["Hello\n", "World"] per P1.
3516        let v = split_paragraphs("Hello\nWorld");
3517        assert_eq!(v, vec!["Hello\n", "World"]);
3518        // Trailing B character keeps the empty trailing paragraph
3519        // suppressed because start == text.len() after the push.
3520        let v = split_paragraphs("Hi\n");
3521        assert_eq!(v, vec!["Hi\n"]);
3522        // Two adjacent B characters yield an empty middle paragraph
3523        // (the inner "\n" by itself).
3524        let v = split_paragraphs("A\n\nB");
3525        assert_eq!(v, vec!["A\n", "\n", "B"]);
3526        // No paragraph separators at all → the whole text.
3527        let v = split_paragraphs("no separators here");
3528        assert_eq!(v, vec!["no separators here"]);
3529        // Empty input → empty vec.
3530        let v = split_paragraphs("");
3531        assert!(v.is_empty());
3532        // U+2029 PARAGRAPH SEPARATOR also splits.
3533        let v = split_paragraphs("a\u{2029}b");
3534        assert_eq!(v, vec!["a\u{2029}", "b"]);
3535    }
3536
3537    // --- Section 5: P2 + P3 paragraph_level --------------------
3538
3539    #[test]
3540    fn paragraph_level_p3_pure_latin_is_zero() {
3541        assert_eq!(paragraph_level("Hello, world!"), 0);
3542        assert_eq!(paragraph_level(""), 0); // empty defaults to 0.
3543        assert_eq!(paragraph_level("   "), 0); // all whitespace → 0.
3544        assert_eq!(paragraph_level("123"), 0); // digits-only → 0 (no strong).
3545    }
3546
3547    #[test]
3548    fn paragraph_level_p3_pure_hebrew_is_one() {
3549        // "שלום" (peace).
3550        assert_eq!(paragraph_level("\u{05E9}\u{05DC}\u{05D5}\u{05DD}"), 1);
3551    }
3552
3553    #[test]
3554    fn paragraph_level_p3_pure_arabic_is_one() {
3555        // "مرحبا" (hello).
3556        assert_eq!(
3557            paragraph_level("\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"),
3558            1
3559        );
3560    }
3561
3562    #[test]
3563    fn paragraph_level_first_strong_after_neutrals_decides() {
3564        // Leading neutrals do not affect P2: the first L gives 0.
3565        assert_eq!(paragraph_level("  \"Hello\""), 0);
3566        // Leading neutrals + first strong = AL → 1.
3567        assert_eq!(paragraph_level("   \u{0627}"), 1);
3568    }
3569
3570    #[test]
3571    fn paragraph_level_p2_skips_isolate_regions() {
3572        // LRI ... PDI region is skipped by P2. Inside the isolate is
3573        // Latin; the only strong character outside it is Hebrew →
3574        // P3 returns 1.
3575        let s = "\u{2066}Hello\u{2069}\u{05D0}";
3576        assert_eq!(paragraph_level(s), 1);
3577        // RLI ... PDI region is skipped. The only strong character
3578        // outside it is Latin → P3 returns 0.
3579        let s = "\u{2067}\u{05D0}\u{2069}Hello";
3580        assert_eq!(paragraph_level(s), 0);
3581        // Nested isolates: LRI (RLI Arabic PDI) PDI then Latin.
3582        // The whole bracketed region is skipped, leaving Latin → 0.
3583        let s = "\u{2066}\u{2067}\u{0627}\u{2069}\u{2069}World";
3584        assert_eq!(paragraph_level(s), 0);
3585        // No matching PDI: the isolate region runs to end of
3586        // paragraph, so no strong character is "visible" outside it
3587        // → P3 default 0.
3588        let s = "\u{2066}\u{05D0}";
3589        assert_eq!(paragraph_level(s), 0);
3590        // FSI is treated like the other initiators by P2.
3591        let s = "\u{2068}\u{05D0}\u{2069}World";
3592        assert_eq!(paragraph_level(s), 0);
3593    }
3594
3595    #[test]
3596    fn paragraph_level_embedding_initiators_do_not_skip() {
3597        // RLE / LRE / LRO / RLO / PDF are NOT skipped by P2 — only
3598        // isolate initiators are. The first strong character is the
3599        // Latin "H" → P3 returns 0.
3600        let s = "\u{202B}\u{05D0}\u{202C}Hello";
3601        assert_eq!(paragraph_level(s), 1); // Hebrew comes first as strong.
3602                                           // Now invert: embedding wraps Latin, then Hebrew. P2 sees
3603                                           // Latin first inside the embedding → 0.
3604        let s = "\u{202B}Hello\u{202C}\u{05D0}";
3605        assert_eq!(paragraph_level(s), 0);
3606    }
3607
3608    #[test]
3609    fn paragraph_level_unmatched_pdi_is_ignored() {
3610        // An unmatched PDI at top level is ignored by P2 — the next
3611        // strong character decides. Here the first strong is Latin.
3612        let s = "\u{2069}Hello";
3613        assert_eq!(paragraph_level(s), 0);
3614    }
3615
3616    // --- Section 6: NI predicate -------------------------------
3617
3618    #[test]
3619    fn neutral_or_isolate_predicate_covers_uax9_ni_alias() {
3620        // NI alias = neutrals (B/S/WS/ON) ∪ isolate-formatting
3621        // (FSI/LRI/RLI/PDI). Every member tests true.
3622        for c in [
3623            BidiClass::B,
3624            BidiClass::S,
3625            BidiClass::WS,
3626            BidiClass::ON,
3627            BidiClass::FSI,
3628            BidiClass::LRI,
3629            BidiClass::RLI,
3630            BidiClass::PDI,
3631        ] {
3632            assert!(c.is_neutral_or_isolate(), "{c:?} should be NI");
3633        }
3634        // Strong / weak / embedding-formatting / PDF are NOT NI.
3635        for c in [
3636            BidiClass::L,
3637            BidiClass::R,
3638            BidiClass::AL,
3639            BidiClass::EN,
3640            BidiClass::ES,
3641            BidiClass::ET,
3642            BidiClass::AN,
3643            BidiClass::CS,
3644            BidiClass::NSM,
3645            BidiClass::BN,
3646            BidiClass::LRE,
3647            BidiClass::LRO,
3648            BidiClass::RLE,
3649            BidiClass::RLO,
3650            BidiClass::PDF,
3651        ] {
3652            assert!(!c.is_neutral_or_isolate(), "{c:?} should not be NI");
3653        }
3654    }
3655
3656    // --- Section 7: W rules (W1..W7) ---------------------------
3657
3658    #[test]
3659    fn w_rules_empty_input_is_noop() {
3660        let mut cls: Vec<BidiClass> = vec![];
3661        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3662        assert!(cls.is_empty());
3663    }
3664
3665    #[test]
3666    fn w1_consecutive_nsm_inherit_first_strongs_type() {
3667        // Spec example: AL NSM NSM → AL AL AL (forward pass; second NSM
3668        // sees the first NSM after rewrite).
3669        let mut cls = vec![BidiClass::AL, BidiClass::NSM, BidiClass::NSM];
3670        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3671        // After W1: AL AL AL. After W3: R R R.
3672        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::R]);
3673    }
3674
3675    #[test]
3676    fn w1_nsm_at_sequence_start_takes_sos_type() {
3677        // Spec example: <sos=R> NSM → <sos> R. Then W3 has no AL to
3678        // collapse, so the NSM stays R.
3679        let mut cls = vec![BidiClass::NSM, BidiClass::L];
3680        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3681        assert_eq!(cls, vec![BidiClass::R, BidiClass::L]);
3682    }
3683
3684    #[test]
3685    fn w1_nsm_after_isolate_initiator_or_pdi_becomes_on() {
3686        // Spec example: LRI NSM → LRI ON; PDI NSM → PDI ON.
3687        let mut cls = vec![BidiClass::LRI, BidiClass::NSM];
3688        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3689        assert_eq!(cls, vec![BidiClass::LRI, BidiClass::ON]);
3690
3691        let mut cls = vec![BidiClass::PDI, BidiClass::NSM];
3692        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3693        assert_eq!(cls, vec![BidiClass::PDI, BidiClass::ON]);
3694
3695        let mut cls = vec![BidiClass::RLI, BidiClass::NSM];
3696        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3697        assert_eq!(cls, vec![BidiClass::RLI, BidiClass::ON]);
3698
3699        let mut cls = vec![BidiClass::FSI, BidiClass::NSM];
3700        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3701        assert_eq!(cls, vec![BidiClass::FSI, BidiClass::ON]);
3702    }
3703
3704    #[test]
3705    fn w2_en_after_al_strong_becomes_an() {
3706        // Spec example: AL EN → AL AN. After W3 the AL collapses to R.
3707        let mut cls = vec![BidiClass::AL, BidiClass::EN];
3708        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3709        assert_eq!(cls, vec![BidiClass::R, BidiClass::AN]);
3710        // AL NI EN → AL NI AN (the NI is ON which doesn't touch the
3711        // last-strong tracker).
3712        let mut cls = vec![BidiClass::AL, BidiClass::ON, BidiClass::EN];
3713        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3714        assert_eq!(cls, vec![BidiClass::R, BidiClass::ON, BidiClass::AN]);
3715    }
3716
3717    #[test]
3718    fn w2_en_with_no_al_predecessor_stays_en() {
3719        // sos=L, no AL → EN stays EN. (W7 may yet flip it to L; see
3720        // dedicated test.)
3721        let mut cls = vec![BidiClass::ON, BidiClass::EN];
3722        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3723        // sos=L → W7 fires: last_strong is L, EN → L.
3724        assert_eq!(cls, vec![BidiClass::ON, BidiClass::L]);
3725        // L NI EN → L NI EN (W2: last strong is L, not AL); after W7
3726        // the EN becomes L.
3727        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::EN];
3728        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3729        assert_eq!(cls, vec![BidiClass::L, BidiClass::ON, BidiClass::L]);
3730        // R NI EN → R NI EN: W7 sees R as last strong, leaves EN alone.
3731        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::EN];
3732        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3733        assert_eq!(cls, vec![BidiClass::R, BidiClass::ON, BidiClass::EN]);
3734    }
3735
3736    #[test]
3737    fn w2_sos_alone_does_not_flip_en() {
3738        // sos NI EN → sos NI EN (W2: sos is not AL).
3739        // With sos=L, W7 then fires → EN becomes L.
3740        let mut cls = vec![BidiClass::ON, BidiClass::EN];
3741        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3742        assert_eq!(cls, vec![BidiClass::ON, BidiClass::L]);
3743        // With sos=R, W7 does not fire → EN stays.
3744        let mut cls = vec![BidiClass::ON, BidiClass::EN];
3745        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3746        assert_eq!(cls, vec![BidiClass::ON, BidiClass::EN]);
3747    }
3748
3749    #[test]
3750    fn w3_all_remaining_al_become_r() {
3751        // Pure AL run → R run.
3752        let mut cls = vec![BidiClass::AL, BidiClass::AL, BidiClass::AL];
3753        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3754        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::R]);
3755    }
3756
3757    #[test]
3758    fn w4_single_es_or_cs_between_two_ens_collapses_to_en() {
3759        // Spec: EN ES EN → EN EN EN.
3760        let mut cls = vec![BidiClass::EN, BidiClass::ES, BidiClass::EN];
3761        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3762        assert_eq!(cls, vec![BidiClass::EN, BidiClass::EN, BidiClass::EN]);
3763        // Spec: EN CS EN → EN EN EN.
3764        let mut cls = vec![BidiClass::EN, BidiClass::CS, BidiClass::EN];
3765        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3766        assert_eq!(cls, vec![BidiClass::EN, BidiClass::EN, BidiClass::EN]);
3767        // Spec: AN CS AN → AN AN AN (CS between same-type AN both
3768        // sides flips).
3769        let mut cls = vec![BidiClass::AN, BidiClass::CS, BidiClass::AN];
3770        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3771        assert_eq!(cls, vec![BidiClass::AN, BidiClass::AN, BidiClass::AN]);
3772    }
3773
3774    #[test]
3775    fn w4_does_not_collapse_mixed_or_multiple_separators() {
3776        // Mixed-type CS (EN CS AN) does NOT collapse (W4 demands same
3777        // type both sides).
3778        let mut cls = vec![BidiClass::EN, BidiClass::CS, BidiClass::AN];
3779        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3780        // CS doesn't match W4, W6 turns it into ON.
3781        assert_eq!(cls, vec![BidiClass::EN, BidiClass::ON, BidiClass::AN]);
3782        // Two consecutive ES are NOT a "single ES" — neither flips.
3783        let mut cls = vec![BidiClass::EN, BidiClass::ES, BidiClass::ES, BidiClass::EN];
3784        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3785        assert_eq!(
3786            cls,
3787            vec![BidiClass::EN, BidiClass::ON, BidiClass::ON, BidiClass::EN]
3788        );
3789        // AN ES AN does NOT collapse — W4 covers CS only for AN, not ES.
3790        let mut cls = vec![BidiClass::AN, BidiClass::ES, BidiClass::AN];
3791        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3792        assert_eq!(cls, vec![BidiClass::AN, BidiClass::ON, BidiClass::AN]);
3793    }
3794
3795    #[test]
3796    fn w5_ets_adjacent_to_en_collapse() {
3797        // Spec: ET ET EN → EN EN EN.
3798        let mut cls = vec![BidiClass::ET, BidiClass::ET, BidiClass::EN];
3799        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3800        assert_eq!(cls, vec![BidiClass::EN, BidiClass::EN, BidiClass::EN]);
3801        // Spec: EN ET ET → EN EN EN.
3802        let mut cls = vec![BidiClass::EN, BidiClass::ET, BidiClass::ET];
3803        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3804        assert_eq!(cls, vec![BidiClass::EN, BidiClass::EN, BidiClass::EN]);
3805        // Spec: AN ET EN → AN EN EN (the ET is adjacent to EN on the
3806        // right side, so it flips; the AN on the left does not push
3807        // anything because AN is not EN).
3808        let mut cls = vec![BidiClass::AN, BidiClass::ET, BidiClass::EN];
3809        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3810        assert_eq!(cls, vec![BidiClass::AN, BidiClass::EN, BidiClass::EN]);
3811    }
3812
3813    #[test]
3814    fn w5_isolated_ets_do_not_collapse() {
3815        // A solitary ET with no EN neighbour stays ET → W6 → ON.
3816        let mut cls = vec![BidiClass::R, BidiClass::ET, BidiClass::R];
3817        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3818        assert_eq!(cls, vec![BidiClass::R, BidiClass::ON, BidiClass::R]);
3819        // ET-only run far from any EN → ON ON.
3820        let mut cls = vec![BidiClass::ET, BidiClass::ET];
3821        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3822        assert_eq!(cls, vec![BidiClass::ON, BidiClass::ON]);
3823    }
3824
3825    #[test]
3826    fn w6_remaining_separators_terminators_become_on() {
3827        // Spec: AN ET → AN ON. (ET adjacent to AN does NOT flip; W5
3828        // is EN-only.)
3829        let mut cls = vec![BidiClass::AN, BidiClass::ET];
3830        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3831        assert_eq!(cls, vec![BidiClass::AN, BidiClass::ON]);
3832        // Spec: L ES EN → L ON EN. ES has no EN on the left, so W4
3833        // doesn't fire; W6 turns it into ON. Then W7 sees L as last
3834        // strong → EN becomes L. Final: L ON L.
3835        let mut cls = vec![BidiClass::L, BidiClass::ES, BidiClass::EN];
3836        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3837        assert_eq!(cls, vec![BidiClass::L, BidiClass::ON, BidiClass::L]);
3838        // Spec: EN CS AN → EN ON AN.
3839        let mut cls = vec![BidiClass::EN, BidiClass::CS, BidiClass::AN];
3840        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3841        assert_eq!(cls, vec![BidiClass::EN, BidiClass::ON, BidiClass::AN]);
3842        // Spec: ET AN → ON AN.
3843        let mut cls = vec![BidiClass::ET, BidiClass::AN];
3844        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3845        assert_eq!(cls, vec![BidiClass::ON, BidiClass::AN]);
3846    }
3847
3848    #[test]
3849    fn w7_en_after_l_becomes_l() {
3850        // Spec: L NI EN → L NI L.
3851        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::EN];
3852        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3853        assert_eq!(cls, vec![BidiClass::L, BidiClass::ON, BidiClass::L]);
3854        // Spec: R NI EN → R NI EN (R as last strong leaves EN alone).
3855        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::EN];
3856        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3857        assert_eq!(cls, vec![BidiClass::R, BidiClass::ON, BidiClass::EN]);
3858    }
3859
3860    #[test]
3861    fn w7_with_sos_l_flips_lone_en() {
3862        // sos=L, no L in the sequence, EN at end → W7 sees sos as L
3863        // and flips EN → L.
3864        let mut cls = vec![BidiClass::EN];
3865        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3866        assert_eq!(cls, vec![BidiClass::L]);
3867        // sos=R, no L in the sequence → EN stays EN.
3868        let mut cls = vec![BidiClass::EN];
3869        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
3870        assert_eq!(cls, vec![BidiClass::EN]);
3871    }
3872
3873    #[test]
3874    fn w_rules_compose_w2_before_w3_before_w7() {
3875        // Critical ordering check: AL EN → after W2 → AL AN → after W3
3876        // → R AN. W7 sees R as last strong (not L), so the AN is NOT
3877        // re-flipped (and W7 only inspects EN anyway). Confirms W2
3878        // fires *before* W3 (otherwise we would lose the AL marker
3879        // and EN would never flip to AN).
3880        let mut cls = vec![BidiClass::AL, BidiClass::EN, BidiClass::EN];
3881        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
3882        assert_eq!(cls, vec![BidiClass::R, BidiClass::AN, BidiClass::AN]);
3883    }
3884
3885    // --- Section 8: N rules (N1 + N2) -------------------------
3886
3887    #[test]
3888    fn n_rules_empty_input_is_noop() {
3889        let mut cls: Vec<BidiClass> = vec![];
3890        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
3891        assert!(cls.is_empty());
3892    }
3893
3894    #[test]
3895    fn n1_l_ni_l_collapses_to_l() {
3896        // Spec example: L NI L → L L L.
3897        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::L];
3898        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
3899        assert_eq!(cls, vec![BidiClass::L, BidiClass::L, BidiClass::L]);
3900    }
3901
3902    #[test]
3903    fn n1_r_ni_r_collapses_to_r() {
3904        // Spec example: R NI R → R R R.
3905        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::R];
3906        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3907        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::R]);
3908    }
3909
3910    #[test]
3911    fn n1_numbers_count_as_r_for_surrounding_check() {
3912        // Spec table — exhaustive R/AN/EN cross-product.
3913        // R NI AN → R R AN.
3914        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::AN];
3915        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3916        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::AN]);
3917        // R NI EN → R R EN.
3918        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::EN];
3919        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3920        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::EN]);
3921        // AN NI R → AN R R.
3922        let mut cls = vec![BidiClass::AN, BidiClass::ON, BidiClass::R];
3923        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3924        assert_eq!(cls, vec![BidiClass::AN, BidiClass::R, BidiClass::R]);
3925        // AN NI AN → AN R AN.
3926        let mut cls = vec![BidiClass::AN, BidiClass::ON, BidiClass::AN];
3927        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3928        assert_eq!(cls, vec![BidiClass::AN, BidiClass::R, BidiClass::AN]);
3929        // AN NI EN → AN R EN.
3930        let mut cls = vec![BidiClass::AN, BidiClass::ON, BidiClass::EN];
3931        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3932        assert_eq!(cls, vec![BidiClass::AN, BidiClass::R, BidiClass::EN]);
3933        // EN NI R → EN R R.
3934        let mut cls = vec![BidiClass::EN, BidiClass::ON, BidiClass::R];
3935        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3936        assert_eq!(cls, vec![BidiClass::EN, BidiClass::R, BidiClass::R]);
3937        // EN NI AN → EN R AN.
3938        let mut cls = vec![BidiClass::EN, BidiClass::ON, BidiClass::AN];
3939        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3940        assert_eq!(cls, vec![BidiClass::EN, BidiClass::R, BidiClass::AN]);
3941        // EN NI EN → EN R EN.
3942        let mut cls = vec![BidiClass::EN, BidiClass::ON, BidiClass::EN];
3943        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3944        assert_eq!(cls, vec![BidiClass::EN, BidiClass::R, BidiClass::EN]);
3945    }
3946
3947    #[test]
3948    fn n2_differing_strong_context_takes_embedding_direction() {
3949        // Spec example footnote: with eos=L sos=R the run "R NI eos"
3950        // resolves NI → e (the embedding direction). Here we shape
3951        // the same with explicit slices.
3952        //
3953        // L NI R, embedding_level 0 → L stays, NI takes embedding
3954        // direction L, R stays. Final: L L R.
3955        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::R];
3956        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::R);
3957        assert_eq!(cls, vec![BidiClass::L, BidiClass::L, BidiClass::R]);
3958        // Same input with embedding_level 1 → NI takes R.
3959        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::R];
3960        resolve_neutral_types(&mut cls, 1, BidiClass::L, BidiClass::R);
3961        assert_eq!(cls, vec![BidiClass::L, BidiClass::R, BidiClass::R]);
3962        // R NI L mirror, embedding_level 1 → NI takes R.
3963        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::L];
3964        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::L);
3965        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::L]);
3966    }
3967
3968    #[test]
3969    fn n_rules_sos_eos_drive_boundary_runs() {
3970        // Spec example footnote: <sos=R> NI L → <sos> R L
3971        // (N1 sees R on left via sos, L on right; mismatch → N2 takes
3972        // embedding direction; here embedding 1 (R) → NI becomes R).
3973        let mut cls = vec![BidiClass::ON, BidiClass::L];
3974        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::L);
3975        // Mismatch — embedding (1 = R) wins.
3976        assert_eq!(cls, vec![BidiClass::R, BidiClass::L]);
3977        // Same with embedding 0 (L): NI becomes L.
3978        let mut cls = vec![BidiClass::ON, BidiClass::L];
3979        resolve_neutral_types(&mut cls, 0, BidiClass::R, BidiClass::L);
3980        assert_eq!(cls, vec![BidiClass::L, BidiClass::L]);
3981        // <sos=L> NI <eos=L>: both sides agree → N1 folds to L.
3982        let mut cls = vec![BidiClass::ON, BidiClass::WS];
3983        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
3984        assert_eq!(cls, vec![BidiClass::L, BidiClass::L]);
3985        // <sos=R> NI <eos=R>: both sides agree → N1 folds to R.
3986        let mut cls = vec![BidiClass::ON, BidiClass::WS];
3987        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
3988        assert_eq!(cls, vec![BidiClass::R, BidiClass::R]);
3989    }
3990
3991    #[test]
3992    fn n_rules_long_ni_run_collapses_uniformly() {
3993        // A run of many NIs of mixed types (B / S / WS / ON / LRI /
3994        // RLI / FSI / PDI) all flip to the resolved direction in one
3995        // pass.
3996        let mut cls = vec![
3997            BidiClass::L,
3998            BidiClass::WS,
3999            BidiClass::ON,
4000            BidiClass::LRI,
4001            BidiClass::PDI,
4002            BidiClass::S,
4003            BidiClass::B,
4004            BidiClass::L,
4005        ];
4006        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
4007        assert_eq!(
4008            cls,
4009            vec![
4010                BidiClass::L,
4011                BidiClass::L,
4012                BidiClass::L,
4013                BidiClass::L,
4014                BidiClass::L,
4015                BidiClass::L,
4016                BidiClass::L,
4017                BidiClass::L,
4018            ]
4019        );
4020    }
4021
4022    #[test]
4023    fn n_rules_leave_nsm_and_bn_alone() {
4024        // NSM and BN are NOT in the NI alias (only the four neutrals
4025        // + four isolate-formatting types are). They must pass
4026        // through unchanged.
4027        let mut cls = vec![BidiClass::L, BidiClass::NSM, BidiClass::BN, BidiClass::L];
4028        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
4029        assert_eq!(
4030            cls,
4031            vec![BidiClass::L, BidiClass::NSM, BidiClass::BN, BidiClass::L,]
4032        );
4033    }
4034
4035    #[test]
4036    fn n_rules_nsm_does_not_terminate_ni_run() {
4037        // An NSM embedded in an NI run participates as a "skip" for
4038        // the strong-search — it is non-strong and non-NI, so the
4039        // strong-search walks past it. With L on both sides (one of
4040        // them past an NSM) the whole NI run still resolves to L via
4041        // N1.
4042        //
4043        // Layout: [L, NSM, ON, ON, L] — the NI run is positions 2..4.
4044        // Left strong: walk back from position 2 → see ON? no
4045        // (position 1 is NSM, position 0 is L). Wait — N1's "strong
4046        // type on either side" only considers strong-direction
4047        // contributors (L / R / EN / AN). NSM is neither. The walk
4048        // skips over it: left strong is L. Right strong is L. → run
4049        // becomes L.
4050        let mut cls = vec![
4051            BidiClass::L,
4052            BidiClass::NSM,
4053            BidiClass::ON,
4054            BidiClass::ON,
4055            BidiClass::L,
4056        ];
4057        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
4058        assert_eq!(
4059            cls,
4060            vec![
4061                BidiClass::L,
4062                BidiClass::NSM,
4063                BidiClass::L,
4064                BidiClass::L,
4065                BidiClass::L,
4066            ]
4067        );
4068    }
4069
4070    #[test]
4071    fn n_rules_multiple_independent_ni_runs() {
4072        // Two NI runs separated by an L. Each run resolves
4073        // independently against its own neighbours.
4074        // Layout: [R, ON, R, ON, ON, L]:
4075        //   Run 1 = [1..2], left=R right=R → R.
4076        //   Run 2 = [3..5], left=R right=L → mismatch → embedding (0
4077        //   = L).
4078        let mut cls = vec![
4079            BidiClass::R,
4080            BidiClass::ON,
4081            BidiClass::R,
4082            BidiClass::ON,
4083            BidiClass::ON,
4084            BidiClass::L,
4085        ];
4086        resolve_neutral_types(&mut cls, 0, BidiClass::R, BidiClass::L);
4087        assert_eq!(
4088            cls,
4089            vec![
4090                BidiClass::R,
4091                BidiClass::R,
4092                BidiClass::R,
4093                BidiClass::L,
4094                BidiClass::L,
4095                BidiClass::L,
4096            ]
4097        );
4098    }
4099
4100    #[test]
4101    fn n_rules_ni_only_sequence_uses_sos_eos() {
4102        // No strong elements anywhere — both endpoints fall back to
4103        // sos / eos. With sos=L eos=L → both agree on L → run → L.
4104        let mut cls = vec![BidiClass::ON, BidiClass::WS, BidiClass::ON];
4105        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
4106        assert_eq!(cls, vec![BidiClass::L, BidiClass::L, BidiClass::L]);
4107        // sos=L eos=R → mismatch → embedding (1 = R) → run → R.
4108        let mut cls = vec![BidiClass::ON, BidiClass::WS, BidiClass::ON];
4109        resolve_neutral_types(&mut cls, 1, BidiClass::L, BidiClass::R);
4110        assert_eq!(cls, vec![BidiClass::R, BidiClass::R, BidiClass::R]);
4111    }
4112
4113    #[test]
4114    fn n_rules_compose_with_w_rules_realistic_run() {
4115        // Realistic full pipeline: start from a paragraph "AL NSM EN
4116        // ET EN CS AN" already used in the W7 composition test, push
4117        // it through both W and N. After W rules: [R R AN ON AN AN
4118        // AN]. Then N: position 3 is ON (the only NI), surrounded by
4119        // AN on both sides (which count as R) → N1 fires → AN
4120        // becomes R. Wait — AN is *not* an NI, and N1 *rewrites* the
4121        // NI itself. Position 3 is the ON; its neighbours are AN-3
4122        // (left: position 2) and AN-4 (right: position 4). AN counts
4123        // as R for the N1 search. left=R, right=R → ON → R.
4124        // Final: [R R AN R AN AN AN].
4125        let mut cls = vec![
4126            BidiClass::AL,
4127            BidiClass::NSM,
4128            BidiClass::EN,
4129            BidiClass::ET,
4130            BidiClass::EN,
4131            BidiClass::CS,
4132            BidiClass::AN,
4133        ];
4134        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
4135        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
4136        assert_eq!(
4137            cls,
4138            vec![
4139                BidiClass::R,
4140                BidiClass::R,
4141                BidiClass::AN,
4142                BidiClass::R,
4143                BidiClass::AN,
4144                BidiClass::AN,
4145                BidiClass::AN,
4146            ]
4147        );
4148    }
4149
4150    // --- Section 6: I1 / I2 implicit-level resolution -----------
4151
4152    #[test]
4153    fn i1_even_level_l_stays_r_goes_up_one() {
4154        // Table 5 row 1 + 2 at even EL: L → EL, R → EL+1.
4155        let cls = vec![BidiClass::L, BidiClass::R, BidiClass::L, BidiClass::R];
4156        assert_eq!(resolve_implicit_levels(&cls, 0), vec![0, 1, 0, 1]);
4157        // Same shape at EL = 2.
4158        assert_eq!(resolve_implicit_levels(&cls, 2), vec![2, 3, 2, 3]);
4159    }
4160
4161    #[test]
4162    fn i1_even_level_an_en_go_up_two() {
4163        // Table 5 row 3 + 4 at even EL: AN / EN → EL+2.
4164        let cls = vec![BidiClass::AN, BidiClass::EN, BidiClass::L, BidiClass::R];
4165        assert_eq!(resolve_implicit_levels(&cls, 0), vec![2, 2, 0, 1]);
4166        assert_eq!(resolve_implicit_levels(&cls, 2), vec![4, 4, 2, 3]);
4167    }
4168
4169    #[test]
4170    fn i2_odd_level_l_en_an_go_up_one_r_stays() {
4171        // Table 5 odd column: L / EN / AN → EL+1; R → EL.
4172        let cls = vec![BidiClass::L, BidiClass::R, BidiClass::EN, BidiClass::AN];
4173        assert_eq!(resolve_implicit_levels(&cls, 1), vec![2, 1, 2, 2]);
4174        // Same shape at EL = 3.
4175        assert_eq!(resolve_implicit_levels(&cls, 3), vec![4, 3, 4, 4]);
4176    }
4177
4178    #[test]
4179    fn implicit_levels_ignore_bn() {
4180        // §5.2 "In rules I1 and I2, ignore BN." A BN inserted between
4181        // L and R should sit at the embedding level, not bump.
4182        let cls = vec![BidiClass::L, BidiClass::BN, BidiClass::R];
4183        assert_eq!(resolve_implicit_levels(&cls, 0), vec![0, 0, 1]);
4184        assert_eq!(resolve_implicit_levels(&cls, 1), vec![2, 1, 1]);
4185    }
4186
4187    #[test]
4188    fn implicit_levels_nsm_stays_at_embedding_level() {
4189        // NSM that survived the N pass (the rare case where W1 itself
4190        // left it as NSM — e.g. an NSM at the very start of a sequence
4191        // whose sos is also NSM-like / non-strong, the spec maps that
4192        // to ON via the §3.3.4 boundary rules and the N pass folds it,
4193        // but defensive behaviour matters here): keep it at the
4194        // embedding level, like BN.
4195        let cls = vec![BidiClass::L, BidiClass::NSM, BidiClass::R];
4196        assert_eq!(resolve_implicit_levels(&cls, 0), vec![0, 0, 1]);
4197    }
4198
4199    #[test]
4200    fn implicit_levels_empty_input_yields_empty_output() {
4201        assert_eq!(resolve_implicit_levels(&[], 0), Vec::<u8>::new());
4202        assert_eq!(resolve_implicit_levels(&[], 1), Vec::<u8>::new());
4203    }
4204
4205    #[test]
4206    fn implicit_levels_compose_after_n_rules() {
4207        // End-to-end: feed a slice through W → N → I and check the
4208        // final level vector. Logical: "L NI L" at EL 0. After N1
4209        // (matching L on both sides), all three are L → all sit at 0.
4210        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::L];
4211        resolve_weak_types(&mut cls, BidiClass::L, BidiClass::L);
4212        resolve_neutral_types(&mut cls, 0, BidiClass::L, BidiClass::L);
4213        let levels = resolve_implicit_levels(&cls, 0);
4214        assert_eq!(levels, vec![0, 0, 0]);
4215    }
4216
4217    #[test]
4218    fn implicit_levels_arabic_with_numbers_realistic() {
4219        // Mixed Arabic + EN at paragraph level 1 (RTL paragraph):
4220        // start with [AL NSM EN ET EN CS AN] (same shape as the
4221        // w_rules_full_pipeline_realistic_run case), push it through
4222        // W + N + I, check that AN / EN positions all end at level 2
4223        // (one above the EL-1 base), while the R positions stay at 1.
4224        let mut cls = vec![
4225            BidiClass::AL,
4226            BidiClass::NSM,
4227            BidiClass::EN,
4228            BidiClass::ET,
4229            BidiClass::EN,
4230            BidiClass::CS,
4231            BidiClass::AN,
4232        ];
4233        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
4234        resolve_neutral_types(&mut cls, 1, BidiClass::R, BidiClass::R);
4235        // After W + N: [R R AN R AN AN AN] (per the realistic-run
4236        // test above). At EL 1: R → 1, AN → 2.
4237        let levels = resolve_implicit_levels(&cls, 1);
4238        assert_eq!(levels, vec![1, 1, 2, 1, 2, 2, 2]);
4239    }
4240
4241    #[test]
4242    fn implicit_levels_max_depth_overflow_is_explicit() {
4243        // The spec note "it is possible for text to end up at level
4244        // max_depth+1 as a result of this process." We don't clamp;
4245        // a caller passing EL near 125 (max_depth) can see levels
4246        // 126 or 127. Test that the arithmetic is straightforward
4247        // (no panic, no clamp). At EL 124 (even): L → 124, R → 125,
4248        // EN → 126.
4249        let cls = vec![BidiClass::L, BidiClass::R, BidiClass::EN];
4250        assert_eq!(resolve_implicit_levels(&cls, 124), vec![124, 125, 126]);
4251        // At EL 125 (odd): L → 126, R → 125, EN → 126.
4252        assert_eq!(resolve_implicit_levels(&cls, 125), vec![126, 125, 126]);
4253    }
4254
4255    #[test]
4256    fn w_rules_full_pipeline_realistic_run() {
4257        // A mock isolating run sequence drawn from a hypothetical
4258        // mixed Arabic + number paragraph: AL NSM EN ET EN CS AN.
4259        // Walk through:
4260        //   W1: NSM after AL → AL.   → [AL AL EN ET EN CS AN]
4261        //   W2: EN after AL strong → AN. The second EN also sees AL
4262        //        as the most recent strong (the AN we just wrote
4263        //        doesn't change last_strong because AN is not strong).
4264        //                              → [AL AL AN ET AN CS AN]
4265        //   W3: ALs → R.              → [R  R  AN ET AN CS AN]
4266        //   W4: CS between two ANs flips → AN. ET is not eligible
4267        //       under W4. (After W2 the prev/next of CS are AN.)
4268        //                              → [R  R  AN ET AN AN AN]
4269        //   W5: ET is NOT adjacent to an EN on either side (the AN
4270        //       on both sides is AN, not EN), so it doesn't flip.
4271        //                              → [R  R  AN ET AN AN AN]
4272        //   W6: lingering ET → ON.    → [R  R  AN ON AN AN AN]
4273        //   W7: only inspects EN; no EN survives.
4274        let mut cls = vec![
4275            BidiClass::AL,
4276            BidiClass::NSM,
4277            BidiClass::EN,
4278            BidiClass::ET,
4279            BidiClass::EN,
4280            BidiClass::CS,
4281            BidiClass::AN,
4282        ];
4283        resolve_weak_types(&mut cls, BidiClass::R, BidiClass::R);
4284        assert_eq!(
4285            cls,
4286            vec![
4287                BidiClass::R,
4288                BidiClass::R,
4289                BidiClass::AN,
4290                BidiClass::ON,
4291                BidiClass::AN,
4292                BidiClass::AN,
4293                BidiClass::AN,
4294            ]
4295        );
4296    }
4297
4298    // --- Section 8: L-rule line-level transformations -----------
4299
4300    #[test]
4301    fn l1_segment_separator_resets_to_paragraph_level() {
4302        // §3.4 case (1): an `S` (tab) inside an RTL paragraph
4303        // resets to paragraph level 1, not whatever the I-rule
4304        // pass left it at.
4305        let cls = vec![BidiClass::R, BidiClass::S, BidiClass::R];
4306        let mut lvl = vec![1, 2, 1];
4307        reset_trailing_levels(&cls, &mut lvl, 1);
4308        assert_eq!(lvl, vec![1, 1, 1]);
4309    }
4310
4311    #[test]
4312    fn l1_paragraph_separator_resets_to_paragraph_level() {
4313        // §3.4 case (2): a `B` at the end of an RTL line resets
4314        // to paragraph level 1.
4315        let cls = vec![BidiClass::R, BidiClass::R, BidiClass::B];
4316        let mut lvl = vec![1, 1, 2];
4317        reset_trailing_levels(&cls, &mut lvl, 1);
4318        assert_eq!(lvl, vec![1, 1, 1]);
4319    }
4320
4321    #[test]
4322    fn l1_whitespace_before_separator_resets() {
4323        // §3.4 case (3): a WS run immediately preceding the
4324        // separator is folded onto the paragraph level too.
4325        let cls = vec![
4326            BidiClass::R,
4327            BidiClass::R,
4328            BidiClass::WS,
4329            BidiClass::WS,
4330            BidiClass::S,
4331        ];
4332        let mut lvl = vec![1, 1, 2, 2, 2];
4333        reset_trailing_levels(&cls, &mut lvl, 1);
4334        assert_eq!(lvl, vec![1, 1, 1, 1, 1]);
4335    }
4336
4337    #[test]
4338    fn l1_isolate_formatting_before_separator_resets() {
4339        // §3.4 case (3): isolate-formatting characters (LRI / RLI
4340        // / FSI / PDI) count alongside WS in the trailing-filler
4341        // set.
4342        let cls = vec![
4343            BidiClass::L,
4344            BidiClass::WS,
4345            BidiClass::PDI,
4346            BidiClass::LRI,
4347            BidiClass::B,
4348        ];
4349        let mut lvl = vec![0, 1, 1, 1, 1];
4350        reset_trailing_levels(&cls, &mut lvl, 0);
4351        assert_eq!(lvl, vec![0, 0, 0, 0, 0]);
4352    }
4353
4354    #[test]
4355    fn l1_trailing_whitespace_at_end_of_line_resets() {
4356        // §3.4 case (4): no separator, but trailing WS still
4357        // resets to paragraph level.
4358        let cls = vec![BidiClass::R, BidiClass::R, BidiClass::WS, BidiClass::WS];
4359        let mut lvl = vec![1, 1, 2, 2];
4360        reset_trailing_levels(&cls, &mut lvl, 1);
4361        assert_eq!(lvl, vec![1, 1, 1, 1]);
4362    }
4363
4364    #[test]
4365    fn l1_leading_whitespace_is_left_alone() {
4366        // §3.4 cases (3) + (4) target trailing fillers only;
4367        // leading WS (without a separator behind it) keeps its
4368        // I-rule level.
4369        let cls = vec![BidiClass::WS, BidiClass::WS, BidiClass::R, BidiClass::R];
4370        let mut lvl = vec![2, 2, 1, 1];
4371        reset_trailing_levels(&cls, &mut lvl, 1);
4372        assert_eq!(lvl, vec![2, 2, 1, 1]);
4373    }
4374
4375    #[test]
4376    fn l1_interior_whitespace_is_left_alone() {
4377        // Whitespace surrounded by strong characters on both
4378        // sides is neither case (3) (no following separator) nor
4379        // case (4) (not at end of line). It keeps its I level.
4380        let cls = vec![BidiClass::R, BidiClass::WS, BidiClass::R];
4381        let mut lvl = vec![1, 2, 1];
4382        reset_trailing_levels(&cls, &mut lvl, 1);
4383        assert_eq!(lvl, vec![1, 2, 1]);
4384    }
4385
4386    #[test]
4387    fn l1_empty_line_is_noop() {
4388        let cls: Vec<BidiClass> = Vec::new();
4389        let mut lvl: Vec<u8> = Vec::new();
4390        reset_trailing_levels(&cls, &mut lvl, 0);
4391        assert!(lvl.is_empty());
4392    }
4393
4394    #[test]
4395    fn l1_uses_original_classes_not_post_w_rules() {
4396        // §3.4 normative note: "The types of characters used here
4397        // are the *original* types, not those modified by the
4398        // previous phase." Here the original is `B` (a paragraph
4399        // separator); a W-rule pass cannot reach `B`, but L1 sees
4400        // it directly through `orig_classes`.
4401        let cls_orig = vec![BidiClass::R, BidiClass::R, BidiClass::B];
4402        let mut lvl = vec![1, 1, 2];
4403        reset_trailing_levels(&cls_orig, &mut lvl, 1);
4404        assert_eq!(lvl, vec![1, 1, 1]);
4405    }
4406
4407    #[test]
4408    fn l1_multiple_separators_each_pull_their_preceding_whitespace() {
4409        // Two `S`s on one line: each resets its preceding WS
4410        // independently.
4411        let cls = vec![
4412            BidiClass::R,
4413            BidiClass::WS,
4414            BidiClass::S,
4415            BidiClass::R,
4416            BidiClass::WS,
4417            BidiClass::S,
4418        ];
4419        let mut lvl = vec![1, 2, 2, 1, 2, 2];
4420        reset_trailing_levels(&cls, &mut lvl, 1);
4421        assert_eq!(lvl, vec![1, 1, 1, 1, 1, 1]);
4422    }
4423
4424    #[test]
4425    #[should_panic(expected = "same length")]
4426    fn l1_length_mismatch_panics() {
4427        let cls = vec![BidiClass::L, BidiClass::L];
4428        let mut lvl = vec![0];
4429        reset_trailing_levels(&cls, &mut lvl, 0);
4430    }
4431
4432    #[test]
4433    fn l2_all_ltr_is_identity() {
4434        assert_eq!(reorder_line(&[0, 0, 0, 0]), vec![0, 1, 2, 3]);
4435    }
4436
4437    #[test]
4438    fn l2_empty_input_is_empty() {
4439        let out = reorder_line(&[]);
4440        assert!(out.is_empty());
4441    }
4442
4443    #[test]
4444    fn l2_all_rtl_is_full_reverse() {
4445        // Whole line at level 1: a single reversal flips it.
4446        assert_eq!(reorder_line(&[1, 1, 1, 1]), vec![3, 2, 1, 0]);
4447    }
4448
4449    #[test]
4450    fn l2_uax9_example_1_car_means_car_dot() {
4451        // §3.4 Example 1: "car means CAR." with resolved levels
4452        // 00000000001110. Only the level-1 run reverses; the
4453        // trailing '.' stays put.
4454        let lv = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0];
4455        let visual = reorder_line(&lv);
4456        assert_eq!(visual, vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 11, 10, 13]);
4457    }
4458
4459    #[test]
4460    fn l2_uax9_example_2_nested_level_1_and_2() {
4461        // §3.4 Example 2: "<car MEANS CAR.=" resolved levels
4462        // 0222111111111110 (16 chars). Pass at level 2 reverses
4463        // the "rac" run (positions 1..4). Pass at level 1 reverses
4464        // positions 1..15.
4465        let lv = [0, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0];
4466        let visual = reorder_line(&lv);
4467        // After level-2 pass: identity except [1, 2, 3] -> [3, 2, 1].
4468        // After level-1 pass: positions 1..15 reverse, so the
4469        // final visual order is:
4470        //   0, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 1, 2, 3, 15
4471        assert_eq!(
4472            visual,
4473            vec![0, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 1, 2, 3, 15]
4474        );
4475    }
4476
4477    #[test]
4478    fn l2_uax9_example_4_rtl_paragraph_deep_nesting() {
4479        // §3.4 Example 4 (embedding level = 1) resolved levels
4480        // 111111111111114222222222444333333333322111 — 42 chars.
4481        let lv = [
4482            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0..13
4483            2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // 14..23
4484            4, 4, 4, // 24..26
4485            3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // 27..36
4486            2, 2, // 37..38
4487            1, 1, 1, // 39..41
4488        ];
4489        // Reproduce the spec's display by stepping the algorithm
4490        // by hand:
4491        //   - level 4 pass: reverse [24..27]   ("rac" -> "car")
4492        //   - level 3 pass: reverse [24..37]   (the bracketed RTL fragment)
4493        //   - level 2 pass: reverse [14..39]   (the LTR-inside-RTL embedding)
4494        //   - level 1 pass: reverse [0..42]    (the whole line)
4495        let visual = reorder_line(&lv);
4496        // After all four reversals, position 0 in visual order is
4497        // logical index 41 (the final paragraph-level-1 char), and
4498        // the algorithm should produce a strictly decreasing
4499        // prefix [41, 40, 39] followed by the inner-embedding
4500        // remap. Spot-check the head + tail:
4501        assert_eq!(visual[0], 41);
4502        assert_eq!(visual[1], 40);
4503        assert_eq!(visual[2], 39);
4504        assert_eq!(visual.last().copied(), Some(0));
4505        // And the permutation must be a valid permutation of 0..42.
4506        let mut sorted = visual.clone();
4507        sorted.sort_unstable();
4508        assert_eq!(sorted, (0..42).collect::<Vec<_>>());
4509    }
4510
4511    #[test]
4512    fn l2_output_is_always_a_permutation() {
4513        // For any level vector the output must hit every index in
4514        // 0..n exactly once. Sweep a small set of mixed shapes.
4515        for lv in [
4516            vec![0u8, 1, 0, 1],
4517            vec![1, 0, 1, 0],
4518            vec![0, 2, 1, 2, 0],
4519            vec![3, 3, 1, 1, 3, 3],
4520            vec![5, 4, 3, 2, 1, 0],
4521            vec![0],
4522            vec![1],
4523            vec![0, 0, 0, 0, 0, 0, 0, 0],
4524        ] {
4525            let n = lv.len();
4526            let visual = reorder_line(&lv);
4527            assert_eq!(visual.len(), n);
4528            let mut sorted = visual.clone();
4529            sorted.sort_unstable();
4530            assert_eq!(sorted, (0..n).collect::<Vec<_>>());
4531        }
4532    }
4533
4534    #[test]
4535    fn l2_reverse_is_idempotent_when_applied_to_uniform_levels() {
4536        // Reordering an already-uniform-level line a second time
4537        // (by re-feeding the same level vector) would flip again —
4538        // the permutation is not its own inverse for n > 2. Sanity
4539        // check that the single application produces the expected
4540        // shape rather than a stable identity by accident.
4541        let lv = [1u8, 1, 1, 1, 1];
4542        let visual = reorder_line(&lv);
4543        assert_eq!(visual, vec![4, 3, 2, 1, 0]);
4544    }
4545
4546    #[test]
4547    fn l1_then_l2_pipeline_trailing_space_in_rtl_paragraph() {
4548        // End-to-end mini-pipeline: L1 anchors trailing WS to the
4549        // paragraph level, then L2 reorders. With RTL text "AB " in
4550        // an RTL paragraph the displayed order should still have
4551        // the space on the visual left edge (paragraph-direction
4552        // tail) of the run.
4553        let cls = vec![BidiClass::R, BidiClass::R, BidiClass::WS];
4554        let mut lvl = vec![1, 1, 2];
4555        reset_trailing_levels(&cls, &mut lvl, 1);
4556        assert_eq!(lvl, vec![1, 1, 1]);
4557        let visual = reorder_line(&lvl);
4558        // All level 1 → full reverse. Visual order: WS, B, A.
4559        assert_eq!(visual, vec![2, 1, 0]);
4560    }
4561
4562    // --- Section 8b: rule L3 (combining-mark reordering) -------
4563
4564    #[test]
4565    fn l3_empty_input_no_op() {
4566        let mut visual: Vec<usize> = Vec::new();
4567        reorder_combining_marks(&[], &[], &mut visual);
4568        assert!(visual.is_empty());
4569    }
4570
4571    #[test]
4572    fn l3_all_ltr_no_op() {
4573        // Even levels everywhere → no L2 reversal happened, marks
4574        // already follow base. L3 is a no-op.
4575        let cls = [BidiClass::L, BidiClass::NSM, BidiClass::NSM];
4576        let lvl = [0, 0, 0];
4577        let mut visual = reorder_line(&lvl);
4578        assert_eq!(visual, vec![0, 1, 2]);
4579        reorder_combining_marks(&cls, &lvl, &mut visual);
4580        assert_eq!(visual, vec![0, 1, 2]);
4581    }
4582
4583    #[test]
4584    fn l3_rtl_base_with_one_mark_swaps_to_base_first() {
4585        // R NSM at level 1: L2 → [1, 0]. L3 → [0, 1].
4586        let cls = [BidiClass::R, BidiClass::NSM];
4587        let lvl = [1, 1];
4588        let mut visual = reorder_line(&lvl);
4589        assert_eq!(visual, vec![1, 0]);
4590        reorder_combining_marks(&cls, &lvl, &mut visual);
4591        assert_eq!(visual, vec![0, 1]);
4592    }
4593
4594    #[test]
4595    fn l3_rtl_base_with_multiple_marks_preserves_mark_order() {
4596        // R NSM NSM NSM at level 1: L2 → [3, 2, 1, 0]. L3 rotates
4597        // the base into the front and preserves logical order
4598        // among the marks.
4599        let cls = [BidiClass::R, BidiClass::NSM, BidiClass::NSM, BidiClass::NSM];
4600        let lvl = [1, 1, 1, 1];
4601        let mut visual = reorder_line(&lvl);
4602        assert_eq!(visual, vec![3, 2, 1, 0]);
4603        reorder_combining_marks(&cls, &lvl, &mut visual);
4604        // Base 0 first; marks then in original logical order 1, 2, 3.
4605        assert_eq!(visual, vec![0, 1, 2, 3]);
4606    }
4607
4608    #[test]
4609    fn l3_rtl_run_inside_ltr_paragraph() {
4610        // Logical: L L R NSM L L (level 0 0 1 1 0 0).
4611        // L2 reverses the single level-1 run [R, NSM] to [NSM, R].
4612        // L3 rotates back so the base precedes its mark in visual.
4613        let cls = [
4614            BidiClass::L,
4615            BidiClass::L,
4616            BidiClass::R,
4617            BidiClass::NSM,
4618            BidiClass::L,
4619            BidiClass::L,
4620        ];
4621        let lvl = [0, 0, 1, 1, 0, 0];
4622        let mut visual = reorder_line(&lvl);
4623        assert_eq!(visual, vec![0, 1, 3, 2, 4, 5]);
4624        reorder_combining_marks(&cls, &lvl, &mut visual);
4625        assert_eq!(visual, vec![0, 1, 2, 3, 4, 5]);
4626    }
4627
4628    #[test]
4629    fn l3_multiple_rtl_clusters_each_rotate_independently() {
4630        // R NSM R NSM at level 1: L2 reverses to [3, 2, 1, 0].
4631        // L3 sees in visual order: pos0=NSM(3), pos1=base(2),
4632        // pos2=NSM(1), pos3=base(0). Two clusters; rotate each.
4633        let cls = [BidiClass::R, BidiClass::NSM, BidiClass::R, BidiClass::NSM];
4634        let lvl = [1, 1, 1, 1];
4635        let mut visual = reorder_line(&lvl);
4636        assert_eq!(visual, vec![3, 2, 1, 0]);
4637        reorder_combining_marks(&cls, &lvl, &mut visual);
4638        // Each [NSM, base] block becomes [base, NSM]. Result is
4639        // [base2, nsm3, base0, nsm1] → [2, 3, 0, 1].
4640        assert_eq!(visual, vec![2, 3, 0, 1]);
4641    }
4642
4643    #[test]
4644    fn l3_leading_orphan_nsm_in_rtl_run_is_left_alone() {
4645        // Pathological: an NSM with no preceding base in its run.
4646        // Logical: NSM NSM at level 1. L2 → [1, 0]. L3 finds no
4647        // following base, so it leaves the visual order untouched.
4648        let cls = [BidiClass::NSM, BidiClass::NSM];
4649        let lvl = [1, 1];
4650        let mut visual = reorder_line(&lvl);
4651        assert_eq!(visual, vec![1, 0]);
4652        reorder_combining_marks(&cls, &lvl, &mut visual);
4653        assert_eq!(visual, vec![1, 0]);
4654    }
4655
4656    #[test]
4657    fn l3_idempotent_when_applied_twice() {
4658        // Applying L3 a second time on the same visual must yield
4659        // the same vector (the marks are already after their base
4660        // so no rotation has anything to do).
4661        let cls = [BidiClass::R, BidiClass::NSM, BidiClass::NSM];
4662        let lvl = [1, 1, 1];
4663        let mut visual = reorder_line(&lvl);
4664        reorder_combining_marks(&cls, &lvl, &mut visual);
4665        let once = visual.clone();
4666        reorder_combining_marks(&cls, &lvl, &mut visual);
4667        assert_eq!(visual, once);
4668    }
4669
4670    #[test]
4671    fn l3_mixed_level_nsm_left_alone() {
4672        // NSM whose level differs from its surrounding RTL base
4673        // (an unusual post-W1 leftover) is conservatively skipped.
4674        // Logical R(1) NSM(0) — the NSM ended up level 0. L2's
4675        // sequence at level 1 is just position 0, reversed = [0].
4676        // Then level-1+ run [0] does not include position 1.
4677        let cls = [BidiClass::R, BidiClass::NSM];
4678        let lvl = [1, 0];
4679        let mut visual = reorder_line(&lvl);
4680        // Single position-0 level-1 run reversed = identity.
4681        assert_eq!(visual, vec![0, 1]);
4682        reorder_combining_marks(&cls, &lvl, &mut visual);
4683        // NSM at level 0 (even) is not in scope of L3.
4684        assert_eq!(visual, vec![0, 1]);
4685    }
4686
4687    // --- Section 9: X-rules (X1..X9) ---------------------------
4688
4689    #[test]
4690    fn x_rules_empty_paragraph() {
4691        let out = resolve_explicit_levels(&[], 0);
4692        assert!(out.levels.is_empty());
4693        assert!(out.effective_classes.is_empty());
4694        assert!(out.removed.is_empty());
4695    }
4696
4697    #[test]
4698    fn x_rules_plain_latin_stays_level_zero() {
4699        // Latin paragraph "ab" at paragraph level 0 — every char
4700        // gets level 0, no override, no removal.
4701        let cls = vec![BidiClass::L, BidiClass::L];
4702        let out = resolve_explicit_levels(&cls, 0);
4703        assert_eq!(out.levels, vec![0, 0]);
4704        assert_eq!(out.effective_classes, cls);
4705        assert_eq!(out.removed, vec![false, false]);
4706    }
4707
4708    #[test]
4709    fn x_rules_rtl_paragraph_assigns_level_one() {
4710        // RTL paragraph: Arabic letters at paragraph level 1 get
4711        // level 1; no formatting characters in play.
4712        let cls = vec![BidiClass::AL, BidiClass::AL];
4713        let out = resolve_explicit_levels(&cls, 1);
4714        assert_eq!(out.levels, vec![1, 1]);
4715        assert_eq!(out.removed, vec![false, false]);
4716    }
4717
4718    #[test]
4719    fn x_rules_rle_pushes_odd_level_pdf_pops() {
4720        // RLE L PDF at paragraph level 0 — RLE pushes level 1,
4721        // L gets level 1, PDF pops. The embedding initiator's
4722        // own level is reported as the new-scope level (the
4723        // stack top *after* the push), and PDF reports the
4724        // stack-top *after* the pop (the enclosing scope). Both
4725        // RLE and PDF are X9-removed; their reported level is
4726        // not consumed by the implicit phases.
4727        let cls = vec![BidiClass::RLE, BidiClass::L, BidiClass::PDF];
4728        let out = resolve_explicit_levels(&cls, 0);
4729        assert_eq!(out.levels, vec![1, 1, 0]);
4730        assert_eq!(out.removed, vec![true, false, true]);
4731    }
4732
4733    #[test]
4734    fn x_rules_lre_pushes_least_greater_even() {
4735        // At level 0 LRE goes to level 2 (least even > 0). At
4736        // level 1 LRE goes to level 2 as well. The LRE's own
4737        // level reflects the new scope.
4738        let cls = vec![BidiClass::LRE, BidiClass::L, BidiClass::PDF];
4739        let out0 = resolve_explicit_levels(&cls, 0);
4740        assert_eq!(out0.levels, vec![2, 2, 0]);
4741        let out1 = resolve_explicit_levels(&cls, 1);
4742        assert_eq!(out1.levels, vec![2, 2, 1]);
4743    }
4744
4745    #[test]
4746    fn x_rules_rlo_overrides_to_r() {
4747        // RLO L PDF — the L between RLO and PDF gets rewritten to
4748        // R by X6 + the override status.
4749        let cls = vec![BidiClass::RLO, BidiClass::L, BidiClass::PDF];
4750        let out = resolve_explicit_levels(&cls, 0);
4751        assert_eq!(out.levels, vec![1, 1, 0]);
4752        assert_eq!(out.effective_classes[1], BidiClass::R);
4753        assert_eq!(out.removed, vec![true, false, true]);
4754    }
4755
4756    #[test]
4757    fn x_rules_lro_overrides_to_l() {
4758        // LRO AL PDF — AL gets rewritten to L by the override; the
4759        // explicit level is 2 (least even > 0).
4760        let cls = vec![BidiClass::LRO, BidiClass::AL, BidiClass::PDF];
4761        let out = resolve_explicit_levels(&cls, 0);
4762        assert_eq!(out.levels, vec![2, 2, 0]);
4763        assert_eq!(out.effective_classes[1], BidiClass::L);
4764    }
4765
4766    #[test]
4767    fn x_rules_rli_pushes_isolate_pdi_pops() {
4768        // RLI L PDI at paragraph 0 — RLI's own level is the
4769        // enclosing scope (0), L gets level 1 (the new isolate
4770        // scope), PDI's level matches the enclosing scope = 0.
4771        // None of RLI / PDI is X9-removed.
4772        let cls = vec![BidiClass::RLI, BidiClass::L, BidiClass::PDI];
4773        let out = resolve_explicit_levels(&cls, 0);
4774        assert_eq!(out.levels, vec![0, 1, 0]);
4775        assert_eq!(out.removed, vec![false, false, false]);
4776    }
4777
4778    #[test]
4779    fn x_rules_lri_pushes_least_greater_even() {
4780        let cls = vec![BidiClass::LRI, BidiClass::AL, BidiClass::PDI];
4781        let out = resolve_explicit_levels(&cls, 1);
4782        // LRI at paragraph level 1 pushes least even > 1 = 2.
4783        assert_eq!(out.levels, vec![1, 2, 1]);
4784    }
4785
4786    #[test]
4787    fn x_rules_fsi_with_strong_l_inside_resolves_lri() {
4788        // FSI L PDI — FSI sees L first inside the span, so
4789        // resolves as LRI: at paragraph level 0 that pushes level
4790        // 2; the inner L gets level 2.
4791        let cls = vec![BidiClass::FSI, BidiClass::L, BidiClass::PDI];
4792        let out = resolve_explicit_levels(&cls, 0);
4793        assert_eq!(out.levels, vec![0, 2, 0]);
4794    }
4795
4796    #[test]
4797    fn x_rules_fsi_with_strong_r_inside_resolves_rli() {
4798        // FSI AL PDI — FSI sees AL first inside the span, so
4799        // resolves as RLI: at paragraph level 0 that pushes level
4800        // 1; the inner AL gets level 1.
4801        let cls = vec![BidiClass::FSI, BidiClass::AL, BidiClass::PDI];
4802        let out = resolve_explicit_levels(&cls, 0);
4803        assert_eq!(out.levels, vec![0, 1, 0]);
4804    }
4805
4806    #[test]
4807    fn x_rules_fsi_with_no_strong_inside_resolves_lri() {
4808        // FSI WS PDI — no strong character → default 0 → LRI →
4809        // pushes level 2; WS gets level 2.
4810        let cls = vec![BidiClass::FSI, BidiClass::WS, BidiClass::PDI];
4811        let out = resolve_explicit_levels(&cls, 0);
4812        assert_eq!(out.levels, vec![0, 2, 0]);
4813    }
4814
4815    #[test]
4816    fn x_rules_b_assigned_paragraph_level() {
4817        // B inside an RLE scope should still get the paragraph
4818        // level per X8 ("they are not included in any embedding,
4819        // override or isolate").
4820        let cls = vec![BidiClass::RLE, BidiClass::L, BidiClass::B];
4821        let out = resolve_explicit_levels(&cls, 0);
4822        assert_eq!(out.levels, vec![1, 1, 0]);
4823    }
4824
4825    #[test]
4826    fn x_rules_bn_removed_by_x9() {
4827        // BN inherits the enclosing scope's level + is marked
4828        // removed. RLE BN L PDF.
4829        let cls = vec![BidiClass::RLE, BidiClass::BN, BidiClass::L, BidiClass::PDF];
4830        let out = resolve_explicit_levels(&cls, 0);
4831        assert_eq!(out.levels, vec![1, 1, 1, 0]);
4832        assert_eq!(out.removed, vec![true, true, false, true]);
4833    }
4834
4835    #[test]
4836    fn x_rules_nested_embeddings_at_most_three_deep() {
4837        // RLE LRE RLE L PDF PDF PDF at paragraph 0:
4838        // - RLE: level 0→1
4839        // - LRE: 1→2
4840        // - RLE: 2→3
4841        // - L : level 3
4842        // PDF unwinds back.
4843        let cls = vec![
4844            BidiClass::RLE,
4845            BidiClass::LRE,
4846            BidiClass::RLE,
4847            BidiClass::L,
4848            BidiClass::PDF,
4849            BidiClass::PDF,
4850            BidiClass::PDF,
4851        ];
4852        let out = resolve_explicit_levels(&cls, 0);
4853        assert_eq!(out.levels[3], 3);
4854        // The unwinding levels: after the third PDF the stack is
4855        // back to the paragraph frame; each PDF carries the level
4856        // *before* its pop, which the implementation reports as
4857        // the stack-top after the pop. Either contract is fine
4858        // since X9 removes PDFs anyway. We assert only that the
4859        // levels vector has no panic.
4860        assert_eq!(out.levels.len(), 7);
4861        // Embeddings + PDFs all X9-removed.
4862        assert_eq!(out.removed, vec![true, true, true, false, true, true, true]);
4863    }
4864
4865    #[test]
4866    fn x_rules_overflow_embedding_at_max_depth() {
4867        // Build a sequence that pushes RLE 65 times (each adds
4868        // +2 to the level after the first). With paragraph level
4869        // 0: RLE pushes 1, 3, 5, ... up to MAX_DEPTH (125). 63
4870        // valid pushes reach level 125 (63 RLEs from level 0:
4871        // 1, 3, 5, ..., 125). A 64th RLE would attempt level 127
4872        // > 125 → overflow.
4873        let mut cls = vec![BidiClass::RLE; 64];
4874        cls.push(BidiClass::L);
4875        cls.push(BidiClass::PDF);
4876        let out = resolve_explicit_levels(&cls, 0);
4877        // 63 valid pushes give level 125; the 64th RLE overflows
4878        // so the L is still at level 125.
4879        assert_eq!(out.levels[64], 125);
4880    }
4881
4882    #[test]
4883    fn x_rules_unmatched_pdf_at_paragraph_level_ignored() {
4884        // PDF at paragraph level with no matching embedding is
4885        // ignored (does nothing) — the level vector reflects the
4886        // paragraph level for any following non-formatting char.
4887        let cls = vec![BidiClass::PDF, BidiClass::L];
4888        let out = resolve_explicit_levels(&cls, 0);
4889        assert_eq!(out.levels, vec![0, 0]);
4890        assert_eq!(out.removed, vec![true, false]);
4891    }
4892
4893    #[test]
4894    fn x_rules_unmatched_pdi_ignored() {
4895        // PDI at top level with no isolate above it is ignored
4896        // (X6a "Otherwise, if the valid isolate count is zero,
4897        // this PDI does not match any isolate initiator, valid or
4898        // overflow. Do nothing.").
4899        let cls = vec![BidiClass::PDI, BidiClass::L];
4900        let out = resolve_explicit_levels(&cls, 0);
4901        assert_eq!(out.levels, vec![0, 0]);
4902        assert_eq!(out.removed, vec![false, false]);
4903    }
4904
4905    #[test]
4906    fn x_rules_pdi_pops_embeddings_inside_isolate() {
4907        // RLI RLE L PDI — the PDI matches the RLI, which by X6a
4908        // unwinds the embedding stack down to the matched isolate
4909        // frame and then pops the isolate. Final stack: paragraph
4910        // frame only.
4911        let cls = vec![
4912            BidiClass::RLI,
4913            BidiClass::RLE,
4914            BidiClass::L,
4915            BidiClass::PDI,
4916            BidiClass::L,
4917        ];
4918        let out = resolve_explicit_levels(&cls, 0);
4919        // RLI's level = enclosing = 0.
4920        assert_eq!(out.levels[0], 0);
4921        // RLE (inside the RLI scope at level 1) pushes 1 → 3.
4922        // But the RLE's reported level (per the implementation)
4923        // is the stack top *after* the push = 3, or the
4924        // enclosing level 1 depending on contract. Both are
4925        // tolerable per X9.
4926        // The L inside RLI+RLE is at level 3.
4927        assert_eq!(out.levels[2], 3);
4928        // PDI: matched RLI, so back to paragraph = level 0.
4929        assert_eq!(out.levels[3], 0);
4930        // Following L at paragraph level.
4931        assert_eq!(out.levels[4], 0);
4932    }
4933
4934    #[test]
4935    fn x_rules_least_greater_odd_helper_table() {
4936        // Spot check the helper directly against the spec table:
4937        // "level 0 → 1; levels 1, 2 → 3; levels 3, 4 → 5; ..."
4938        assert_eq!(least_greater_odd(0), 1);
4939        assert_eq!(least_greater_odd(1), 3);
4940        assert_eq!(least_greater_odd(2), 3);
4941        assert_eq!(least_greater_odd(3), 5);
4942        assert_eq!(least_greater_odd(4), 5);
4943        // And the LRE/LRO version: "levels 0, 1 → 2; levels 2, 3
4944        // → 4; levels 4, 5 → 6; ..."
4945        assert_eq!(least_greater_even(0), 2);
4946        assert_eq!(least_greater_even(1), 2);
4947        assert_eq!(least_greater_even(2), 4);
4948        assert_eq!(least_greater_even(3), 4);
4949        assert_eq!(least_greater_even(4), 6);
4950    }
4951
4952    #[test]
4953    fn x_rules_rle_inside_isolate_pdf_only_matches_inside() {
4954        // RLI RLE L PDF PDI L — the PDF matches the RLE inside the
4955        // isolate (X7 third bullet). After the PDI, the L is at
4956        // paragraph level 0.
4957        let cls = vec![
4958            BidiClass::RLI,
4959            BidiClass::RLE,
4960            BidiClass::L,
4961            BidiClass::PDF,
4962            BidiClass::PDI,
4963            BidiClass::L,
4964        ];
4965        let out = resolve_explicit_levels(&cls, 0);
4966        // L between RLE and PDF: level 3 (paragraph 0 → RLI 1 → RLE 3).
4967        assert_eq!(out.levels[2], 3);
4968        // Trailing L at paragraph level.
4969        assert_eq!(out.levels[5], 0);
4970    }
4971
4972    #[test]
4973    fn x_rules_pdi_inside_overflow_isolate_decrements_overflow_isolate() {
4974        // Two RLIs nested at max depth: the second triggers
4975        // overflow_isolate; the matching PDI decrements it
4976        // (and the next PDI matches the valid RLI). Hard to test
4977        // exhaustively without a full max-depth chain — instead we
4978        // just confirm that a doubly-nested RLI ... PDI PDI pair
4979        // both succeed in normal depth (no panic).
4980        let cls = vec![
4981            BidiClass::RLI,
4982            BidiClass::RLI,
4983            BidiClass::L,
4984            BidiClass::PDI,
4985            BidiClass::PDI,
4986            BidiClass::L,
4987        ];
4988        let out = resolve_explicit_levels(&cls, 0);
4989        // Outer RLI → 1; inner RLI → 3; L inside → 3; first PDI
4990        // pops back to 1; second PDI pops back to 0; trailing L → 0.
4991        assert_eq!(out.levels[2], 3);
4992        assert_eq!(out.levels[5], 0);
4993    }
4994
4995    #[test]
4996    fn x_rules_max_depth_constant_is_125() {
4997        // BD2: max_depth = 125, guaranteed stable.
4998        assert_eq!(MAX_DEPTH, 125);
4999    }
5000
5001    // --- Section: BD7 level-run partition ----------------------
5002
5003    #[test]
5004    fn level_runs_empty_input_returns_empty() {
5005        assert!(level_runs(&[]).is_empty());
5006    }
5007
5008    #[test]
5009    fn level_runs_single_level_collapses_to_one_run() {
5010        let runs = level_runs(&[0, 0, 0]);
5011        assert_eq!(runs.len(), 1);
5012        assert_eq!((runs[0].start, runs[0].end, runs[0].level), (0, 3, 0));
5013        assert_eq!(runs[0].len(), 3);
5014        assert!(!runs[0].is_empty());
5015    }
5016
5017    #[test]
5018    fn level_runs_split_on_level_change() {
5019        // [0, 0, 1, 1, 1, 0, 2] → (0..2, 0), (2..5, 1), (5..6, 0), (6..7, 2).
5020        let runs = level_runs(&[0, 0, 1, 1, 1, 0, 2]);
5021        assert_eq!(runs.len(), 4);
5022        assert_eq!((runs[0].start, runs[0].end, runs[0].level), (0, 2, 0));
5023        assert_eq!((runs[1].start, runs[1].end, runs[1].level), (2, 5, 1));
5024        assert_eq!((runs[2].start, runs[2].end, runs[2].level), (5, 6, 0));
5025        assert_eq!((runs[3].start, runs[3].end, runs[3].level), (6, 7, 2));
5026    }
5027
5028    #[test]
5029    fn level_runs_cover_input_exactly() {
5030        // Concatenated ranges fully cover [0, n) without overlap.
5031        let levels = [0, 0, 1, 0, 1, 1, 0];
5032        let runs = level_runs(&levels);
5033        let mut expect_next = 0;
5034        for r in &runs {
5035            assert_eq!(r.start, expect_next);
5036            assert!(r.end > r.start);
5037            expect_next = r.end;
5038        }
5039        assert_eq!(expect_next, levels.len());
5040    }
5041
5042    // --- Section: X10 + BD13 isolating-run-sequence partition ---
5043
5044    fn build(s: &str) -> (Vec<BidiClass>, ExplicitLevels, u8) {
5045        let cls: Vec<BidiClass> = s.chars().map(bidi_class).collect();
5046        let pl = paragraph_level(s);
5047        let out = resolve_explicit_levels(&cls, pl);
5048        (cls, out, pl)
5049    }
5050
5051    #[test]
5052    fn x10_empty_paragraph_no_sequences() {
5053        let cls: Vec<BidiClass> = Vec::new();
5054        let out = resolve_explicit_levels(&cls, 0);
5055        let seqs = isolating_run_sequences(&cls, &out, 0);
5056        assert!(seqs.is_empty());
5057    }
5058
5059    #[test]
5060    fn x10_pure_l_one_sequence_one_run() {
5061        let (cls, out, pl) = build("Hello");
5062        let seqs = isolating_run_sequences(&cls, &out, pl);
5063        assert_eq!(seqs.len(), 1);
5064        assert_eq!(seqs[0].runs.len(), 1);
5065        assert_eq!(seqs[0].level, 0);
5066        assert_eq!(seqs[0].sos, BidiClass::L);
5067        assert_eq!(seqs[0].eos, BidiClass::L);
5068    }
5069
5070    #[test]
5071    fn x10_arabic_paragraph_rtl_sos_eos_r() {
5072        // Hebrew letter שלום — paragraph level 1, single sequence at
5073        // level 1, sos = eos = R.
5074        let (cls, out, pl) = build("\u{05E9}\u{05DC}\u{05D5}\u{05DD}");
5075        assert_eq!(pl, 1);
5076        let seqs = isolating_run_sequences(&cls, &out, pl);
5077        assert_eq!(seqs.len(), 1);
5078        assert_eq!(seqs[0].level, 1);
5079        assert_eq!(seqs[0].sos, BidiClass::R);
5080        assert_eq!(seqs[0].eos, BidiClass::R);
5081    }
5082
5083    #[test]
5084    fn x10_rle_split_emits_three_sequences_with_correct_sos_eos() {
5085        // L RLE L PDF L — runs:
5086        //   [0..1] level 0 (L)
5087        //   [1..3] level 1 (RLE + L; PDF index 3 stays at level 0 →
5088        //                 wait: PDF gets the post-pop stack top → 0,
5089        //                 so the level vector is [0,1,1,0,0]).
5090        // Result: (0..1)@0, (1..3)@1, (3..5)@0 — three sequences.
5091        let cls = vec![
5092            BidiClass::L,
5093            BidiClass::RLE,
5094            BidiClass::L,
5095            BidiClass::PDF,
5096            BidiClass::L,
5097        ];
5098        let out = resolve_explicit_levels(&cls, 0);
5099        let seqs = isolating_run_sequences(&cls, &out, 0);
5100        assert_eq!(seqs.len(), 3);
5101        // First: sos paragraph (0), eos other side is RLE @ level 1
5102        // BUT RLE is X9-removed — the spec says boundary lookups
5103        // skip removed chars, so eos other side scans forward past
5104        // the RLE to the next non-removed char (L at index 2 @
5105        // level 1) → higher is 1 → R.
5106        assert_eq!(seqs[0].level, 0);
5107        assert_eq!(seqs[0].sos, BidiClass::L);
5108        assert_eq!(seqs[0].eos, BidiClass::R);
5109        // Middle: level 1, sos other side is L @ 0 (the leading L),
5110        // higher of 1 vs 0 → 1 → R. eos other side: skip PDF → L
5111        // @ level 0, higher 1 vs 0 → R.
5112        assert_eq!(seqs[1].level, 1);
5113        assert_eq!(seqs[1].sos, BidiClass::R);
5114        assert_eq!(seqs[1].eos, BidiClass::R);
5115        // Last: level 0, sos other side: skip PDF backward → L @
5116        // level 1, higher 0 vs 1 → 1 → R. eos paragraph edge → 0
5117        // → L.
5118        assert_eq!(seqs[2].level, 0);
5119        assert_eq!(seqs[2].sos, BidiClass::R);
5120        assert_eq!(seqs[2].eos, BidiClass::L);
5121    }
5122
5123    #[test]
5124    fn x10_lri_chains_outer_runs_into_one_sequence() {
5125        // "a" LRI "b" PDI "c" — the LRI initiator and matching PDI
5126        // both carry the *enclosing* embedding level (0). Levels are
5127        // [0, 0, 0, 0, 0] — a single level run, hence a single
5128        // sequence (the LRI/PDI happen not to raise the level because
5129        // we are already at level 0 and an LRI under paragraph level
5130        // 0 pushes level 2 only for the *contents*, which is also
5131        // level 2 only if there ARE intervening chars).
5132        //
5133        // Concretely with "a" LRI "b" PDI "c": LRI pushes level 2
5134        // (least even > 0), 'b' lands at 2, PDI pops back, 'c' at 0.
5135        // Levels [0, 0, 2, 0, 0] — runs (0..2, 0), (2..3, 2),
5136        // (3..5, 0).
5137        //
5138        // BD13: the first run ends with LRI (an isolate initiator)
5139        // whose matching PDI is at index 3 — but index 3 is the
5140        // *first character* of the third run, not the second. So
5141        // the first run chains to the third, and the second run is
5142        // its own sequence.
5143        let cls = vec![
5144            BidiClass::L,
5145            BidiClass::LRI,
5146            BidiClass::L,
5147            BidiClass::PDI,
5148            BidiClass::L,
5149        ];
5150        let out = resolve_explicit_levels(&cls, 0);
5151        assert_eq!(out.levels, vec![0, 0, 2, 0, 0]);
5152        let runs = level_runs(&out.levels);
5153        assert_eq!(runs.len(), 3);
5154        let seqs = isolating_run_sequences(&cls, &out, 0);
5155        // Two sequences: one chaining (0..2)+(3..5), one for (2..3).
5156        assert_eq!(seqs.len(), 2);
5157        // The chained sequence is the first emitted (its seed run
5158        // starts at index 0).
5159        assert_eq!(seqs[0].runs.len(), 2);
5160        assert_eq!(seqs[0].runs[0].start, 0);
5161        assert_eq!(seqs[0].runs[0].end, 2);
5162        assert_eq!(seqs[0].runs[1].start, 3);
5163        assert_eq!(seqs[0].runs[1].end, 5);
5164        assert_eq!(seqs[0].level, 0);
5165        // The interior sequence is the body of the isolate.
5166        assert_eq!(seqs[1].runs.len(), 1);
5167        assert_eq!(seqs[1].runs[0].start, 2);
5168        assert_eq!(seqs[1].runs[0].end, 3);
5169        assert_eq!(seqs[1].level, 2);
5170    }
5171
5172    #[test]
5173    fn x10_unmatched_isolate_initiator_triggers_eos_paragraph_fallback() {
5174        // "a" LRI "b" — LRI raises level for 'b' to 2; there is no
5175        // matching PDI. The last run ends with an isolate initiator
5176        // (the LRI itself sits in the first run, since 'b' is on a
5177        // raised level), but the LRI's matching PDI doesn't exist
5178        // so the eos of the outer sequence falls back to the
5179        // paragraph level.
5180        let cls = vec![BidiClass::L, BidiClass::LRI, BidiClass::L];
5181        let out = resolve_explicit_levels(&cls, 0);
5182        // Levels: 'a' 0, LRI carries enclosing 0, 'b' 2.
5183        assert_eq!(out.levels, vec![0, 0, 2]);
5184        let seqs = isolating_run_sequences(&cls, &out, 0);
5185        // Two sequences: (0..2)@0 and (2..3)@2. The first ends with
5186        // an unmatched LRI; by X10 step 2 its eos uses the paragraph
5187        // level (0) as the other side → higher(0, 0) = 0 → L.
5188        assert_eq!(seqs.len(), 2);
5189        assert_eq!(seqs[0].runs[0].start, 0);
5190        assert_eq!(seqs[0].runs[0].end, 2);
5191        assert_eq!(seqs[0].sos, BidiClass::L);
5192        assert_eq!(seqs[0].eos, BidiClass::L);
5193        // Second sequence is at level 2 (even). sos other side is
5194        // the LRI at index 1 (not X9-removed) at level 0 — higher
5195        // of (0, 2) = 2 → L (even). eos paragraph edge → paragraph
5196        // level 0 — higher of (0, 2) = 2 → L.
5197        assert_eq!(seqs[1].sos, BidiClass::L);
5198        assert_eq!(seqs[1].eos, BidiClass::L);
5199    }
5200
5201    #[test]
5202    fn x10_paragraph_level_1_rtl_default_sos_eos() {
5203        // Pure-Hebrew paragraph at level 1: paragraph fallback for
5204        // sos / eos is paragraph level 1 → R.
5205        let cls = vec![BidiClass::R, BidiClass::R, BidiClass::R];
5206        let out = resolve_explicit_levels(&cls, 1);
5207        assert_eq!(out.levels, vec![1, 1, 1]);
5208        let seqs = isolating_run_sequences(&cls, &out, 1);
5209        assert_eq!(seqs.len(), 1);
5210        assert_eq!(seqs[0].sos, BidiClass::R);
5211        assert_eq!(seqs[0].eos, BidiClass::R);
5212    }
5213
5214    #[test]
5215    fn x10_each_level_run_belongs_to_exactly_one_sequence() {
5216        // BD13 invariant: every level run appears in exactly one
5217        // isolating run sequence. Build a paragraph with multiple
5218        // RLE/PDF + LRI/PDI nestings and assert partition coverage.
5219        let cls = vec![
5220            BidiClass::L,   // 0
5221            BidiClass::RLE, // 1
5222            BidiClass::L,   // 2
5223            BidiClass::LRI, // 3 (under RLE override)
5224            BidiClass::L,   // 4
5225            BidiClass::PDI, // 5
5226            BidiClass::L,   // 6
5227            BidiClass::PDF, // 7
5228            BidiClass::L,   // 8
5229        ];
5230        let out = resolve_explicit_levels(&cls, 0);
5231        let runs = level_runs(&out.levels);
5232        let total_run_count = runs.len();
5233        let seqs = isolating_run_sequences(&cls, &out, 0);
5234        let mut counted = 0;
5235        for s in &seqs {
5236            counted += s.runs.len();
5237            // All runs in one sequence share embedding level.
5238            for r in &s.runs {
5239                assert_eq!(r.level, s.level);
5240            }
5241        }
5242        assert_eq!(
5243            counted, total_run_count,
5244            "every level run belongs to exactly one sequence"
5245        );
5246    }
5247
5248    #[test]
5249    fn x10_indices_iterator_skips_x9_removed() {
5250        // L RLE L PDF L — sequence [0..1, level 0] yields just {0};
5251        // sequence [1..3, level 1] skips the RLE at index 1 and
5252        // yields {2}; sequence [3..5, level 0] skips the PDF at
5253        // index 3 and yields {4}.
5254        let cls = vec![
5255            BidiClass::L,
5256            BidiClass::RLE,
5257            BidiClass::L,
5258            BidiClass::PDF,
5259            BidiClass::L,
5260        ];
5261        let out = resolve_explicit_levels(&cls, 0);
5262        let seqs = isolating_run_sequences(&cls, &out, 0);
5263        assert_eq!(seqs.len(), 3);
5264        let s0: Vec<usize> = seqs[0].indices(&out.removed).collect();
5265        assert_eq!(s0, vec![0]);
5266        let s1: Vec<usize> = seqs[1].indices(&out.removed).collect();
5267        assert_eq!(s1, vec![2]);
5268        let s2: Vec<usize> = seqs[2].indices(&out.removed).collect();
5269        assert_eq!(s2, vec![4]);
5270    }
5271
5272    #[test]
5273    fn x10_indices_for_chained_isolate_sequence_concatenates_runs() {
5274        // "a" LRI "b" PDI "c" — chained sequence is (0..2) + (3..5).
5275        // Indices walk yields {0, 1, 3, 4} (LRI at 1 and PDI at 3
5276        // are isolate-formatting characters, NOT X9-removed, so
5277        // they participate in the index walk per the X9 spec note).
5278        let cls = vec![
5279            BidiClass::L,
5280            BidiClass::LRI,
5281            BidiClass::L,
5282            BidiClass::PDI,
5283            BidiClass::L,
5284        ];
5285        let out = resolve_explicit_levels(&cls, 0);
5286        let seqs = isolating_run_sequences(&cls, &out, 0);
5287        let s0: Vec<usize> = seqs[0].indices(&out.removed).collect();
5288        assert_eq!(s0, vec![0, 1, 3, 4]);
5289    }
5290
5291    #[test]
5292    fn x10_sequences_compose_with_existing_w_n_i_pipeline() {
5293        // End-to-end sanity: build a paragraph, partition it, and
5294        // verify each sequence's sos / eos lets the W rules run
5295        // without modification. We don't check W output here (that
5296        // is covered by the existing W tests); we only confirm that
5297        // the API hands us per-sequence `sos` / `eos` of class L or
5298        // R as required.
5299        let cls = vec![
5300            BidiClass::L,
5301            BidiClass::RLE,
5302            BidiClass::AL,
5303            BidiClass::EN,
5304            BidiClass::PDF,
5305            BidiClass::L,
5306        ];
5307        let out = resolve_explicit_levels(&cls, 0);
5308        let seqs = isolating_run_sequences(&cls, &out, 0);
5309        assert!(!seqs.is_empty());
5310        for s in &seqs {
5311            assert!(matches!(s.sos, BidiClass::L | BidiClass::R));
5312            assert!(matches!(s.eos, BidiClass::L | BidiClass::R));
5313            // Round-trip through resolve_weak_types per-sequence in
5314            // place: we just confirm it does not panic and leaves
5315            // the slice strong-typed.
5316            let mut indices: Vec<usize> = s.indices(&out.removed).collect();
5317            // Pull the effective classes for this sequence into a
5318            // working buffer; W rules expect a contiguous mut slice.
5319            let mut seq_classes: Vec<BidiClass> =
5320                indices.iter().map(|&i| out.effective_classes[i]).collect();
5321            resolve_weak_types(&mut seq_classes, s.sos, s.eos);
5322            // After W, no AL should remain (W3).
5323            assert!(seq_classes.iter().all(|c| !matches!(c, BidiClass::AL)));
5324            let _ = indices.drain(..);
5325        }
5326    }
5327
5328    #[test]
5329    fn x10_matching_pdi_skips_non_isolate_formatting() {
5330        // BD9 closing note: "all formatting characters except for
5331        // isolate initiators and PDIs are ignored when finding the
5332        // matching PDI." LRI ... RLE LRE PDF ... PDI — the matching
5333        // PDI is correctly found past the embedding formatting.
5334        let cls = vec![
5335            BidiClass::L,
5336            BidiClass::LRI, // 1
5337            BidiClass::RLE, // 2 (ignored by BD9)
5338            BidiClass::L,   // 3
5339            BidiClass::PDF, // 4 (ignored by BD9)
5340            BidiClass::PDI, // 5
5341            BidiClass::L,
5342        ];
5343        let pdi = matching_pdi(&cls, 1);
5344        assert_eq!(pdi, Some(5));
5345    }
5346
5347    #[test]
5348    fn x10_matching_pdi_returns_none_for_unmatched_initiator() {
5349        let cls = vec![BidiClass::L, BidiClass::LRI, BidiClass::L];
5350        assert_eq!(matching_pdi(&cls, 1), None);
5351    }
5352
5353    #[test]
5354    fn x10_matching_pdi_counts_nested_isolates_correctly() {
5355        // LRI LRI PDI PDI — the outer LRI at 0 matches the second
5356        // PDI at 3, the inner LRI at 1 matches the first PDI at 2.
5357        let cls = vec![
5358            BidiClass::LRI,
5359            BidiClass::LRI,
5360            BidiClass::PDI,
5361            BidiClass::PDI,
5362        ];
5363        assert_eq!(matching_pdi(&cls, 0), Some(3));
5364        assert_eq!(matching_pdi(&cls, 1), Some(2));
5365    }
5366
5367    // --- Section ?: §3 whole-paragraph driver -----------------------
5368
5369    #[test]
5370    fn process_paragraph_classes_empty_input_returns_empty_carrier() {
5371        let p = process_paragraph_classes(&[], None);
5372        assert_eq!(p.paragraph_level, 0);
5373        assert!(p.classes.is_empty());
5374        assert!(p.effective_classes.is_empty());
5375        assert!(p.removed.is_empty());
5376        assert!(p.levels.is_empty());
5377    }
5378
5379    #[test]
5380    fn process_paragraph_classes_first_strong_l_is_ltr() {
5381        // "ABC" all L → paragraph level 0, every char at level 0.
5382        let cls = vec![BidiClass::L, BidiClass::L, BidiClass::L];
5383        let p = process_paragraph_classes(&cls, None);
5384        assert_eq!(p.paragraph_level, 0);
5385        assert_eq!(p.levels, vec![0, 0, 0]);
5386    }
5387
5388    #[test]
5389    fn process_paragraph_classes_first_strong_r_is_rtl() {
5390        // Hebrew-only paragraph: every char at level 1.
5391        let cls = vec![BidiClass::R, BidiClass::R, BidiClass::R];
5392        let p = process_paragraph_classes(&cls, None);
5393        assert_eq!(p.paragraph_level, 1);
5394        assert_eq!(p.levels, vec![1, 1, 1]);
5395    }
5396
5397    #[test]
5398    fn process_paragraph_classes_first_strong_al_is_rtl() {
5399        // Arabic-only paragraph: W3 rewrites AL → R, every char at
5400        // level 1.
5401        let cls = vec![BidiClass::AL, BidiClass::AL, BidiClass::AL];
5402        let p = process_paragraph_classes(&cls, None);
5403        assert_eq!(p.paragraph_level, 1);
5404        assert_eq!(p.levels, vec![1, 1, 1]);
5405        // effective_classes preserves AL (X-stack does no W-rule
5406        // rewriting); the W-rule rewrite is internal to the per-
5407        // sequence pass and never re-published.
5408        assert_eq!(p.effective_classes, vec![BidiClass::AL; 3]);
5409    }
5410
5411    #[test]
5412    fn process_paragraph_classes_p3_fallback_is_ltr() {
5413        // No strong characters → P3 says paragraph level 0 (LTR).
5414        let cls = vec![BidiClass::ON, BidiClass::WS, BidiClass::ON];
5415        let p = process_paragraph_classes(&cls, None);
5416        assert_eq!(p.paragraph_level, 0);
5417        // The single NI run resolves to L (sos = eos = L), so every
5418        // level stays at 0.
5419        assert_eq!(p.levels, vec![0, 0, 0]);
5420    }
5421
5422    #[test]
5423    fn process_paragraph_classes_base_level_overrides_first_strong() {
5424        // "L L L" but caller forces base_level = 1 → all chars at
5425        // level 2 (the R-resolution under odd embedding I1 row "L
5426        // goes +1").
5427        let cls = vec![BidiClass::L, BidiClass::L, BidiClass::L];
5428        let p = process_paragraph_classes(&cls, Some(1));
5429        assert_eq!(p.paragraph_level, 1);
5430        assert_eq!(p.levels, vec![2, 2, 2]);
5431    }
5432
5433    #[test]
5434    fn process_paragraph_classes_base_level_clamps_to_low_bit() {
5435        // Caller passes 5 (an odd embedding) — we mask to the
5436        // paragraph-level convention {0, 1}.
5437        let cls = vec![BidiClass::L];
5438        let p = process_paragraph_classes(&cls, Some(5));
5439        assert_eq!(p.paragraph_level, 1);
5440    }
5441
5442    #[test]
5443    fn process_paragraph_classes_isolate_span_skipped_by_p2() {
5444        // BD8 says the first-strong walk skips the contents of an
5445        // LRI..PDI span. Here the first strong outside the isolate
5446        // is R, so the paragraph level should be 1.
5447        let cls = vec![BidiClass::LRI, BidiClass::L, BidiClass::PDI, BidiClass::R];
5448        let p = process_paragraph_classes(&cls, None);
5449        assert_eq!(p.paragraph_level, 1);
5450    }
5451
5452    #[test]
5453    fn process_paragraph_classes_unmatched_isolate_initiator_returns_p3_default() {
5454        // BD8 says the walk skips to end of paragraph past an
5455        // unmatched initiator. No strong character outside it →
5456        // P3 fallback 0.
5457        let cls = vec![BidiClass::LRI, BidiClass::R, BidiClass::R];
5458        let p = process_paragraph_classes(&cls, None);
5459        assert_eq!(p.paragraph_level, 0);
5460    }
5461
5462    #[test]
5463    fn process_paragraph_classes_x9_removed_chars_retain_x_level() {
5464        // L RLE L PDF L — RLE / PDF are X9-removed. The middle L
5465        // sits inside an RLE-pushed odd-level scope (X-level 1); I1
5466        // under odd EL says L → +1, so the middle L ends at level 2.
5467        // The outer L's stay at level 0.
5468        let cls = vec![
5469            BidiClass::L,
5470            BidiClass::RLE,
5471            BidiClass::L,
5472            BidiClass::PDF,
5473            BidiClass::L,
5474        ];
5475        let p = process_paragraph_classes(&cls, None);
5476        assert_eq!(p.paragraph_level, 0);
5477        // RLE + PDF are X9-removed (the W / N / I sweep skipped them);
5478        // their level is whatever X1..X9 left there.
5479        assert_eq!(p.removed, vec![false, true, false, true, false]);
5480        // The outer L's stay at level 0; the inner L lifts to 2
5481        // (X-level 1 + I1 odd-EL increment for L).
5482        assert_eq!(p.levels[0], 0);
5483        assert_eq!(p.levels[2], 2);
5484        assert_eq!(p.levels[4], 0);
5485    }
5486
5487    #[test]
5488    fn process_paragraph_classes_mixed_l_r_compose_full_pipeline() {
5489        // "ABC abc" with abc as Hebrew (R). Paragraph level 0
5490        // (first-strong is L). After W / N / I, the R block goes
5491        // to level 1, the L block to level 0; the WS between them
5492        // resolves to L per N1 / N2 (R/L mismatch → embedding dir
5493        // = L at level 0) and stays at level 0.
5494        let cls = vec![
5495            BidiClass::L,
5496            BidiClass::L,
5497            BidiClass::L, // ABC
5498            BidiClass::WS,
5499            BidiClass::R,
5500            BidiClass::R,
5501            BidiClass::R, // hbr
5502        ];
5503        let p = process_paragraph_classes(&cls, None);
5504        assert_eq!(p.paragraph_level, 0);
5505        // L block + WS at level 0; R block at level 1.
5506        assert_eq!(p.levels, vec![0, 0, 0, 0, 1, 1, 1]);
5507    }
5508
5509    #[test]
5510    fn process_paragraph_classes_rtl_paragraph_lifts_l_to_level_2() {
5511        // RTL paragraph (first strong R) with embedded Latin: the
5512        // Latin block goes from L at level 0 → L at level 2 (I1
5513        // under odd EL: L goes +1; since the surrounding sequence
5514        // is level 1, L sits at 1+1=2).
5515        let cls = vec![
5516            BidiClass::R,
5517            BidiClass::R, // RR
5518            BidiClass::WS,
5519            BidiClass::L,
5520            BidiClass::L,
5521            BidiClass::L, // abc
5522            BidiClass::WS,
5523            BidiClass::R,
5524            BidiClass::R, // RR
5525        ];
5526        let p = process_paragraph_classes(&cls, None);
5527        assert_eq!(p.paragraph_level, 1);
5528        // R block at 1, L block at 2, whitespace at the embedding
5529        // level (1) per N2 because L vs R on either side.
5530        assert_eq!(p.levels, vec![1, 1, 1, 2, 2, 2, 1, 1, 1]);
5531    }
5532
5533    #[test]
5534    fn process_paragraph_text_byte_offsets_track_chars() {
5535        // Plain ASCII paragraph: every char-byte is at the char's
5536        // own index, len 5.
5537        let (p, offsets) = process_paragraph("Hello", None);
5538        assert_eq!(p.paragraph_level, 0);
5539        assert_eq!(p.levels, vec![0; 5]);
5540        assert_eq!(offsets, vec![0, 1, 2, 3, 4]);
5541    }
5542
5543    #[test]
5544    fn process_paragraph_text_multi_byte_chars_byte_offsets_advance() {
5545        // "Ωa" — Ω is 2 bytes, a is 1.
5546        let (p, offsets) = process_paragraph("\u{03A9}a", None);
5547        assert_eq!(p.classes.len(), 2);
5548        assert_eq!(offsets, vec![0, 2]);
5549        // Ω is L per our table fallback; both at level 0.
5550        assert_eq!(p.levels, vec![0, 0]);
5551    }
5552
5553    #[test]
5554    fn reorder_paragraph_ltr_only_is_identity() {
5555        let p = process_paragraph_classes(&[BidiClass::L; 4], None);
5556        let perm = p.reorder_paragraph();
5557        assert_eq!(perm, vec![0, 1, 2, 3]);
5558    }
5559
5560    #[test]
5561    fn reorder_paragraph_rtl_block_reverses() {
5562        // ABC + RTL block. After L1+L2, the RTL block reverses.
5563        let cls = vec![
5564            BidiClass::L,
5565            BidiClass::L,
5566            BidiClass::L,
5567            BidiClass::R,
5568            BidiClass::R,
5569            BidiClass::R,
5570        ];
5571        let p = process_paragraph_classes(&cls, None);
5572        let perm = p.reorder_paragraph();
5573        // L block stays 0,1,2; R block reverses 3,4,5 → 5,4,3.
5574        assert_eq!(perm, vec![0, 1, 2, 5, 4, 3]);
5575    }
5576
5577    #[test]
5578    fn reorder_line_range_per_line_works() {
5579        // Treat a 5-char "ABCDE" as two lines: [0..2] and [2..5].
5580        let p = process_paragraph_classes(&[BidiClass::L; 5], None);
5581        let line1 = p.reorder_line_range(0..2);
5582        let line2 = p.reorder_line_range(2..5);
5583        assert_eq!(line1, vec![0, 1]);
5584        assert_eq!(line2, vec![0, 1, 2]);
5585    }
5586
5587    #[test]
5588    #[should_panic]
5589    fn reorder_line_range_out_of_bounds_panics() {
5590        let p = process_paragraph_classes(&[BidiClass::L; 3], None);
5591        let _ = p.reorder_line_range(0..10);
5592    }
5593
5594    #[test]
5595    fn paragraph_level_from_classes_matches_text_walker() {
5596        // Cross-check the class-driven P2 walk against the text-
5597        // driven `paragraph_level` on a handful of inputs.
5598        for s in &[
5599            "Hello",
5600            "\u{05D0}\u{05D1}\u{05D2}",  // Hebrew
5601            "\u{0627}\u{0628}\u{0629}",  // Arabic
5602            "Hi \u{05D0}\u{05D1}",       // Mixed Latin + Hebrew (P2 = L)
5603            "\u{05D0}\u{05D1} Hi",       // Mixed Hebrew + Latin (P2 = R)
5604            "\u{2066}A\u{2069}\u{05D0}", // LRI A PDI + Hebrew → P2 = R
5605        ] {
5606            let cls: Vec<_> = s.chars().map(bidi_class).collect();
5607            assert_eq!(
5608                paragraph_level_from_classes(&cls),
5609                paragraph_level(s),
5610                "mismatch for input {s:?}",
5611            );
5612        }
5613    }
5614
5615    // --- Section N: §3 P1 multi-paragraph driver ---------------------
5616
5617    #[test]
5618    fn process_text_empty_input_returns_empty_carrier() {
5619        let t = process_text("", None);
5620        assert!(t.is_empty());
5621        assert_eq!(t.len(), 0);
5622        assert_eq!(t.total_chars, 0);
5623        assert!(t.paragraphs.is_empty());
5624    }
5625
5626    #[test]
5627    fn process_text_no_paragraph_separator_single_paragraph() {
5628        // No B character anywhere → P1 produces a single paragraph
5629        // covering the whole input.
5630        let t = process_text("Hello", None);
5631        assert_eq!(t.len(), 1);
5632        assert_eq!(t.total_chars, 5);
5633        let p = &t.paragraphs[0];
5634        assert_eq!(p.byte_range, 0..5);
5635        assert_eq!(p.char_offset, 0);
5636        assert_eq!(p.bidi.paragraph_level, 0);
5637        assert_eq!(p.bidi.levels, vec![0; 5]);
5638    }
5639
5640    #[test]
5641    fn process_text_lf_terminator_kept_with_preceding_paragraph_per_p1() {
5642        // "Hi\nyo" → P1 splits into ["Hi\n", "yo"]. The first
5643        // paragraph contains the LF (which has BidiClass `B`).
5644        let t = process_text("Hi\nyo", None);
5645        assert_eq!(t.len(), 2);
5646        assert_eq!(t.total_chars, 5);
5647        let p0 = &t.paragraphs[0];
5648        let p1 = &t.paragraphs[1];
5649        assert_eq!(p0.byte_range, 0..3);
5650        assert_eq!(p0.char_offset, 0);
5651        assert_eq!(p0.bidi.levels.len(), 3);
5652        assert_eq!(p1.byte_range, 3..5);
5653        assert_eq!(p1.char_offset, 3);
5654        assert_eq!(p1.bidi.levels.len(), 2);
5655    }
5656
5657    #[test]
5658    fn process_text_terminal_lf_does_not_create_phantom_paragraph() {
5659        // "Hi\n" is one paragraph (the LF closes it but starts no new
5660        // one) — `split_paragraphs` only emits the trailing tail if it
5661        // contains any character after the last B.
5662        let t = process_text("Hi\n", None);
5663        assert_eq!(t.len(), 1);
5664        assert_eq!(t.total_chars, 3);
5665        let p = &t.paragraphs[0];
5666        assert_eq!(p.byte_range, 0..3);
5667        assert_eq!(p.bidi.classes.last(), Some(&BidiClass::B));
5668    }
5669
5670    #[test]
5671    fn process_text_per_paragraph_p2_runs_independently() {
5672        // First paragraph is Latin (L), second is Hebrew (R). Each P2
5673        // walk operates on its own paragraph slice, so paragraph
5674        // levels diverge.
5675        let t = process_text("Hi\n\u{05D0}\u{05D1}", None);
5676        assert_eq!(t.len(), 2);
5677        assert_eq!(t.paragraphs[0].bidi.paragraph_level, 0);
5678        assert_eq!(t.paragraphs[1].bidi.paragraph_level, 1);
5679    }
5680
5681    #[test]
5682    fn process_text_base_level_override_applies_uniformly() {
5683        // HL1: caller supplies base_level for both paragraphs.
5684        let t = process_text("Hi\nyo", Some(1));
5685        assert_eq!(t.len(), 2);
5686        for p in &t.paragraphs {
5687            assert_eq!(p.bidi.paragraph_level, 1);
5688        }
5689    }
5690
5691    #[test]
5692    fn process_text_char_byte_offsets_are_whole_input_indices() {
5693        // Multi-byte characters (Hebrew Alef = 2 bytes in UTF-8) +
5694        // multi-paragraph layout. The byte offsets in
5695        // `char_byte_offsets` are whole-input indices, not paragraph-
5696        // local ones.
5697        let s = "A\n\u{05D0}\u{05D1}";
5698        let t = process_text(s, None);
5699        assert_eq!(t.len(), 2);
5700        // Paragraph 0: "A\n" → chars at byte 0, 1.
5701        assert_eq!(t.paragraphs[0].char_byte_offsets, vec![0, 1]);
5702        // Paragraph 1: "אב" starting at whole-input byte 2.
5703        assert_eq!(t.paragraphs[1].char_byte_offsets, vec![2, 4]);
5704        // Verify the byte offsets index the original string correctly.
5705        for p in &t.paragraphs {
5706            for (i, &off) in p.char_byte_offsets.iter().enumerate() {
5707                let c = s[off..].chars().next().expect("char at byte offset");
5708                assert_eq!(bidi_class(c), p.bidi.classes[i]);
5709            }
5710        }
5711    }
5712
5713    #[test]
5714    fn process_text_byte_range_covers_whole_input_with_no_gap() {
5715        // The byte_range entries of consecutive paragraphs tile the
5716        // whole input contiguously.
5717        let s = "AAA\nBBB\nCCC";
5718        let t = process_text(s, None);
5719        assert_eq!(t.len(), 3);
5720        assert_eq!(t.paragraphs[0].byte_range.start, 0);
5721        assert_eq!(
5722            t.paragraphs[2].byte_range.end,
5723            s.len(),
5724            "last paragraph end equals input length",
5725        );
5726        for w in t.paragraphs.windows(2) {
5727            assert_eq!(
5728                w[0].byte_range.end, w[1].byte_range.start,
5729                "paragraph ranges tile contiguously",
5730            );
5731        }
5732    }
5733
5734    #[test]
5735    fn process_text_char_offset_accumulates_correctly() {
5736        let t = process_text("AB\nCDE\nF", None);
5737        assert_eq!(t.len(), 3);
5738        assert_eq!(t.paragraphs[0].char_offset, 0);
5739        // "AB\n" is 3 chars.
5740        assert_eq!(t.paragraphs[1].char_offset, 3);
5741        // "AB\n" (3) + "CDE\n" (4) = 7 chars before paragraph 2.
5742        assert_eq!(t.paragraphs[2].char_offset, 7);
5743        assert_eq!(t.total_chars, 8);
5744    }
5745
5746    #[test]
5747    fn text_bidi_locate_char_returns_paragraph_and_local_index() {
5748        let t = process_text("AB\nCD", None);
5749        // k = 0 → paragraph 0, local index 0.
5750        assert_eq!(t.locate_char(0), Some((0, 0)));
5751        // k = 1 → paragraph 0, local index 1.
5752        assert_eq!(t.locate_char(1), Some((0, 1)));
5753        // k = 2 → paragraph 0, local index 2 (the LF).
5754        assert_eq!(t.locate_char(2), Some((0, 2)));
5755        // k = 3 → paragraph 1, local index 0.
5756        assert_eq!(t.locate_char(3), Some((1, 0)));
5757        // k = 4 → paragraph 1, local index 1.
5758        assert_eq!(t.locate_char(4), Some((1, 1)));
5759        // k = 5 is out of bounds (total_chars = 5).
5760        assert_eq!(t.locate_char(5), None);
5761        assert_eq!(t.locate_char(99), None);
5762    }
5763
5764    #[test]
5765    fn process_text_splits_on_every_paragraph_separator_class_b_codepoint() {
5766        // P1 splits on every class-B character bidi_class() returns.
5767        // Whichever codepoints the local bidi_class() table assigns
5768        // to class B must induce a split — this guards the
5769        // bidi_class() → split_paragraphs → process_text contract end
5770        // to end without re-asserting the exact set membership.
5771        for sep in &[
5772            '\u{000A}', // LF
5773            '\u{000D}', // CR
5774            '\u{0085}', // NEL
5775            '\u{001C}', // File separator
5776            '\u{001D}', // Group separator
5777            '\u{001E}', // Record separator
5778            '\u{2029}', // PS
5779        ] {
5780            assert_eq!(
5781                bidi_class(*sep),
5782                BidiClass::B,
5783                "test-input invariant: {:#x} should be class B",
5784                *sep as u32,
5785            );
5786            let s = format!("A{sep}B");
5787            let t = process_text(&s, None);
5788            assert_eq!(t.len(), 2, "{sep:?} should split into 2 paragraphs");
5789        }
5790    }
5791
5792    #[test]
5793    fn process_text_matches_per_paragraph_process_paragraph_call() {
5794        // process_text must be observationally identical to looping
5795        // process_paragraph over split_paragraphs (modulo byte-offset
5796        // rebasing). Cross-check on a representative LTR + RTL +
5797        // mixed input.
5798        let s = "Hi \u{05D0}\u{05D1}\n\u{0627}\u{0628}\nBye";
5799        let t = process_text(s, None);
5800        let slices = split_paragraphs(s);
5801        assert_eq!(t.paragraphs.len(), slices.len());
5802        let mut byte_start = 0usize;
5803        for (carrier, slice) in t.paragraphs.iter().zip(slices.iter()) {
5804            let (expected, expected_offsets) = process_paragraph(slice, None);
5805            assert_eq!(carrier.bidi, expected);
5806            // Per-paragraph `process_paragraph` returns paragraph-
5807            // local byte offsets — add `byte_start` to match
5808            // `process_text`'s whole-input offsets.
5809            let shifted: Vec<usize> = expected_offsets.iter().map(|o| o + byte_start).collect();
5810            assert_eq!(carrier.char_byte_offsets, shifted);
5811            byte_start += slice.len();
5812        }
5813    }
5814
5815    #[test]
5816    fn process_text_total_chars_matches_input_char_count() {
5817        for s in &[
5818            "",
5819            "Hi",
5820            "Hi\nyo",
5821            "Hi\n\u{05D0}\u{05D1}",
5822            "A\nB\nC\n",
5823            "\u{05D0}\u{05D1}\n\u{0627}\u{0628}",
5824        ] {
5825            let t = process_text(s, None);
5826            assert_eq!(t.total_chars, s.chars().count(), "for input {s:?}");
5827        }
5828    }
5829
5830    #[test]
5831    fn process_text_base_level_low_bit_clamp_per_paragraph() {
5832        // base_level = 5 → low bit 1 → every paragraph RTL.
5833        let t = process_text("Hi\nyo", Some(5));
5834        for p in &t.paragraphs {
5835            assert_eq!(p.bidi.paragraph_level, 1);
5836        }
5837    }
5838
5839    #[test]
5840    fn process_text_locate_char_round_trips_with_offset_arithmetic() {
5841        // For every logical character k, locate_char(k) → (pi, ki)
5842        // should satisfy `paragraphs[pi].char_offset + ki == k`.
5843        let t = process_text("AB\nCD\nE", None);
5844        for k in 0..t.total_chars {
5845            let (pi, ki) = t.locate_char(k).expect("k in bounds");
5846            assert_eq!(t.paragraphs[pi].char_offset + ki, k);
5847        }
5848    }
5849
5850    // --- Section N0: bracket-pair lookup + BD16 + N0 rule ----------
5851
5852    #[test]
5853    fn paired_bracket_round_trips_each_ascii_pair() {
5854        // The six ASCII brackets pair up symmetrically: opener ↔
5855        // closer round-trip via paired_bracket().
5856        for (open, close) in [('(', ')'), ('[', ']'), ('{', '}')] {
5857            let (other, kind) = paired_bracket(open).expect("opener recognised");
5858            assert_eq!(other, close);
5859            assert_eq!(kind, BracketKind::Open);
5860
5861            let (other, kind) = paired_bracket(close).expect("closer recognised");
5862            assert_eq!(other, open);
5863            assert_eq!(kind, BracketKind::Close);
5864        }
5865    }
5866
5867    #[test]
5868    fn paired_bracket_returns_none_for_non_bracket_codepoints() {
5869        // Letters, digits, whitespace, punctuation, and other ON
5870        // characters are all not paired brackets.
5871        for c in [
5872            'a',
5873            'Z',
5874            '0',
5875            ' ',
5876            ',',
5877            '!',
5878            '\u{0028}'.to_ascii_uppercase(),
5879        ] {
5880            if matches!(c, '(' | ')' | '[' | ']' | '{' | '}') {
5881                continue;
5882            }
5883            assert!(paired_bracket(c).is_none(), "{c:?} should not pair");
5884        }
5885        // Non-ASCII paired brackets resolve through the full
5886        // BidiBrackets.txt table (round 283): the angle brackets
5887        // (both the deprecated U+2329/U+232A pair and the canonical
5888        // U+3008/U+3009 CJK pair) and the mathematical white square
5889        // brackets U+27E6/U+27E7.
5890        for (open, close) in [
5891            ('\u{2329}', '\u{232A}'),
5892            ('\u{3008}', '\u{3009}'),
5893            ('\u{27E6}', '\u{27E7}'),
5894        ] {
5895            assert_eq!(paired_bracket(open), Some((close, BracketKind::Open)));
5896            assert_eq!(paired_bracket(close), Some((open, BracketKind::Close)));
5897        }
5898        // Mirrored-but-unpaired (gc=Sm, not Ps/Pe): no bracket entry.
5899        for c in ['<', '>', '\u{00AB}', '\u{00BB}'] {
5900            assert!(paired_bracket(c).is_none(), "{c:?} should not pair");
5901        }
5902    }
5903
5904    #[test]
5905    fn bracket_pairs_empty_input_yields_empty_list() {
5906        let pairs = bracket_pairs(&[], &[]);
5907        assert!(pairs.is_empty());
5908    }
5909
5910    #[test]
5911    fn bracket_pairs_simple_single_pair() {
5912        // "a(b)c" — pair at (1, 3) per UAX #9 §3.1.3 worked example
5913        // "a ( b ) c" → 2-4 in the spec's 1-indexed table.
5914        let chars: Vec<char> = "a(b)c".chars().collect();
5915        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5916        assert_eq!(bracket_pairs(&chars, &classes), vec![(1, 3)]);
5917    }
5918
5919    #[test]
5920    fn bracket_pairs_unbalanced_closer_only_yields_nothing() {
5921        // "a)b(c" — closer with no opener (skipped), then opener
5922        // with no later closer (never matched). UAX #9 BD16:
5923        // "If a closing paired bracket is found ... Else, if the
5924        // current stack element is not at the bottom of the
5925        // stack, advance ... Else, continue with inspecting the
5926        // next character without popping the stack."
5927        let chars: Vec<char> = "a)b(c".chars().collect();
5928        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5929        assert!(bracket_pairs(&chars, &classes).is_empty());
5930    }
5931
5932    #[test]
5933    fn bracket_pairs_mismatched_closer_does_not_pop() {
5934        // "a(b]c" — `]` does not match the `)` opener-closer the
5935        // top-of-stack expects. UAX #9 BD16 spec table line:
5936        // "a ( b ] c   None"
5937        let chars: Vec<char> = "a(b]c".chars().collect();
5938        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5939        assert!(bracket_pairs(&chars, &classes).is_empty());
5940    }
5941
5942    #[test]
5943    fn bracket_pairs_nested_returns_sorted_by_opener() {
5944        // "a(b(c)d)" — UAX #9 BD16 spec table:
5945        // "a ( b ( c ) d )   2-8, 4-6"
5946        // We walk close-first, so the inner (4,6) is appended
5947        // before (2,8); the final sort restores opener-ascending
5948        // order: [(2,8), (4,6)] in spec 1-index → [(1,7), (3,5)]
5949        // in our 0-index.
5950        let chars: Vec<char> = "a(b(c)d)".chars().collect();
5951        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5952        assert_eq!(bracket_pairs(&chars, &classes), vec![(1, 7), (3, 5)]);
5953    }
5954
5955    #[test]
5956    fn bracket_pairs_unmatched_inner_opener_still_pairs_outer() {
5957        // "a(b[c)d]" — UAX #9 BD16 spec table:
5958        // "a ( b [ c ) d ]   2-6"
5959        // The `(` opens, `[` opens, `)` matches the deeper
5960        // matching opener — `(` — popping past `[`. Final `]` has
5961        // no live opener on the stack and is dropped.
5962        let chars: Vec<char> = "a(b[c)d]".chars().collect();
5963        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5964        assert_eq!(bracket_pairs(&chars, &classes), vec![(1, 5)]);
5965    }
5966
5967    #[test]
5968    fn bracket_pairs_curly_matches_curly() {
5969        // "a(b{c}d)" — both pairs balanced; UAX #9 BD16 spec
5970        // table: "a ( b { c } d )   2-8, 4-6".
5971        let chars: Vec<char> = "a(b{c}d)".chars().collect();
5972        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5973        assert_eq!(bracket_pairs(&chars, &classes), vec![(1, 7), (3, 5)]);
5974    }
5975
5976    #[test]
5977    fn bracket_pairs_overflow_returns_empty_list() {
5978        // BD16: "If an opening paired bracket is found and there
5979        // is no room in the stack, stop processing BD16 for the
5980        // remainder of the isolating run sequence and return an
5981        // empty list." Stack cap = 63.
5982        let chars: Vec<char> = vec!['('; 64];
5983        let classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5984        assert!(bracket_pairs(&chars, &classes).is_empty());
5985    }
5986
5987    #[test]
5988    fn bracket_pairs_non_on_position_skipped() {
5989        // BD14 / BD15: bracket must currently be ON. If a caller
5990        // hands us an `(` whose class slot has been rewritten by
5991        // X6 / RLO to R, the walker treats it as a non-bracket.
5992        let chars: Vec<char> = "a(b)c".chars().collect();
5993        let mut classes: Vec<_> = chars.iter().copied().map(bidi_class).collect();
5994        classes[1] = BidiClass::R; // simulate post-RLO rewrite
5995        assert!(bracket_pairs(&chars, &classes).is_empty());
5996    }
5997
5998    #[test]
5999    fn resolve_bracket_pairs_n0b_inside_strong_matches_embedding() {
6000        // LTR embedding + inside L → N0 b → brackets become L.
6001        // "a(b)c", embedding level 0, sos L.
6002        let chars: Vec<char> = "a(b)c".chars().collect();
6003        let mut cls: Vec<_> = chars.iter().copied().map(bidi_class).collect();
6004        let pairs = bracket_pairs(&chars, &cls);
6005        resolve_bracket_pairs(&mut cls, &pairs, 0, BidiClass::L);
6006        assert_eq!(cls[1], BidiClass::L);
6007        assert_eq!(cls[3], BidiClass::L);
6008    }
6009
6010    #[test]
6011    fn resolve_bracket_pairs_n0b_en_inside_counts_as_r() {
6012        // RTL embedding + inside EN (counted as R per N0 note) → N0 b → brackets R.
6013        // sequence: R ( EN ) R, levels=1, sos=R.
6014        let mut cls = vec![
6015            BidiClass::R,
6016            BidiClass::ON,
6017            BidiClass::EN,
6018            BidiClass::ON,
6019            BidiClass::R,
6020        ];
6021        let pairs = vec![(1usize, 3usize)];
6022        resolve_bracket_pairs(&mut cls, &pairs, 1, BidiClass::R);
6023        assert_eq!(cls[1], BidiClass::R);
6024        assert_eq!(cls[3], BidiClass::R);
6025    }
6026
6027    #[test]
6028    fn resolve_bracket_pairs_n0c1_opposite_inside_picks_preceding_strong() {
6029        // RTL embedding + inside L (opposite of R embedding) +
6030        // preceding strong is L → N0 c.1 → brackets become L.
6031        // sequence: L ( L ) ... level 1, sos R.
6032        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::L, BidiClass::ON];
6033        let pairs = vec![(1usize, 3usize)];
6034        resolve_bracket_pairs(&mut cls, &pairs, 1, BidiClass::R);
6035        assert_eq!(cls[1], BidiClass::L);
6036        assert_eq!(cls[3], BidiClass::L);
6037    }
6038
6039    #[test]
6040    fn resolve_bracket_pairs_n0c2_opposite_inside_falls_to_embedding() {
6041        // RTL embedding + inside L (opposite) + preceding strong
6042        // is R → N0 c.2 → brackets become R (embedding).
6043        // sequence: R ( L ) ... level 1, sos R.
6044        let mut cls = vec![BidiClass::R, BidiClass::ON, BidiClass::L, BidiClass::ON];
6045        let pairs = vec![(1usize, 3usize)];
6046        resolve_bracket_pairs(&mut cls, &pairs, 1, BidiClass::R);
6047        assert_eq!(cls[1], BidiClass::R);
6048        assert_eq!(cls[3], BidiClass::R);
6049    }
6050
6051    #[test]
6052    fn resolve_bracket_pairs_n0c1_uses_sos_when_no_preceding_strong() {
6053        // RTL embedding + inside L + no preceding strong + sos L
6054        // → N0 c.1 → brackets become L (sos matches inside).
6055        let mut cls = vec![BidiClass::ON, BidiClass::L, BidiClass::ON];
6056        let pairs = vec![(0usize, 2usize)];
6057        resolve_bracket_pairs(&mut cls, &pairs, 1, BidiClass::L);
6058        assert_eq!(cls[0], BidiClass::L);
6059        assert_eq!(cls[2], BidiClass::L);
6060    }
6061
6062    #[test]
6063    fn resolve_bracket_pairs_n0d_nothing_strong_leaves_pair_untouched() {
6064        // No strong inside → N0 d → leave brackets as ON for N1/N2.
6065        // sequence: L ( WS ) L, level 0.
6066        let mut cls = vec![
6067            BidiClass::L,
6068            BidiClass::ON,
6069            BidiClass::WS,
6070            BidiClass::ON,
6071            BidiClass::L,
6072        ];
6073        let pairs = vec![(1usize, 3usize)];
6074        resolve_bracket_pairs(&mut cls, &pairs, 0, BidiClass::L);
6075        assert_eq!(cls[1], BidiClass::ON);
6076        assert_eq!(cls[3], BidiClass::ON);
6077    }
6078
6079    #[test]
6080    fn resolve_bracket_pairs_sequential_lets_inner_see_outer_rewrite() {
6081        // "RTL paragraph: R ( R ( L ) R ) R" — both pairs nested.
6082        // Outer pair: inside has R → embedding match → N0 b → brackets R.
6083        // Inner pair: inside has L (opposite of R embedding) →
6084        // preceding strong from outer-pair's `(` is now R (just
6085        // rewritten by N0 on the outer pair) → N0 c.2 → brackets R.
6086        let mut cls = vec![
6087            BidiClass::R,  // 0  R
6088            BidiClass::ON, // 1  (   outer open
6089            BidiClass::R,  // 2  R
6090            BidiClass::ON, // 3  (   inner open
6091            BidiClass::L,  // 4  L
6092            BidiClass::ON, // 5  )   inner close
6093            BidiClass::R,  // 6  R
6094            BidiClass::ON, // 7  )   outer close
6095            BidiClass::R,  // 8  R
6096        ];
6097        // BD16-sorted pairs: outer (1,7), inner (3,5).
6098        let pairs = vec![(1usize, 7usize), (3usize, 5usize)];
6099        resolve_bracket_pairs(&mut cls, &pairs, 1, BidiClass::R);
6100        assert_eq!(cls[1], BidiClass::R);
6101        assert_eq!(cls[7], BidiClass::R);
6102        assert_eq!(cls[3], BidiClass::R);
6103        assert_eq!(cls[5], BidiClass::R);
6104    }
6105
6106    #[test]
6107    fn resolve_bracket_pairs_trailing_nsm_inherits_bracket_type() {
6108        // "L ( L ) NSM" — N0 b rewrites the close to L, and the
6109        // trailing NSM should adopt the bracket's L per the N0
6110        // NSM-clarification clause.
6111        let mut cls = vec![
6112            BidiClass::L,
6113            BidiClass::ON,
6114            BidiClass::L,
6115            BidiClass::ON,
6116            BidiClass::NSM,
6117        ];
6118        let pairs = vec![(1usize, 3usize)];
6119        resolve_bracket_pairs(&mut cls, &pairs, 0, BidiClass::L);
6120        assert_eq!(cls[3], BidiClass::L);
6121        assert_eq!(cls[4], BidiClass::L);
6122    }
6123
6124    #[test]
6125    fn resolve_bracket_pairs_no_pairs_is_noop() {
6126        let mut cls = vec![BidiClass::L, BidiClass::ON, BidiClass::L];
6127        let before = cls.clone();
6128        resolve_bracket_pairs(&mut cls, &[], 0, BidiClass::L);
6129        assert_eq!(cls, before);
6130    }
6131
6132    // --- Section N0: paragraph-driver wiring -----------------------
6133
6134    #[test]
6135    fn process_paragraph_with_brackets_smoke_ltr() {
6136        // "(a)" — LTR paragraph, paragraph level 0, all chars at 0.
6137        let (p, _offsets) = process_paragraph_with_brackets("(a)", None);
6138        assert_eq!(p.paragraph_level, 0);
6139        assert_eq!(p.levels, vec![0, 0, 0]);
6140    }
6141
6142    #[test]
6143    fn process_paragraph_with_brackets_n0b_promotes_brackets_in_rtl() {
6144        // RTL paragraph "AB(CD)" where AB and CD are Hebrew (R).
6145        // Inside the brackets is R → embedding match → N0 b → both
6146        // brackets become R → R-grade level (1) for both.
6147        let text = "\u{05D0}\u{05D1}(\u{05D2}\u{05D3})";
6148        let (p, _offsets) = process_paragraph_with_brackets(text, None);
6149        assert_eq!(p.paragraph_level, 1);
6150        // All chars at level 1 (R glyphs and brackets-as-R).
6151        assert_eq!(p.levels, vec![1, 1, 1, 1, 1, 1]);
6152    }
6153
6154    #[test]
6155    fn process_paragraph_with_brackets_n0c2_diverges_from_plain_n() {
6156        // RTL paragraph: `R ( L L L ) R` — brackets enclose LTR-only
6157        // text. N1 alone would resolve brackets via L↔L surround
6158        // (matching neighbours) → brackets become L. N0 c sees an
6159        // opposite-direction inside-strong L and walks back: the
6160        // preceding R matches the embedding, so N0 c.2 fires →
6161        // brackets become R. Test that the new pipeline picks
6162        // brackets at level 1 (R).
6163        let text = "\u{05D0}(abc)\u{05D1}";
6164        let (p, _offsets) = process_paragraph_with_brackets(text, None);
6165        assert_eq!(p.paragraph_level, 1);
6166        // chars: 0=R 1=( 2=a 3=b 4=c 5=) 6=R
6167        // After N0 b/c: '(' = R, ')' = R. After N1/N2: the L run
6168        // inside stays L. After I-rules: R → 1 (already at emb 1),
6169        // L at odd level → +1 = level 2. EN/AN N/A.
6170        assert_eq!(p.levels[0], 1); // R
6171        assert_eq!(p.levels[1], 1); // (
6172        assert_eq!(p.levels[2], 2); // a (L bumped under odd embedding)
6173        assert_eq!(p.levels[3], 2); // b
6174        assert_eq!(p.levels[4], 2); // c
6175        assert_eq!(p.levels[5], 1); // )
6176        assert_eq!(p.levels[6], 1); // R
6177    }
6178
6179    #[test]
6180    fn process_paragraph_with_brackets_n0d_unchanged_when_no_inside_strong() {
6181        // "(  )" — only whitespace inside, no strong type. N0 d
6182        // leaves the brackets alone; N1 then resolves them via
6183        // the sos / eos defaults. LTR paragraph (P3) → both
6184        // brackets resolve to L at level 0.
6185        let (p, _offsets) = process_paragraph_with_brackets("(  )", None);
6186        assert_eq!(p.paragraph_level, 0);
6187        assert_eq!(p.levels, vec![0, 0, 0, 0]);
6188    }
6189
6190    #[test]
6191    fn process_paragraph_classes_with_brackets_length_mismatch_panics() {
6192        let result = std::panic::catch_unwind(|| {
6193            let chars = vec!['a', 'b'];
6194            let classes = vec![BidiClass::L];
6195            let _ = process_paragraph_classes_with_brackets(&classes, &chars, None);
6196        });
6197        assert!(result.is_err(), "length mismatch must panic");
6198    }
6199
6200    // --- §3.4 rule L4: bidi mirroring (round 268) -------------------
6201
6202    #[test]
6203    fn l4_mirrored_glyph_covers_each_ascii_bracket_pair() {
6204        for (a, b) in [('(', ')'), ('[', ']'), ('{', '}')] {
6205            assert_eq!(mirrored_glyph(a), Some(b));
6206            assert_eq!(mirrored_glyph(b), Some(a));
6207        }
6208    }
6209
6210    #[test]
6211    fn l4_mirrored_glyph_is_an_involution() {
6212        for c in ['(', ')', '[', ']', '{', '}'] {
6213            let m = mirrored_glyph(c).expect("seed-set member must have a mirror");
6214            assert_eq!(mirrored_glyph(m), Some(c), "mirror of mirror returns {c}");
6215        }
6216    }
6217
6218    #[test]
6219    fn l4_mirrored_glyph_none_outside_seed_set() {
6220        for c in ['a', 'Z', '0', ' ', ',', '.', '\u{05D0}', '\u{0627}'] {
6221            assert_eq!(
6222                mirrored_glyph(c),
6223                None,
6224                "{c:?} has no mirror in the seed set"
6225            );
6226        }
6227    }
6228
6229    #[test]
6230    fn l4_ornate_parentheses_not_mirrored_per_spec_note() {
6231        // §3.4 L4 note: "for backward compatibility the characters
6232        // U+FD3E ORNATE LEFT PARENTHESIS and U+FD3F ORNATE RIGHT
6233        // PARENTHESIS are not mirrored."
6234        assert_eq!(mirrored_glyph('\u{FD3E}'), None);
6235        assert_eq!(mirrored_glyph('\u{FD3F}'), None);
6236    }
6237
6238    #[test]
6239    fn l4_mirrored_glyph_agrees_with_paired_bracket_on_seed_set() {
6240        // For the ASCII paired brackets the acceptable mirror is the
6241        // BD14 / BD15 paired character — the two lookups must agree.
6242        for c in ['(', ')', '[', ']', '{', '}'] {
6243            let (paired, _kind) = paired_bracket(c).expect("seed-set member is a paired bracket");
6244            assert_eq!(mirrored_glyph(c), Some(paired));
6245        }
6246    }
6247
6248    #[test]
6249    fn l4_even_levels_leave_characters_unchanged() {
6250        // L4 (a) fails: resolved directionality L (even level) — the
6251        // '(' keeps its un-mirrored shape per the spec worked example.
6252        let mut chars = ['(', 'a', ')'];
6253        apply_mirroring(&mut chars, &[0, 0, 0]);
6254        assert_eq!(chars, ['(', 'a', ')']);
6255        let mut chars2 = ['[', 'b', ']'];
6256        apply_mirroring(&mut chars2, &[2, 2, 2]);
6257        assert_eq!(chars2, ['[', 'b', ']']);
6258    }
6259
6260    #[test]
6261    fn l4_odd_levels_mirror_bracket_positions() {
6262        // L4 worked example: U+0028 "appears as '(' when its resolved
6263        // level is even, and as the mirrored glyph ')' when its
6264        // resolved level is odd".
6265        let mut chars = ['(', 'a', ')'];
6266        apply_mirroring(&mut chars, &[1, 1, 1]);
6267        assert_eq!(chars, [')', 'a', '(']);
6268    }
6269
6270    #[test]
6271    fn l4_mixed_levels_mirror_only_odd_positions() {
6272        // Same bracket character at different resolved levels: only
6273        // the odd-level occurrences flip.
6274        let mut chars = ['(', '(', ')', ')'];
6275        apply_mirroring(&mut chars, &[0, 1, 1, 0]);
6276        assert_eq!(chars, ['(', ')', '(', ')']);
6277    }
6278
6279    #[test]
6280    fn l4_non_mirrored_characters_untouched_at_odd_levels() {
6281        // L4 (b) fails: no Bidi_Mirrored property in the seed set —
6282        // strong letters and digits pass through at any level.
6283        let mut chars = ['\u{05D0}', 'a', '7', '.'];
6284        apply_mirroring(&mut chars, &[1, 1, 1, 1]);
6285        assert_eq!(chars, ['\u{05D0}', 'a', '7', '.']);
6286    }
6287
6288    #[test]
6289    fn l4_empty_input_no_op() {
6290        let mut chars: [char; 0] = [];
6291        apply_mirroring(&mut chars, &[]);
6292        assert!(chars.is_empty());
6293    }
6294
6295    #[test]
6296    fn l4_double_application_restores_original() {
6297        // mirrored_glyph is an involution, so running L4 twice swaps
6298        // every mirrored position back — callers run it exactly once.
6299        let original = ['{', '\u{05D0}', '}'];
6300        let mut chars = original;
6301        let levels = [1, 1, 1];
6302        apply_mirroring(&mut chars, &levels);
6303        assert_eq!(chars, ['}', '\u{05D0}', '{']);
6304        apply_mirroring(&mut chars, &levels);
6305        assert_eq!(chars, original);
6306    }
6307
6308    #[test]
6309    fn l4_length_mismatch_panics() {
6310        let result = std::panic::catch_unwind(|| {
6311            let mut chars = ['(', ')'];
6312            apply_mirroring(&mut chars, &[1]);
6313        });
6314        assert!(result.is_err(), "length mismatch must panic");
6315    }
6316
6317    #[test]
6318    fn l4_composes_with_bracket_driver_on_rtl_paragraph() {
6319        // RTL paragraph "א(ב)ג": the N0 b case resolves both brackets
6320        // to R; I2 keeps every R at the embedding level 1 (odd), so
6321        // L4 mirrors both brackets.
6322        let (p, _offsets) = process_paragraph_with_brackets("\u{05D0}(\u{05D1})\u{05D2}", None);
6323        assert_eq!(p.paragraph_level, 1);
6324        let mut chars: Vec<char> = "\u{05D0}(\u{05D1})\u{05D2}".chars().collect();
6325        apply_mirroring(&mut chars, &p.levels);
6326        assert_eq!(chars, vec!['\u{05D0}', ')', '\u{05D1}', '(', '\u{05D2}']);
6327    }
6328}