Skip to main content

panache_parser/parser/blocks/
paragraphs.rs

1//! Paragraph handling utilities.
2//!
3//! Note: Most paragraph logic is in the main Parser since paragraphs
4//! are tightly integrated with container handling.
5
6use crate::options::ParserOptions;
7use rowan::GreenNodeBuilder;
8
9use crate::parser::blocks::raw_blocks::{extract_environment_name, is_inline_math_environment};
10use crate::parser::utils::container_stack::{Container, ContainerStack, OpenDisplayMath};
11use crate::parser::utils::helpers::trim_end_newlines;
12use crate::parser::utils::text_buffer::ParagraphBuffer;
13
14/// Split a trimmed line into a leading `$$...` run and the rest, if the run
15/// has at least two dollars.
16fn dollar_run(trimmed: &str) -> Option<(usize, &str)> {
17    let run_len = trimmed.bytes().take_while(|b| *b == b'$').count();
18    if run_len < 2 {
19        return None;
20    }
21    Some((run_len, &trimmed[run_len..]))
22}
23
24/// Scan a paragraph line for pandoc bracket display-math delimiters and return
25/// the open state after the line.
26///
27/// Mirrors the inline parsers (`try_parse_single_backslash_display_math` /
28/// `try_parse_double_backslash_display_math`): delimiters may share a line
29/// with content, the double form wins at a position when both extensions are
30/// enabled, and there is no escape handling inside an open region.
31fn scan_bracket_delimiters(
32    line: &str,
33    mut state: Option<OpenDisplayMath>,
34    config: &ParserOptions,
35) -> Option<OpenDisplayMath> {
36    let single = config.extensions.tex_math_single_backslash;
37    let double = config.extensions.tex_math_double_backslash;
38    if !single && !double {
39        return state;
40    }
41
42    let bytes = line.as_bytes();
43    let mut i = 0;
44    while i < bytes.len() {
45        match state {
46            None => {
47                if double && bytes[i..].starts_with(br"\\[") {
48                    state = Some(OpenDisplayMath::DoubleBrackets);
49                    i += 3;
50                } else if single && bytes[i..].starts_with(br"\[") {
51                    state = Some(OpenDisplayMath::SingleBrackets);
52                    i += 2;
53                } else {
54                    i += 1;
55                }
56            }
57            Some(OpenDisplayMath::SingleBrackets) => {
58                if bytes[i..].starts_with(br"\]") {
59                    state = None;
60                    i += 2;
61                } else {
62                    i += 1;
63                }
64            }
65            Some(OpenDisplayMath::DoubleBrackets) => {
66                if bytes[i..].starts_with(br"\\]") {
67                    state = None;
68                    i += 3;
69                } else {
70                    i += 1;
71                }
72            }
73            // Callers only pass bracket states; dollars are handled before
74            // this scan and suppress it entirely.
75            Some(OpenDisplayMath::Dollars(_)) => return state,
76        }
77    }
78    state
79}
80
81/// Track multi-line display-math regions (`$$ ... $$`, `\[ ... \]`,
82/// `\\[ ... \\]`) across buffered paragraph lines.
83///
84/// While a region of one delimiter kind is open, delimiters of the other
85/// kinds are content and are ignored; only the matching closer changes state.
86///
87/// Shared with `ListItemBuffer`, which tracks the same regions across
88/// buffered list-item lines.
89pub(in crate::parser) fn update_display_math_state(
90    line_no_newline: &str,
91    open_display_math: &mut Option<OpenDisplayMath>,
92    config: &ParserOptions,
93) {
94    match *open_display_math {
95        Some(OpenDisplayMath::Dollars(open_len)) => {
96            let trimmed = line_no_newline.trim();
97            if let Some((run_len, rest)) = dollar_run(trimmed) {
98                let is_pure_dollars = rest.is_empty();
99                let is_quarto_equation_attr_closer = rest.starts_with(char::is_whitespace) && {
100                    let attr = rest.trim_start();
101                    attr.starts_with('{') && attr.ends_with('}')
102                };
103                if (is_pure_dollars || is_quarto_equation_attr_closer) && run_len >= open_len {
104                    *open_display_math = None;
105                }
106            }
107        }
108        Some(OpenDisplayMath::SingleBrackets) | Some(OpenDisplayMath::DoubleBrackets) => {
109            *open_display_math =
110                scan_bracket_delimiters(line_no_newline, *open_display_math, config);
111        }
112        None => {
113            let trimmed = line_no_newline.trim();
114            if let Some((run_len, rest)) = dollar_run(trimmed)
115                && rest.is_empty()
116            {
117                *open_display_math = Some(OpenDisplayMath::Dollars(run_len));
118            } else {
119                *open_display_math = scan_bracket_delimiters(line_no_newline, None, config);
120            }
121        }
122    }
123}
124
125fn extract_end_environment_name(line: &str) -> Option<&str> {
126    let trimmed = line.trim_start();
127    if !trimmed.starts_with("\\end{") {
128        return None;
129    }
130    let rest = &trimmed[5..];
131    let close = rest.find('}')?;
132    let name = &rest[..close];
133    if name.is_empty() {
134        return None;
135    }
136    Some(name)
137}
138
139/// Start a paragraph if not already in one.
140///
141/// Takes a checkpoint at the current builder position so the paragraph can be
142/// retroactively wrapped as `PARAGRAPH` on close, or as `HEADING` for
143/// multi-line setext heading promotion. Nothing is emitted into the builder
144/// here; emission happens at close via `start_node_at(checkpoint, kind)`.
145pub(in crate::parser) fn start_paragraph_if_needed(
146    containers: &mut ContainerStack,
147    builder: &mut GreenNodeBuilder<'static>,
148) {
149    if !matches!(containers.last(), Some(Container::Paragraph { .. })) {
150        let start_checkpoint = builder.checkpoint();
151        containers.push(Container::Paragraph {
152            buffer: ParagraphBuffer::new(),
153            open_inline_math_envs: Vec::new(),
154            open_display_math: None,
155            start_checkpoint,
156        });
157    }
158}
159
160/// Append a line to the current paragraph (preserving losslessness).
161pub(in crate::parser) fn append_paragraph_line(
162    containers: &mut ContainerStack,
163    _builder: &mut GreenNodeBuilder<'static>,
164    line: &str,
165    config: &ParserOptions,
166) {
167    // Buffer the line (with newline for losslessness)
168    // Works for ALL paragraphs including those in blockquotes
169    if let Some(Container::Paragraph {
170        buffer,
171        open_inline_math_envs,
172        open_display_math,
173        ..
174    }) = containers.stack.last_mut()
175    {
176        buffer.push_text(line);
177
178        let line_no_newline = trim_end_newlines(line);
179        // Track display-math delimiter lines (`$$`, and `\[`/`\]` when the
180        // tex-math bracket extensions are on) so we keep multi-line display
181        // math in a single paragraph parse context. This prevents delimiter +
182        // `\begin{...}` forms from being split into PARAGRAPH + TEX_BLOCK
183        // across parse passes.
184        update_display_math_state(line_no_newline, open_display_math, config);
185        if let Some(env_name) = extract_environment_name(line_no_newline)
186            && is_inline_math_environment(env_name)
187        {
188            open_inline_math_envs.push(env_name.to_string());
189            return;
190        }
191
192        if let Some(end_name) = extract_end_environment_name(line_no_newline)
193            && open_inline_math_envs
194                .last()
195                .is_some_and(|open| open == end_name)
196        {
197            open_inline_math_envs.pop();
198        }
199    }
200}
201
202/// Buffer a blockquote marker in the current paragraph.
203///
204/// Called when processing blockquote continuation lines while a paragraph is open
205/// and using integrated inline parsing. The marker will be emitted at the correct
206/// position when the paragraph is closed.
207pub(in crate::parser) fn append_paragraph_marker(
208    containers: &mut ContainerStack,
209    leading_spaces: usize,
210    has_trailing_space: bool,
211) {
212    if let Some(Container::Paragraph { buffer, .. }) = containers.stack.last_mut() {
213        buffer.push_marker(leading_spaces, has_trailing_space);
214    }
215}
216
217pub(in crate::parser) fn has_open_inline_math_environment(containers: &ContainerStack) -> bool {
218    matches!(
219        containers.last(),
220        Some(Container::Paragraph {
221            open_inline_math_envs,
222            ..
223        }) if !open_inline_math_envs.is_empty()
224    )
225}
226
227pub(in crate::parser) fn has_open_display_math(containers: &ContainerStack) -> bool {
228    matches!(
229        containers.last(),
230        Some(Container::Paragraph {
231            open_display_math: Some(_),
232            ..
233        })
234    )
235}
236
237/// Get the current content column from the container stack.
238pub(in crate::parser) fn current_content_col(containers: &ContainerStack) -> usize {
239    containers
240        .stack
241        .iter()
242        .rev()
243        .find_map(|c| match c {
244            Container::ListItem { content_col, .. } => Some(*content_col),
245            Container::FootnoteDefinition { content_col, .. } => Some(*content_col),
246            _ => None,
247        })
248        .unwrap_or(0)
249}