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