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, ¶graph_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, ¶graph_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}