Skip to main content

layout/flow/inline/
construct.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::borrow::Cow;
6use std::cell::LazyCell;
7use std::ops::Range;
8use std::sync::{Arc, OnceLock};
9
10use atomic_refcell::AtomicRefCell;
11use icu_properties::CodePointMapData;
12use icu_properties::props::BidiClass;
13use layout_api::LayoutNode;
14use servo_base::text::{AssumeUnder4GB, RangeAny, Utf8CodeUnits, Utf32CodeUnits};
15use style::computed_values::direction::T as Direction;
16use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
17use style::dom::NodeInfo;
18use style::selector_parser::PseudoElement;
19use unicode_bidi::Level;
20use unicode_categories::UnicodeCategories;
21
22use super::text_run::TextRun;
23use super::{
24    InlineBox, InlineBoxIdentifier, InlineBoxes, InlineFormattingContext, InlineItem,
25    SharedInlineStyles,
26};
27use crate::cell::ArcRefCell;
28use crate::context::LayoutContext;
29use crate::dom::{LayoutBox, NodeExt};
30use crate::dom_traversal::{BoxTreeString, NodeAndStyleInfo};
31use crate::flow::BlockLevelBox;
32use crate::flow::float::FloatBox;
33use crate::flow::inline::text_run::SharedTextRunData;
34use crate::flow::inline::text_transform::{OffsetMap, TextTransformationIterator};
35use crate::formatting_contexts::IndependentFormattingContext;
36use crate::positioned::AbsolutelyPositionedBox;
37use crate::style_ext::ComputedValuesExt;
38
39#[derive(Default)]
40pub(crate) struct InlineFormattingContextBuilder {
41    /// A stack of [`SharedInlineStyles`] including one for the root, one for each inline box on the
42    /// inline box stack, and importantly, one for every `display: contents` element that we are
43    /// currently processing. Normally `display: contents` elements don't affect the structure of
44    /// the [`InlineFormattingContext`], but the styles they provide do style their children.
45    pub shared_inline_styles_stack: Vec<SharedInlineStyles>,
46
47    /// The collection of text strings that make up this [`InlineFormattingContext`] under
48    /// construction.
49    pub text_segments: Vec<String>,
50
51    /// A slot used to share the full text string of the [`InlineFormattingContext`] this
52    /// builder will ultimately build.
53    pub text_content_slot: Arc<OnceLock<String>>,
54
55    /// The current offset in the final text string of this [`InlineFormattingContext`],
56    /// used to properly set the text range of new [`InlineItem::TextRun`]s.
57    current_text_offset: Utf8CodeUnits,
58
59    /// The current character offset in the final text string of this [`InlineFormattingContext`],
60    /// used to properly set the text range of new [`InlineItem::TextRun`]s. Note that this is
61    /// different from the UTF-8 code point offset.
62    current_character_offset: Utf32CodeUnits,
63
64    /// Whether the last processed node ended with whitespace. This is used to
65    /// implement rule 4 of <https://www.w3.org/TR/css-text-3/#collapse>:
66    ///
67    /// > Any collapsible space immediately following another collapsible space—even one
68    /// > outside the boundary of the inline containing that space, provided both spaces are
69    /// > within the same inline formatting context—is collapsed to have zero advance width.
70    /// > (It is invisible, but retains its soft wrap opportunity, if any.)
71    last_inline_box_ended_with_collapsible_white_space: bool,
72
73    /// Whether or not the current state of the inline formatting context is on a word boundary
74    /// for the purposes of `text-transform: capitalize`.
75    on_word_boundary: bool,
76
77    /// Whether or not this inline formatting context will contain floats.
78    pub contains_floats: bool,
79
80    /// The current list of [`InlineItem`]s in this [`InlineFormattingContext`] under
81    /// construction. This is stored in a flat list to make it easy to access the last
82    /// item.
83    pub inline_items: Vec<InlineItem>,
84
85    /// The current [`InlineBox`] tree of this [`InlineFormattingContext`] under construction.
86    pub inline_boxes: InlineBoxes,
87
88    /// The ongoing stack of inline boxes stack of the builder.
89    ///
90    /// Contains all the currently ongoing inline boxes we entered so far.
91    /// The traversal is at all times as deep in the tree as this stack is,
92    /// which is why the code doesn't need to keep track of the actual
93    /// container root (see `handle_inline_level_element`).
94    ///
95    /// When an inline box ends, it's removed from this stack.
96    inline_box_stack: Vec<InlineBoxIdentifier>,
97
98    /// Whether this [`InlineFormattingContextBuilder`] is empty for the purposes of ignoring
99    /// during box tree construction. An IFC is empty if it only contains TextRuns with
100    /// completely collapsible whitespace. When that happens it can be ignored completely.
101    pub is_empty: bool,
102
103    /// Whether this inline formatting context should process the `::first-letter` pseudo-element.
104    /// This typically starts as true, and then is set to false after processing `::first-letter`
105    /// or encountering an atomic. But it can also be false from the very beginning if this inline
106    /// formatting context doesn't contain the first formatted line.
107    should_process_first_letter: bool,
108
109    /// Whether or not the inline formatting context under construction has any kind of
110    /// right-to-left content such as a character with an RTL character class or a `dir`
111    /// attribute specifying right-to-left content.
112    pub has_right_to_left_content: bool,
113
114    /// An [`OffsetMap`] used to map selections from their offset before inline formatting
115    /// context text transformation to their offsets after transformation.
116    pub offset_map: ArcRefCell<OffsetMap>,
117}
118
119impl InlineFormattingContextBuilder {
120    /// <https://drafts.csswg.org/css-text/#white-space>:
121    /// > Except where specified otherwise, white space processing in CSS affects only the document
122    /// > white space characters: spaces (U+0020), tabs (U+0009), and segment breaks.
123    ///
124    /// From <https://github.com/w3c/csswg-drafts/issues/5147#issuecomment-637816669>:
125    /// > HTML clearly treats CR, LF, and CRLF as segment breaks.
126    ///
127    /// Other browsers also consider the form feed character (0x0c) to be document white space, it
128    /// seems.
129    ///
130    /// Taken all together, this is equivalent to the WhatWG Infra Standard's definition of ASCII
131    /// white space.
132    pub(crate) fn is_document_white_space(character: char) -> bool {
133        character.is_ascii_whitespace()
134    }
135
136    pub(crate) fn new(
137        info: &NodeAndStyleInfo,
138        context: &LayoutContext,
139        should_process_first_letter: bool,
140    ) -> Self {
141        let has_right_to_left_content = info.style.get_inherited_box().direction == Direction::Rtl;
142        Self {
143            // For the purposes of `text-transform: capitalize` the start of the IFC is a word boundary.
144            on_word_boundary: true,
145            is_empty: true,
146            shared_inline_styles_stack: vec![SharedInlineStyles::from_info_and_context(
147                info, context,
148            )],
149            has_right_to_left_content,
150            should_process_first_letter,
151            ..Default::default()
152        }
153    }
154
155    pub(crate) fn currently_processing_inline_box(&self) -> bool {
156        !self.inline_box_stack.is_empty()
157    }
158
159    fn push_control_character_string(&mut self, string_to_push: &str) {
160        self.text_segments.push(string_to_push.to_owned());
161        // string_to_push is always small
162        self.current_text_offset += Utf8CodeUnits::length_of(AssumeUnder4GB, string_to_push);
163
164        let new_characters = Utf32CodeUnits::length_of(AssumeUnder4GB, string_to_push);
165        self.current_character_offset += new_characters;
166        self.offset_map
167            .borrow_mut()
168            .push_range(new_characters, new_characters);
169    }
170
171    fn shared_inline_styles(&self) -> SharedInlineStyles {
172        self.shared_inline_styles_stack
173            .last()
174            .expect("Should always have at least one SharedInlineStyles")
175            .clone()
176    }
177
178    pub(crate) fn push_atomic(
179        &mut self,
180        independent_formatting_context_creator: impl FnOnce()
181            -> ArcRefCell<IndependentFormattingContext>,
182        old_layout_box: Option<LayoutBox>,
183    ) -> InlineItem {
184        // If there is an existing undamaged layout box that's compatible, use that.
185        let independent_formatting_context = old_layout_box
186            .and_then(|layout_box| match layout_box {
187                LayoutBox::InlineLevel(InlineItem::Atomic(atomic, ..)) => Some(atomic),
188                _ => None,
189            })
190            .unwrap_or_else(independent_formatting_context_creator);
191
192        let inline_level_box = InlineItem::Atomic(
193            independent_formatting_context,
194            self.current_text_offset,
195            Level::ltr(), /* This will be assigned later if necessary. */
196        );
197        self.inline_items.push(inline_level_box.clone());
198        self.is_empty = false;
199
200        // Push an object replacement character for this atomic, which will ensure that the line breaker
201        // inserts a line breaking opportunity here.
202        self.push_control_character_string("\u{fffc}");
203
204        self.last_inline_box_ended_with_collapsible_white_space = false;
205        self.on_word_boundary = true;
206
207        // Atomics such as images should prevent any following text as being interpreted as the first letter.
208        self.should_process_first_letter = false;
209
210        inline_level_box
211    }
212
213    pub(crate) fn push_absolutely_positioned_box(
214        &mut self,
215        absolutely_positioned_box_creator: impl FnOnce() -> ArcRefCell<AbsolutelyPositionedBox>,
216        old_layout_box: Option<LayoutBox>,
217    ) -> InlineItem {
218        let absolutely_positioned_box = old_layout_box
219            .and_then(|layout_box| match layout_box {
220                LayoutBox::InlineLevel(InlineItem::OutOfFlowAbsolutelyPositionedBox(
221                    positioned_box,
222                    ..,
223                )) => Some(positioned_box),
224                _ => None,
225            })
226            .unwrap_or_else(absolutely_positioned_box_creator);
227
228        // We cannot just reuse the old inline item, because the `current_text_offset` may have changed.
229        let inline_level_box = InlineItem::OutOfFlowAbsolutelyPositionedBox(
230            absolutely_positioned_box,
231            self.current_text_offset,
232        );
233
234        self.inline_items.push(inline_level_box.clone());
235        self.is_empty = false;
236        inline_level_box
237    }
238
239    pub(crate) fn push_float_box(
240        &mut self,
241        float_box_creator: impl FnOnce() -> ArcRefCell<FloatBox>,
242        old_layout_box: Option<LayoutBox>,
243    ) -> InlineItem {
244        let inline_level_box = old_layout_box
245            .and_then(|layout_box| match layout_box {
246                LayoutBox::InlineLevel(inline_item) => Some(inline_item),
247                _ => None,
248            })
249            .unwrap_or_else(|| InlineItem::OutOfFlowFloatBox(float_box_creator()));
250
251        debug_assert!(
252            matches!(inline_level_box, InlineItem::OutOfFlowFloatBox(..),),
253            "Created float box with incompatible `old_layout_box`"
254        );
255
256        self.inline_items.push(inline_level_box.clone());
257        self.is_empty = false;
258        self.contains_floats = true;
259        inline_level_box
260    }
261
262    pub(crate) fn push_block_level_box(&mut self, block_level: ArcRefCell<BlockLevelBox>) {
263        assert!(self.currently_processing_inline_box());
264        self.contains_floats = self.contains_floats || block_level.borrow().contains_floats();
265        self.inline_items.push(InlineItem::BlockLevel(block_level));
266    }
267
268    pub(crate) fn start_inline_box(
269        &mut self,
270        inline_box_creator: impl FnOnce() -> ArcRefCell<InlineBox>,
271        old_layout_box: Option<LayoutBox>,
272    ) -> InlineItem {
273        // If there is an existing undamaged layout box that's compatible, use the `InlineBox` within it.
274        let inline_box = old_layout_box
275            .and_then(|layout_box| match layout_box {
276                LayoutBox::InlineLevel(InlineItem::StartInlineBox(inline_box)) => Some(inline_box),
277                _ => None,
278            })
279            .unwrap_or_else(inline_box_creator);
280
281        let borrowed_inline_box = inline_box.borrow();
282
283        let style = &borrowed_inline_box.base.style;
284        self.push_control_character_string(style.bidi_control_chars().0);
285        self.has_right_to_left_content =
286            self.has_right_to_left_content || style.get_inherited_box().direction == Direction::Rtl;
287
288        self.shared_inline_styles_stack
289            .push(borrowed_inline_box.shared_inline_styles.clone());
290        std::mem::drop(borrowed_inline_box);
291
292        let identifier = self.inline_boxes.start_inline_box(inline_box.clone());
293        let inline_item = InlineItem::StartInlineBox(inline_box);
294        self.inline_items.push(inline_item.clone());
295        self.inline_box_stack.push(identifier);
296        self.is_empty = false;
297        inline_item
298    }
299
300    /// End the ongoing inline box in this [`InlineFormattingContextBuilder`], returning
301    /// shared references to all of the box tree items that were created for it. More than
302    /// a single box tree items may be produced for a single inline box when that inline
303    /// box is split around a block-level element.
304    pub(crate) fn end_inline_box(&mut self) {
305        let identifier = self
306            .inline_box_stack
307            .pop()
308            .expect("Ended non-existent inline box");
309        let inline_level_box = self.inline_boxes.get(&identifier);
310
311        self.shared_inline_styles_stack.pop();
312        self.inline_items
313            .push(InlineItem::EndInlineBox(inline_level_box.clone()));
314        self.inline_boxes.end_inline_box(identifier);
315        let bidi_control_chars = inline_level_box.borrow().base.style.bidi_control_chars();
316        self.push_control_character_string(bidi_control_chars.1);
317    }
318
319    /// This is like [`Self::push_text`], except that it might possibly add an anonymous box if
320    ///
321    ///  - This inline formatting context has a `::first-letter` style.
322    ///  - No anonymous box for `::first-letter` has been added yet.
323    ///  - First letter content is detected in this text.
324    ///
325    /// Note that this should only be used when processing text in block containers.
326    pub(crate) fn push_text_with_possible_first_letter<'dom>(
327        &mut self,
328        text: BoxTreeString<'dom>,
329        info: &NodeAndStyleInfo<'dom>,
330        container_info: &NodeAndStyleInfo<'dom>,
331        layout_context: &LayoutContext,
332    ) -> bool {
333        let selection = info.node.text_node_selection();
334        if !self.should_process_first_letter || !container_info.pseudo_element_chain().is_empty() {
335            self.push_text(text, info, selection);
336            return false;
337        }
338
339        let Some(first_letter_info) =
340            container_info.with_pseudo_element(layout_context, PseudoElement::FirstLetter)
341        else {
342            self.push_text(text, info, selection);
343            return false;
344        };
345
346        let first_letter_range = first_letter_range(&text[..]);
347        if first_letter_range.is_empty() {
348            return false;
349        }
350
351        // Push any leading white space first.
352        let first_letter_range_u32 = LazyCell::new(|| {
353            // TODO: ensure layout doesn’t handle more than 4 GiB at a time?
354            Utf32CodeUnits::length_of(AssumeUnder4GB, &text[..first_letter_range.start])..
355                Utf32CodeUnits::length_of(AssumeUnder4GB, &text[..first_letter_range.end])
356        });
357        if first_letter_range.start != 0 {
358            let leading_whitespace_range = 0..first_letter_range.start;
359            let leading_whitespace_selection_range = selection.and_then(|range| {
360                let leading_whitespace_range_u32 =
361                    RangeAny::from_start_to(first_letter_range_u32.start);
362                range.intersect(leading_whitespace_range_u32)
363            });
364
365            self.push_text(
366                Cow::Borrowed(&text[leading_whitespace_range]).into(),
367                info,
368                leading_whitespace_selection_range,
369            );
370        }
371
372        // Push the first-letter text into an anonymous box with the `::first-letter` style.
373        let box_slot = first_letter_info.node.box_slot();
374        let inline_item = self.start_inline_box(
375            || ArcRefCell::new(InlineBox::new(&first_letter_info, layout_context)),
376            None,
377        );
378        box_slot.set(LayoutBox::InlineLevel(inline_item));
379
380        let first_letter_text = Cow::Borrowed(&text[first_letter_range.clone()]);
381        let first_letter_selection_range = selection.and_then(|range| {
382            range
383                .intersect((*first_letter_range_u32).clone().into())
384                .map(|range| range.map(|offset| offset - first_letter_range_u32.start))
385        });
386        self.push_text(
387            first_letter_text.into(),
388            &first_letter_info,
389            first_letter_selection_range,
390        );
391        self.end_inline_box();
392        self.should_process_first_letter = false;
393
394        // Now push the non-first-letter text.
395        let remaining_selection_range = selection.and_then(|range| {
396            let remaining_text_range_u32 =
397                RangeAny::new(Some(first_letter_range_u32.end), range.end());
398            range
399                .intersect(remaining_text_range_u32)
400                .map(|range| range.map(|offset| offset - first_letter_range_u32.end))
401        });
402        self.push_text(
403            Cow::Borrowed(&text[first_letter_range.end..]).into(),
404            info,
405            remaining_selection_range,
406        );
407
408        true
409    }
410
411    pub(crate) fn push_text<'dom>(
412        &mut self,
413        text: BoxTreeString<'dom>,
414        info: &NodeAndStyleInfo<'dom>,
415        selection: Option<RangeAny<Utf32CodeUnits>>,
416    ) {
417        let mut offset_map = self.offset_map.borrow_mut();
418        let original_size_before = offset_map.total_original_size();
419
420        let bidi_class_map = CodePointMapData::<BidiClass>::new();
421        let white_space_collapse = info.style.get_white_space_collapse();
422        let mut character_count = Utf32CodeUnits(0);
423        let mut new_text = String::with_capacity(text.len());
424        for iteration in TextTransformationIterator::new(
425            &text,
426            &info.style,
427            self.last_inline_box_ended_with_collapsible_white_space,
428            self.on_word_boundary,
429        ) {
430            offset_map.push_iteration(&iteration);
431            for &character in iteration.characters() {
432                character_count.0 += 1;
433
434                // If this character has a strong right-to-left class the new inline formatting context will
435                // need to be BiDi-aware. This match is derived from the list of strong right-to-left classes
436                // at https://www.unicode.org/reports/tr44/#Bidi_Class_Values.
437                self.has_right_to_left_content = self.has_right_to_left_content ||
438                    matches!(
439                        bidi_class_map.get(character),
440                        BidiClass::RightToLeft |
441                            BidiClass::ArabicLetter |
442                            BidiClass::RightToLeftEmbedding |
443                            BidiClass::RightToLeftIsolate |
444                            BidiClass::RightToLeftOverride
445                    );
446
447                self.is_empty = self.is_empty &&
448                    match white_space_collapse {
449                        WhiteSpaceCollapse::Collapse => Self::is_document_white_space(character),
450                        WhiteSpaceCollapse::PreserveBreaks => {
451                            Self::is_document_white_space(character) && character != '\n'
452                        },
453                        WhiteSpaceCollapse::Preserve | WhiteSpaceCollapse::BreakSpaces => false,
454                    };
455
456                new_text.push(character)
457            }
458        }
459
460        let needs_caret_placeholder = info.node.text_node_needs_caret_placeholder();
461        self.is_empty &= !needs_caret_placeholder;
462        if new_text.is_empty() && !needs_caret_placeholder {
463            return;
464        }
465
466        if let Some(last_character) = new_text.chars().next_back() {
467            self.on_word_boundary = last_character.is_whitespace();
468            self.last_inline_box_ended_with_collapsible_white_space =
469                self.on_word_boundary && white_space_collapse != &WhiteSpaceCollapse::Preserve;
470        }
471
472        // TODO: ensure layout doesn’t handle more than 4 GiB at a time?
473        let new_text_len = Utf8CodeUnits::length_of(AssumeUnder4GB, &new_text);
474        let new_utf8_range = self.current_text_offset..self.current_text_offset + new_text_len;
475        self.current_text_offset = new_utf8_range.end;
476
477        let new_character_range =
478            self.current_character_offset..self.current_character_offset + character_count;
479        self.current_character_offset = new_character_range.end;
480
481        self.text_segments.push(new_text);
482
483        let current_inline_styles = self.shared_inline_styles();
484        let box_slot = info.node.is_text_node().then(|| info.node.box_slot());
485        let text_run = ArcRefCell::new(TextRun::new(
486            info.into(),
487            SharedTextRunData {
488                text_content: self.text_content_slot.clone(),
489                inline_styles: current_inline_styles,
490                character_range_in_ifc_text: new_character_range,
491                original_offset: original_size_before,
492                selection: AtomicRefCell::new(selection),
493                paints_caret: info.node.text_node_paints_caret(),
494                needs_caret_placeholder,
495                offset_map: self.offset_map.clone(),
496            }
497            .into(),
498            new_utf8_range,
499            box_slot
500                .as_ref()
501                .and_then(|box_slot| box_slot.take_layout_box_as_text_run()),
502        ));
503        self.inline_items
504            .push(InlineItem::TextRun(text_run.clone()));
505
506        if let Some(box_slot) = box_slot {
507            box_slot.set(LayoutBox::Text(text_run));
508        }
509    }
510
511    pub(crate) fn enter_display_contents(&mut self, shared_inline_styles: SharedInlineStyles) {
512        self.shared_inline_styles_stack.push(shared_inline_styles);
513    }
514
515    pub(crate) fn leave_display_contents(&mut self) {
516        self.shared_inline_styles_stack.pop();
517    }
518
519    /// Finish the current inline formatting context, returning [`None`] if the context was empty.
520    pub(crate) fn finish(
521        self,
522        layout_context: &LayoutContext,
523        has_first_formatted_line: bool,
524        is_single_line_text_input: bool,
525        default_bidi_level: Level,
526    ) -> Option<InlineFormattingContext> {
527        if self.is_empty {
528            return None;
529        }
530
531        assert!(self.inline_box_stack.is_empty());
532        debug_assert_eq!(
533            self.offset_map.borrow().total_final_size(),
534            self.current_character_offset
535        );
536
537        Some(InlineFormattingContext::new_with_builder(
538            self,
539            layout_context,
540            has_first_formatted_line,
541            is_single_line_text_input,
542            default_bidi_level,
543        ))
544    }
545
546    /// Push a soft wrap opportunity to this [`InlineFormattingContextBuilder`] which pushes
547    /// a zero-width space to the text content.
548    pub(crate) fn push_soft_wrap_opportunity(&mut self) {
549        self.push_control_character_string("\u{200B}");
550    }
551}
552
553/// Computes the range of the first letter.
554///
555/// The range includes any preceding punctuation and white space, and any trailing punctuation. Any
556/// non-punctuation following the letter/number/symbol of first-letter ends the range. Intervening
557/// spaces within trailing punctuation are not supported yet.
558///
559/// If the resulting range is empty, no compatible first-letter text was found.
560///
561/// <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
562fn first_letter_range(text: &str) -> Range<usize> {
563    enum State {
564        /// All characters that precede the `PrecedingWhitespaceAndPunctuation` state.
565        Start,
566        /// All preceding punctuation and intervening whitepace that precedes the `Lns` state.
567        PrecedingPunctuation,
568        /// Unicode general category L: letter, N: number and S: symbol
569        Lns,
570        /// All punctuation (but no whitespace or other characters), that
571        /// come after the `Lns` state.
572        TrailingPunctuation,
573    }
574
575    let mut start = 0;
576    let mut state = State::Start;
577    for (index, character) in text.char_indices() {
578        match &mut state {
579            State::Start => {
580                if character.is_letter() || character.is_number() || character.is_symbol() {
581                    start = index;
582                    state = State::Lns;
583                } else if character.is_punctuation() {
584                    start = index;
585                    state = State::PrecedingPunctuation
586                }
587            },
588            State::PrecedingPunctuation => {
589                if character.is_letter() || character.is_number() || character.is_symbol() {
590                    state = State::Lns;
591                } else if !character.is_separator_space() && !character.is_punctuation() {
592                    return 0..0;
593                }
594            },
595            State::Lns => {
596                // TODO: Implement support for intervening spaces
597                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
598                if character.is_punctuation() &&
599                    !character.is_punctuation_open() &&
600                    !character.is_punctuation_dash()
601                {
602                    state = State::TrailingPunctuation;
603                } else {
604                    return start..index;
605                }
606            },
607            State::TrailingPunctuation => {
608                // TODO: Implement support for intervening spaces
609                // <https://drafts.csswg.org/css-pseudo/#first-letter-pattern>
610                if character.is_punctuation() &&
611                    !character.is_punctuation_open() &&
612                    !character.is_punctuation_dash()
613                {
614                    continue;
615                } else {
616                    return start..index;
617                }
618            },
619        }
620    }
621
622    match state {
623        State::Start | State::PrecedingPunctuation => 0..0,
624        State::Lns | State::TrailingPunctuation => start..text.len(),
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    fn assert_first_letter_eq(text: &str, expected: &str) {
633        let range = first_letter_range(text);
634        assert_eq!(&text[range], expected);
635    }
636
637    #[test]
638    fn test_first_letter_range() {
639        // All spaces
640        assert_first_letter_eq("", "");
641        assert_first_letter_eq("  ", "");
642
643        // Spaces and punctuation only
644        assert_first_letter_eq("(", "");
645        assert_first_letter_eq(" (", "");
646        assert_first_letter_eq("( ", "");
647        assert_first_letter_eq("()", "");
648
649        // Invalid chars
650        assert_first_letter_eq("\u{0903}", "");
651
652        // First letter only
653        assert_first_letter_eq("A", "A");
654        assert_first_letter_eq(" A", "A");
655        assert_first_letter_eq("A ", "A");
656        assert_first_letter_eq(" A ", "A");
657
658        // Word
659        assert_first_letter_eq("App", "A");
660        assert_first_letter_eq(" App", "A");
661        assert_first_letter_eq("App ", "A");
662
663        // Preceding punctuation(s), intervening spaces and first letter
664        assert_first_letter_eq(r#""A"#, r#""A"#);
665        assert_first_letter_eq(r#" "A"#, r#""A"#);
666        assert_first_letter_eq(r#""A "#, r#""A"#);
667        assert_first_letter_eq(r#"" A"#, r#"" A"#);
668        assert_first_letter_eq(r#" "A "#, r#""A"#);
669        assert_first_letter_eq(r#"("A"#, r#"("A"#);
670        assert_first_letter_eq(r#" ("A"#, r#"("A"#);
671        assert_first_letter_eq(r#"( "A"#, r#"( "A"#);
672        assert_first_letter_eq(r#"[ ( "A"#, r#"[ ( "A"#);
673
674        // First letter and succeeding punctuation(s)
675        // TODO: modify test cases when intervening spaces in succeeding puntuations is supported
676        assert_first_letter_eq(r#"A""#, r#"A""#);
677        assert_first_letter_eq(r#"A" "#, r#"A""#);
678        assert_first_letter_eq(r#"A)]"#, r#"A)]"#);
679        assert_first_letter_eq(r#"A" )]"#, r#"A""#);
680        assert_first_letter_eq(r#"A)] >"#, r#"A)]"#);
681
682        // All
683        assert_first_letter_eq(r#" ("A" )]"#, r#"("A""#);
684        assert_first_letter_eq(r#" ("A")] >"#, r#"("A")]"#);
685
686        // Non ASCII chars
687        assert_first_letter_eq("一", "一");
688        assert_first_letter_eq(" 一 ", "一");
689        assert_first_letter_eq("一二三", "一");
690        assert_first_letter_eq(" 一二三 ", "一");
691        assert_first_letter_eq("(一二三)", "(一");
692        assert_first_letter_eq(" (一二三) ", "(一");
693        assert_first_letter_eq("((一", "((一");
694        assert_first_letter_eq(" ( (一", "( (一");
695        assert_first_letter_eq("一)", "一)");
696        assert_first_letter_eq("一))", "一))");
697        assert_first_letter_eq("一) )", "一)");
698    }
699}