Skip to main content

panache_parser/parser/blocks/
line_blocks.rs

1use crate::options::ParserOptions;
2use crate::syntax::SyntaxKind;
3use rowan::GreenNodeBuilder;
4
5use super::blockquotes::strip_n_blockquote_markers;
6use super::container_prefix::{
7    StrippedLines, advance_columns, bq_outer_of_list, strip_list_indent,
8};
9use crate::parser::utils::container_stack::byte_index_at_column;
10use crate::parser::utils::helpers::strip_newline;
11use crate::parser::utils::inline_emission;
12
13/// Try to parse the start of a line block.
14/// Returns Some(()) if this line starts a line block (| followed by space or end of line).
15pub fn try_parse_line_block_start(line: &str) -> Option<()> {
16    let trimmed = line.trim_start();
17    if trimmed.starts_with("| ") || trimmed == "|" {
18        Some(())
19    } else {
20        None
21    }
22}
23
24/// Parse a complete line block starting at current position.
25/// Returns the new position after the line block.
26///
27/// The container geometry is carried by the `StrippedLines` window: the
28/// dispatch line (`window.pos()`) keeps its bespoke `emit_open_line_prefixes`
29/// emitter — `list_marker_consumed_on_line_0` selects between a silent
30/// column-advance through the upstream-emitted list marker (`advance_columns`)
31/// and a whitespace-only strip with WHITESPACE emission (`strip_list_indent`).
32/// Continuation lines route through the window's `strip_at` (peek) and
33/// `emit_prefix_at` (re-emit prefix tokens), so the list-content-indent is
34/// always whitespace and blank lines aren't eaten by the column-advance.
35pub(crate) fn parse_line_block(
36    window: &StrippedLines<'_, '_>,
37    builder: &mut GreenNodeBuilder<'static>,
38    config: &ParserOptions,
39) -> usize {
40    let lines = window.raw();
41    let start_pos = window.pos();
42    // The 5-scalar container geometry is derived once from the window's
43    // prefix; the dispatch-line emitter (`emit_open_line_prefixes`) still
44    // consumes the scalars, while continuation lines route through the
45    // window's `strip_at` / `emit_prefix_at`.
46    let prefix = window.prefix();
47    let bq_depth = prefix.bq_depth();
48    let list_content_col = prefix.list_content_col();
49    let list_marker_consumed_on_line_0 = prefix.list_marker_consumed_on_line_0;
50    let bq_outer = bq_outer_of_list(prefix);
51    let content_indent = prefix.content_indent();
52
53    log::trace!("Parsing line block at line {}", start_pos + 1);
54
55    builder.start_node(SyntaxKind::LINE_BLOCK.into());
56
57    let mut pos = start_pos;
58    let mut first_line = true;
59
60    while pos < lines.len() {
61        let raw_line = lines[pos];
62
63        let kind = if first_line {
64            // Detection in `LineBlockParser::detect_prepared` already confirmed
65            // line 0 is a marker line; commit without a peek.
66            LineKind::Marker
67        } else {
68            let peek = window.strip_at(pos);
69            if parse_line_block_line_marker(peek).is_some() {
70                LineKind::Marker
71            } else if peek.starts_with(' ') && !peek.trim_start().starts_with("| ") {
72                LineKind::Continuation
73            } else {
74                break;
75            }
76        };
77
78        builder.start_node(SyntaxKind::LINE_BLOCK_LINE.into());
79
80        // Emit container-prefix tokens inside LINE_BLOCK_LINE so each
81        // line's byte range stays self-contained (matches the top-level
82        // line_blocks snapshot convention where LINE_BLOCK_LINE covers a
83        // whole source line).
84        let stripped = if first_line {
85            emit_open_line_prefixes(
86                builder,
87                raw_line,
88                bq_depth,
89                list_content_col,
90                list_marker_consumed_on_line_0,
91                bq_outer,
92                content_indent,
93            )
94        } else {
95            window.emit_prefix_at(builder, pos)
96        };
97
98        match kind {
99            LineKind::Marker => {
100                let content_start = parse_line_block_line_marker(stripped)
101                    .expect("marker presence verified upstream");
102                builder.token(
103                    SyntaxKind::LINE_BLOCK_MARKER.into(),
104                    &stripped[..content_start],
105                );
106                let content = &stripped[content_start..];
107                let (content_without_newline, newline_str) = strip_newline(content);
108                if !content_without_newline.is_empty() {
109                    inline_emission::emit_inlines(builder, content_without_newline, config, false);
110                }
111                if !newline_str.is_empty() {
112                    builder.token(SyntaxKind::NEWLINE.into(), newline_str);
113                }
114            }
115            LineKind::Continuation => {
116                let (line_without_newline, newline_str) = strip_newline(stripped);
117                if !line_without_newline.is_empty() {
118                    inline_emission::emit_inlines(builder, line_without_newline, config, false);
119                }
120                if !newline_str.is_empty() {
121                    builder.token(SyntaxKind::NEWLINE.into(), newline_str);
122                }
123            }
124        }
125
126        builder.finish_node(); // LineBlockLine
127        pos += 1;
128        first_line = false;
129    }
130
131    builder.finish_node(); // LineBlock
132
133    log::trace!("Parsed line block: lines {}-{}", start_pos + 1, pos);
134
135    pos
136}
137
138enum LineKind {
139    Marker,
140    Continuation,
141}
142
143/// Strip and emit the active container prefix on the dispatch line (line 0).
144/// Mirrors `prepare_fence_open_line` in `code_blocks.rs` minus the final
145/// `strip_leading_spaces` step — line blocks treat any leading spaces
146/// before `|` as part of `LINE_BLOCK_MARKER`, so we must not strip them.
147fn emit_open_line_prefixes<'a>(
148    builder: &mut GreenNodeBuilder<'static>,
149    source_line: &'a str,
150    bq_depth: usize,
151    list_content_col: usize,
152    list_marker_consumed_on_line_0: bool,
153    bq_outer: bool,
154    content_indent: usize,
155) -> &'a str {
156    let mut s: &'a str = source_line;
157    let mut pending_ws_start: Option<usize> = None;
158    let suppress_list = list_marker_consumed_on_line_0;
159
160    let flush_ws = |builder: &mut GreenNodeBuilder<'static>,
161                    pending: &mut Option<usize>,
162                    current_offset: usize| {
163        if let Some(start) = *pending
164            && current_offset > start
165        {
166            builder.token(
167                SyntaxKind::WHITESPACE.into(),
168                &source_line[start..current_offset],
169            );
170        }
171        *pending = None;
172    };
173
174    let do_strip_list = |s: &mut &'a str, pending: &mut Option<usize>| {
175        if list_content_col == 0 {
176            return;
177        }
178        let stripped = if suppress_list {
179            advance_columns(s, list_content_col)
180        } else {
181            strip_list_indent(s, list_content_col)
182        };
183        let consumed = s.len() - stripped.len();
184        if consumed > 0 {
185            let start = source_line.len() - s.len();
186            if !suppress_list && pending.is_none() {
187                *pending = Some(start);
188            }
189            *s = stripped;
190        }
191    };
192
193    let do_strip_bq =
194        |builder: &mut GreenNodeBuilder<'static>, s: &mut &'a str, pending: &mut Option<usize>| {
195            if bq_depth == 0 {
196                return;
197            }
198            let current_offset = source_line.len() - s.len();
199            flush_ws(builder, pending, current_offset);
200            *s = strip_n_blockquote_markers(s, bq_depth);
201        };
202
203    if bq_outer {
204        do_strip_bq(builder, &mut s, &mut pending_ws_start);
205        do_strip_list(&mut s, &mut pending_ws_start);
206    } else {
207        do_strip_list(&mut s, &mut pending_ws_start);
208        do_strip_bq(builder, &mut s, &mut pending_ws_start);
209    }
210
211    if content_indent > 0 {
212        let indent_bytes = byte_index_at_column(s, content_indent);
213        if s.len() >= indent_bytes && indent_bytes > 0 {
214            let start = source_line.len() - s.len();
215            if pending_ws_start.is_none() {
216                pending_ws_start = Some(start);
217            }
218            s = &s[indent_bytes..];
219        }
220    }
221
222    let final_offset = source_line.len() - s.len();
223    flush_ws(builder, &mut pending_ws_start, final_offset);
224    s
225}
226
227/// Parse a line block marker and return the index where content starts.
228/// Returns Some(index) if the line starts with "| " or just "|", None otherwise.
229fn parse_line_block_line_marker(line: &str) -> Option<usize> {
230    // Line block lines start with | followed by a space or end of line
231    // We need to handle leading whitespace (indentation)
232    let trimmed_start = line.len() - line.trim_start().len();
233    let after_indent = &line[trimmed_start..];
234
235    if after_indent.starts_with("| ") {
236        Some(trimmed_start + 2) // Skip "| "
237    } else if after_indent == "|" || after_indent == "|\n" {
238        Some(trimmed_start + 1) // Just "|", no space
239    } else {
240        None
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::super::container_prefix::ContainerPrefix;
247    use super::*;
248
249    #[test]
250    fn test_try_parse_line_block_start() {
251        assert!(try_parse_line_block_start("| Some text").is_some());
252        assert!(try_parse_line_block_start("| ").is_some());
253        assert!(try_parse_line_block_start("|").is_some()); // Empty line block
254        assert!(try_parse_line_block_start("  | Some text").is_some());
255
256        // Not line blocks
257        assert!(try_parse_line_block_start("|No space").is_none());
258        assert!(try_parse_line_block_start("Regular text").is_none());
259        assert!(try_parse_line_block_start("").is_none());
260    }
261
262    #[test]
263    fn test_parse_line_block_marker() {
264        assert_eq!(parse_line_block_line_marker("| Some text"), Some(2));
265        assert_eq!(parse_line_block_line_marker("| "), Some(2));
266        assert_eq!(parse_line_block_line_marker("|"), Some(1)); // Empty line block
267        assert_eq!(parse_line_block_line_marker("  | Indented"), Some(4));
268
269        // Not valid
270        assert_eq!(parse_line_block_line_marker("|No space"), None);
271        assert_eq!(parse_line_block_line_marker("Regular"), None);
272    }
273
274    #[test]
275    fn test_simple_line_block() {
276        let input = vec!["| Line one", "| Line two", "| Line three"];
277
278        let mut builder = GreenNodeBuilder::new();
279        let prefix = ContainerPrefix::default();
280        let window = StrippedLines::new(&input, 0, &prefix);
281        let new_pos = parse_line_block(&window, &mut builder, &ParserOptions::default());
282
283        assert_eq!(new_pos, 3);
284    }
285
286    #[test]
287    fn test_line_block_with_continuation() {
288        let input = vec![
289            "| This is a long line",
290            "  that continues here",
291            "| Second line",
292        ];
293
294        let mut builder = GreenNodeBuilder::new();
295        let prefix = ContainerPrefix::default();
296        let window = StrippedLines::new(&input, 0, &prefix);
297        let new_pos = parse_line_block(&window, &mut builder, &ParserOptions::default());
298
299        assert_eq!(new_pos, 3);
300    }
301
302    #[test]
303    fn test_line_block_with_indentation() {
304        let input = vec!["| First line", "|    Indented line", "| Back to normal"];
305
306        let mut builder = GreenNodeBuilder::new();
307        let prefix = ContainerPrefix::default();
308        let window = StrippedLines::new(&input, 0, &prefix);
309        let new_pos = parse_line_block(&window, &mut builder, &ParserOptions::default());
310
311        assert_eq!(new_pos, 3);
312    }
313
314    #[test]
315    fn test_line_block_stops_at_non_line_block() {
316        let input = vec!["| Line one", "| Line two", "Regular paragraph"];
317
318        let mut builder = GreenNodeBuilder::new();
319        let prefix = ContainerPrefix::default();
320        let window = StrippedLines::new(&input, 0, &prefix);
321        let new_pos = parse_line_block(&window, &mut builder, &ParserOptions::default());
322
323        assert_eq!(new_pos, 2); // Should stop before "Regular paragraph"
324    }
325}