Skip to main content

sphinx_ultra/rst/
lines.rs

1//! Line preprocessing: docutils `statemachine.string2lines(tab_width=8,
2//! convert_whitespace=True)` equivalent, producing the parser's line stream
3//! as index-based [`LineRec`] records over a processed source text.
4//!
5//! Probe-verified semantics (docutils 0.22.4):
6//! - `\v` / `\f` become single spaces (convert_whitespace).
7//! - Lines split on `\n`, `\r\n`, `\r` (plus Python `splitlines` exotics:
8//!   `\x1c`-`\x1e`, `\u{85}`, `\u{2028}`, `\u{2029}`).
9//! - Tabs expand to the next multiple-of-8 column (character columns).
10//! - Trailing whitespace stripped per line.
11//! - One processed line == one source line, so message line numbers are
12//!   `processed index + 1`.
13
14/// One line of the parser's stream: `(source, lineno)` provenance plus the
15/// byte range of the line's current (possibly dedented) view into the
16/// *processed* text of its source.
17///
18/// `source` indexes the parser's source-text table (entry 0 is the document
19/// itself; included files push further entries). Holding indices rather
20/// than borrows is the load-bearing choice: the table can grow mid-parse
21/// (an included file splices its lines into the running stream) without
22/// any record referencing parser-owned text, so no self-reference and no
23/// unsafe code.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct LineRec {
26    /// Index into the parser's source table (0 = the document itself).
27    pub source: u16,
28    /// 1-based line number within `source`.
29    pub lineno: u32,
30    /// Byte range of the current view into the processed text of `source`.
31    pub start: u32,
32    pub end: u32,
33    /// Cached leading-space count. Computed once at record construction and
34    /// derived arithmetically on dedent — re-scanning per nesting level made
35    /// deep nesting O(depth^3) (measured: 800-level nest took ~0.5s).
36    indent: u32,
37}
38
39impl LineRec {
40    /// Build a record over `line_text`, which must be exactly the
41    /// `start..end` slice of the source's processed text.
42    pub(crate) fn new(source: u16, lineno: u32, start: u32, end: u32, line_text: &str) -> LineRec {
43        debug_assert_eq!(line_text.len(), (end - start) as usize);
44        let indent = (line_text.len() - line_text.trim_start_matches(' ').len()) as u32;
45        LineRec {
46            source,
47            lineno,
48            start,
49            end,
50            indent,
51        }
52    }
53
54    pub(crate) fn is_blank(&self) -> bool {
55        self.start == self.end
56    }
57
58    pub(crate) fn indent(&self) -> usize {
59        self.indent as usize
60    }
61
62    /// Dedent by `n` columns (leading columns are spaces by construction;
63    /// marker lines are re-wrapped with [`LineRec::new`] instead).
64    pub(crate) fn dedented(&self, n: usize) -> LineRec {
65        let n = n.min(self.indent()) as u32;
66        LineRec {
67            start: self.start + n,
68            indent: self.indent - n,
69            ..*self
70        }
71    }
72
73    /// The line's current view within its source's processed `text`.
74    pub(crate) fn slice<'t>(&self, text: &'t str) -> &'t str {
75        &text[self.start as usize..self.end as usize]
76    }
77}
78
79/// The processed form of one source: its lines joined with `\n` (the text
80/// every [`LineRec`] range indexes) plus the single-source record stream.
81#[derive(Debug, Clone, Default)]
82pub struct Lines {
83    text: String,
84    recs: Vec<LineRec>,
85}
86
87fn is_line_boundary(c: char) -> bool {
88    matches!(
89        c,
90        '\n' | '\r' | '\x1c' | '\x1d' | '\x1e' | '\u{85}' | '\u{2028}' | '\u{2029}'
91    )
92}
93
94fn process_line(raw: &str, out: &mut String) {
95    // convert_whitespace (\v, \f -> space), then expandtabs(8), then rstrip.
96    let base = out.len();
97    let mut col = 0usize;
98    for c in raw.chars() {
99        match c {
100            '\t' => {
101                let next_stop = (col / 8 + 1) * 8;
102                for _ in col..next_stop {
103                    out.push(' ');
104                }
105                col = next_stop;
106            }
107            '\x0b' | '\x0c' => {
108                out.push(' ');
109                col += 1;
110            }
111            _ => {
112                out.push(c);
113                col += 1;
114            }
115        }
116    }
117    // Python `str.rstrip()` — [`crate::utils::py_isspace`], so a trailing
118    // \x1f (which `splitlines` does NOT eat) goes too.
119    out.truncate(base + out[base..].trim_end_matches(crate::utils::py_isspace).len());
120}
121
122impl Lines {
123    /// Process `source` into the single-source form: source id 0, linenos
124    /// `1..=n`.
125    pub fn new(source: &str) -> Lines {
126        Lines::for_source(source, 0, 1)
127    }
128
129    /// Process `source` as the table entry `source_id`, numbering its lines
130    /// from `first_lineno` (an included file numbers from 1; a sub-parse of
131    /// text lifted out of a document keeps the document's numbering).
132    pub(crate) fn for_source(source: &str, source_id: u16, first_lineno: u32) -> Lines {
133        let mut text = String::with_capacity(source.len());
134        let mut recs = Vec::new();
135        let push_line = |raw: &str, text: &mut String, recs: &mut Vec<LineRec>| {
136            if !recs.is_empty() {
137                text.push('\n');
138            }
139            let start = text.len() as u32;
140            process_line(raw, text);
141            let end = text.len() as u32;
142            let lineno = first_lineno + recs.len() as u32;
143            recs.push(LineRec::new(
144                source_id,
145                lineno,
146                start,
147                end,
148                &text[start as usize..end as usize],
149            ));
150        };
151        let bytes_len = source.len();
152        let mut line_start = 0usize;
153        let mut chars = source.char_indices().peekable();
154        while let Some((i, c)) = chars.next() {
155            if is_line_boundary(c) {
156                push_line(&source[line_start..i], &mut text, &mut recs);
157                // \r\n is one boundary
158                if c == '\r' {
159                    if let Some(&(_, '\n')) = chars.peek() {
160                        chars.next();
161                    }
162                }
163                line_start = match chars.peek() {
164                    Some(&(j, _)) => j,
165                    None => bytes_len,
166                };
167            }
168        }
169        if line_start < bytes_len {
170            push_line(&source[line_start..], &mut text, &mut recs);
171        }
172        Lines { text, recs }
173    }
174
175    #[cfg(test)]
176    pub(crate) fn recs(&self) -> &[LineRec] {
177        &self.recs
178    }
179
180    /// The processed text and the record stream, for a parser taking
181    /// ownership of both.
182    pub(crate) fn into_parts(self) -> (String, Vec<LineRec>) {
183        (self.text, self.recs)
184    }
185
186    #[cfg(test)]
187    fn line_str(&self, i: usize) -> &str {
188        self.recs[i].slice(&self.text)
189    }
190
191    #[cfg(test)]
192    fn len(&self) -> usize {
193        self.recs.len()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn tabs_expand_to_8_col_stops() {
203        assert_eq!(Lines::new("a\tb").line_str(0), "a       b");
204        assert_eq!(Lines::new("\ta").line_str(0), "        a");
205        assert_eq!(Lines::new("ab\tc").line_str(0), "ab      c");
206        assert_eq!(Lines::new("abcdefgh\tz").line_str(0), "abcdefgh        z");
207        assert_eq!(Lines::new("x\ty\tz").line_str(0), "x       y       z");
208    }
209
210    #[test]
211    fn trailing_whitespace_stripped_and_recs_map_into_processed_text() {
212        let l = Lines::new("one  \ntwo");
213        assert_eq!(l.line_str(0), "one");
214        assert_eq!(l.line_str(1), "two");
215        // Ranges index the PROCESSED text (trailing whitespace gone).
216        assert_eq!(l.text, "one\ntwo");
217        assert_eq!(
218            (l.recs[1].start as usize, l.recs[1].end as usize),
219            (4, 7),
220            "second line's range starts past the first line and its separator"
221        );
222    }
223
224    #[test]
225    fn a_three_line_document_yields_source_0_linenos_1_to_3() {
226        let l = Lines::new("one\ntwo\nthree");
227        let stream: Vec<(u16, u32)> = l.recs().iter().map(|r| (r.source, r.lineno)).collect();
228        assert_eq!(stream, vec![(0, 1), (0, 2), (0, 3)]);
229    }
230
231    #[test]
232    fn for_source_stamps_the_given_source_id_and_base_lineno() {
233        let l = Lines::for_source("a\nb", 3, 10);
234        let stream: Vec<(u16, u32)> = l.recs().iter().map(|r| (r.source, r.lineno)).collect();
235        assert_eq!(stream, vec![(3, 10), (3, 11)]);
236    }
237
238    #[test]
239    fn vertical_tab_and_formfeed_become_spaces() {
240        assert_eq!(Lines::new("a\x0bb\x0cc").line_str(0), "a b c");
241    }
242
243    #[test]
244    fn crlf_and_cr_split_without_stray_cr() {
245        let l = Lines::new("one\r\ntwo\rthree");
246        assert_eq!(l.len(), 3);
247        assert_eq!(l.line_str(0), "one");
248        assert_eq!(l.line_str(1), "two");
249        assert_eq!(l.line_str(2), "three");
250    }
251
252    #[test]
253    fn trailing_newline_produces_no_empty_last_line() {
254        assert_eq!(Lines::new("a\n").len(), 1);
255        let l = Lines::new("a\n\n");
256        assert_eq!(l.len(), 2);
257        assert!(l.recs[1].is_blank());
258    }
259
260    #[test]
261    fn indent_counts_leading_spaces() {
262        let l = Lines::new("    four\n\tone-tab");
263        assert_eq!(l.recs[0].indent(), 4);
264        assert_eq!(l.recs[1].indent(), 8);
265    }
266
267    #[test]
268    fn dedent_moves_the_view_start_and_shrinks_the_cached_indent() {
269        let l = Lines::new("    body");
270        let d = l.recs[0].dedented(4);
271        assert_eq!(d.slice(&l.text), "body");
272        assert_eq!(d.indent(), 0);
273        assert_eq!(d.lineno, 1);
274    }
275}