Skip to main content

panache_parser/parser/utils/
continuation.rs

1//! Continuation/blank-line handling policy.
2//!
3//! This module centralizes the parser's "should this line continue an existing container?"
4//! logic (especially across blank lines). Keeping this logic in one place reduces the
5//! risk of scattered ad-hoc heuristics diverging as blocks move into the dispatcher.
6
7use crate::options::{PandocCompat, ParserOptions};
8
9use crate::parser::block_dispatcher::{BlockContext, BlockParserRegistry};
10use crate::parser::blocks::blockquotes::{count_blockquote_markers, strip_n_blockquote_markers};
11use crate::parser::blocks::container_prefix::{ContainerPrefix, StrippedLines};
12use crate::parser::blocks::{definition_lists, html_blocks, lists, raw_blocks};
13use crate::parser::utils::container_stack::{ContainerStack, leading_indent};
14use crate::parser::utils::helpers::is_blank_line;
15
16pub(crate) struct ContinuationPolicy<'a, 'cfg> {
17    config: &'cfg ParserOptions,
18    block_registry: &'a BlockParserRegistry,
19}
20
21impl<'a, 'cfg> ContinuationPolicy<'a, 'cfg> {
22    pub(crate) fn new(
23        config: &'cfg ParserOptions,
24        block_registry: &'a BlockParserRegistry,
25    ) -> Self {
26        Self {
27            config,
28            block_registry,
29        }
30    }
31
32    fn definition_min_block_indent(&self, content_col: usize) -> usize {
33        if self.config.effective_pandoc_compat() == PandocCompat::V3_7 {
34            content_col.max(4)
35        } else {
36            content_col
37        }
38    }
39
40    pub(crate) fn compute_levels_to_keep(
41        &self,
42        current_bq_depth: usize,
43        containers: &ContainerStack,
44        lines: &[&str],
45        next_line_pos: usize,
46        next_line: &str,
47    ) -> usize {
48        let (next_bq_depth, next_inner) = count_blockquote_markers(next_line);
49        let (raw_indent_cols, _) = leading_indent(next_inner);
50        let next_marker = lists::try_parse_list_marker(
51            next_inner,
52            self.config,
53            lists::open_list_hint_at_indent(containers, raw_indent_cols),
54        );
55        let next_is_definition_marker =
56            definition_lists::try_parse_definition_marker(next_inner).is_some();
57        let next_is_definition_term = !is_blank_line(next_inner)
58            && definition_lists::next_line_is_definition_marker(lines, next_line_pos).is_some();
59
60        // Re-detect the definition marker after stripping a content-container
61        // indent (e.g. the 4-space footnote body indent). Without this, a `:`
62        // line nested inside a footnote body fails the 0-3-space marker test
63        // and the parent DefinitionList/DefinitionItem incorrectly closes
64        // across blank lines, splitting one logical item into many.
65        let stripped_is_definition_marker = |content_indent_so_far: usize| -> bool {
66            if content_indent_so_far == 0 || raw_indent_cols < content_indent_so_far {
67                return false;
68            }
69            let strip_bytes = crate::parser::utils::container_stack::byte_index_at_column(
70                next_inner,
71                content_indent_so_far,
72            );
73            if strip_bytes > next_inner.len() {
74                return false;
75            }
76            definition_lists::try_parse_definition_marker(&next_inner[strip_bytes..]).is_some()
77        };
78
79        // `current_bq_depth` is used for proper indent calculation when the next line
80        // increases blockquote nesting.
81
82        let mut keep_level = 0;
83        let mut content_indent_so_far = 0usize;
84
85        // First, account for blockquotes
86        for (i, c) in containers.stack.iter().enumerate() {
87            match c {
88                crate::parser::utils::container_stack::Container::BlockQuote { .. } => {
89                    let bq_count = containers.stack[..=i]
90                        .iter()
91                        .filter(|x| {
92                            matches!(
93                                x,
94                                crate::parser::utils::container_stack::Container::BlockQuote { .. }
95                            )
96                        })
97                        .count();
98                    if bq_count <= next_bq_depth {
99                        keep_level = i + 1;
100                    }
101                }
102                crate::parser::utils::container_stack::Container::FootnoteDefinition {
103                    content_col,
104                    ..
105                } => {
106                    content_indent_so_far += *content_col;
107                    let min_indent = (*content_col).max(4);
108                    if raw_indent_cols >= min_indent {
109                        keep_level = i + 1;
110                    }
111                }
112                crate::parser::utils::container_stack::Container::Admonition { content_col } => {
113                    // A blank line keeps the admonition open only while the
114                    // next content stays indented to its content column.
115                    content_indent_so_far += *content_col;
116                    if raw_indent_cols >= *content_col {
117                        keep_level = i + 1;
118                    }
119                }
120                crate::parser::utils::container_stack::Container::Definition {
121                    content_col,
122                    ..
123                } => {
124                    // A blank line does not necessarily end a definition, but the continuation
125                    // indent must be measured relative to any outer content containers (e.g.
126                    // footnotes). Otherwise a line indented only for the footnote would wrongly
127                    // continue the definition.
128                    let min_indent = self.definition_min_block_indent(*content_col);
129                    let effective_indent = raw_indent_cols.saturating_sub(content_indent_so_far);
130                    if effective_indent >= min_indent {
131                        keep_level = i + 1;
132                    }
133                    content_indent_so_far += *content_col;
134                }
135                crate::parser::utils::container_stack::Container::DefinitionItem { .. }
136                    if next_is_definition_marker
137                        || stripped_is_definition_marker(content_indent_so_far) =>
138                {
139                    keep_level = i + 1;
140                }
141                crate::parser::utils::container_stack::Container::DefinitionList { .. }
142                    if next_is_definition_marker
143                        || next_is_definition_term
144                        || stripped_is_definition_marker(content_indent_so_far) =>
145                {
146                    keep_level = i + 1;
147                }
148                crate::parser::utils::container_stack::Container::List {
149                    marker,
150                    base_indent_cols,
151                    ..
152                } => {
153                    let definition_ancestor_kept = containers.stack[..i]
154                        .iter()
155                        .enumerate()
156                        .rev()
157                        .find_map(|(idx, container)| {
158                            matches!(
159                                container,
160                                crate::parser::utils::container_stack::Container::Definition { .. }
161                            )
162                            .then_some(keep_level > idx)
163                        })
164                        .unwrap_or(true);
165                    if !definition_ancestor_kept {
166                        continue;
167                    }
168
169                    let effective_indent = raw_indent_cols.saturating_sub(content_indent_so_far);
170                    let continues_list = if let Some(ref marker_match) = next_marker {
171                        // Ordered markers can be right-aligned across items
172                        // (e.g. `i.`, `ii.`, `iii.`), so they need a symmetric
173                        // drift tolerance. Bullets are directional: a marker
174                        // outdented from the list's base indent belongs to an
175                        // outer list, not this one. Without that lower bound,
176                        // a blank line followed by an outer-level marker keeps
177                        // the inner list open and parks the BLANK_LINE inside
178                        // it, breaking idempotency for nested-list outputs.
179                        let indent_in_range = match marker {
180                            lists::ListMarker::Ordered(_) => {
181                                effective_indent.abs_diff(*base_indent_cols) <= 3
182                            }
183                            lists::ListMarker::Bullet(_) => {
184                                // A bullet marker at indent ≥ 4 cannot continue
185                                // a shallow-base bullet list across a blank line:
186                                // pandoc treats the would-be marker as the start
187                                // of an indented code block once the list is
188                                // ineligible to absorb it as a sublist of the
189                                // open item. The LIST_ITEM branch below still
190                                // rescues the LIST when the previous item's
191                                // content column accommodates the new indent
192                                // (keep_level is monotonic), so this guard only
193                                // closes the list when no item can absorb it.
194                                let jumps_out_of_shallow_list =
195                                    effective_indent >= 4 && *base_indent_cols < 4;
196                                if jumps_out_of_shallow_list {
197                                    false
198                                } else if effective_indent >= *base_indent_cols {
199                                    effective_indent <= base_indent_cols + 3
200                                } else {
201                                    // Bullets are directional, but only when an
202                                    // outer bullet list with matching marker can
203                                    // absorb the outdented marker. With no such
204                                    // outer list, pandoc keeps the current list
205                                    // open (the marker continues this list with
206                                    // a small leftward drift). Closing here would
207                                    // split one logical list into two and surface
208                                    // as an idempotency failure once the
209                                    // formatter normalizes indents.
210                                    let has_outer_match =
211                                        containers.stack[..i].iter().any(|outer| {
212                                            matches!(
213                                                outer,
214                                                crate::parser::utils::container_stack::Container::List {
215                                                    marker: outer_marker,
216                                                    base_indent_cols: outer_base,
217                                                    ..
218                                                } if matches!(
219                                                    outer_marker,
220                                                    lists::ListMarker::Bullet(_)
221                                                ) && lists::markers_match(
222                                                    outer_marker,
223                                                    &marker_match.marker,
224                                                    self.config.dialect,
225                                                ) && *outer_base <= effective_indent
226                                            )
227                                        });
228                                    !has_outer_match
229                                        && base_indent_cols.saturating_sub(effective_indent) <= 3
230                                }
231                            }
232                        };
233                        lists::markers_match(marker, &marker_match.marker, self.config.dialect)
234                            && indent_in_range
235                    } else {
236                        let item_content_col = containers
237                            .stack
238                            .get(i + 1)
239                            .and_then(|c| match c {
240                                crate::parser::utils::container_stack::Container::ListItem {
241                                    content_col,
242                                    ..
243                                } => Some(*content_col),
244                                _ => None,
245                            })
246                            .unwrap_or(1);
247                        effective_indent >= item_content_col
248                    };
249                    if continues_list {
250                        keep_level = i + 1;
251                    }
252                }
253                crate::parser::utils::container_stack::Container::ListItem {
254                    content_col,
255                    marker_only,
256                    ..
257                } => {
258                    let definition_ancestor_kept = containers.stack[..i]
259                        .iter()
260                        .enumerate()
261                        .rev()
262                        .find_map(|(idx, container)| {
263                            matches!(
264                                container,
265                                crate::parser::utils::container_stack::Container::Definition { .. }
266                            )
267                            .then_some(keep_level > idx)
268                        })
269                        .unwrap_or(true);
270                    if !definition_ancestor_kept {
271                        continue;
272                    }
273
274                    // CommonMark §5.2: a list item that has only seen its
275                    // marker line is closed by the first blank line. Any
276                    // subsequent indented content is no longer part of the
277                    // item. Pandoc keeps the item open across the blank.
278                    if *marker_only && self.config.dialect == crate::options::Dialect::CommonMark {
279                        // If the next line doesn't start another list marker,
280                        // the parent List has nothing to continue with — close
281                        // it too. (The List's own branch above optimistically
282                        // kept itself based on indent ≥ content_col, which
283                        // assumes a continuing item; that assumption fails
284                        // once the empty item is closed by the blank.)
285                        if next_marker.is_none() && i > 0 && keep_level == i {
286                            keep_level = i - 1;
287                        }
288                        continue;
289                    }
290
291                    let effective_indent = if next_bq_depth > current_bq_depth {
292                        let after_current_bq =
293                            strip_n_blockquote_markers(next_line, current_bq_depth);
294                        let (spaces_before_next_marker, _) = leading_indent(after_current_bq);
295                        spaces_before_next_marker.saturating_sub(content_indent_so_far)
296                    } else {
297                        raw_indent_cols.saturating_sub(content_indent_so_far)
298                    };
299
300                    let is_new_item_at_outer_level = if next_marker.is_some() {
301                        effective_indent < *content_col
302                    } else {
303                        false
304                    };
305
306                    if !is_new_item_at_outer_level && effective_indent >= *content_col {
307                        keep_level = i + 1;
308                    }
309                }
310                _ => {}
311            }
312        }
313
314        keep_level
315    }
316
317    /// Checks whether a line inside a definition should be treated as a plain continuation
318    /// (and buffered into the definition PLAIN), rather than parsed as a new block.
319    pub(crate) fn definition_plain_can_continue(
320        &self,
321        stripped_content: &str,
322        raw_content: &str,
323        content_indent: usize,
324        block_ctx: &BlockContext,
325        lines: &[&str],
326        pos: usize,
327    ) -> bool {
328        let prev_line_blank = if pos > 0 {
329            let prev_line = lines[pos - 1];
330            let (prev_bq_depth, prev_inner) = count_blockquote_markers(prev_line);
331            is_blank_line(prev_line) || (prev_bq_depth > 0 && is_blank_line(prev_inner))
332        } else {
333            false
334        };
335
336        // A blank line that isn't indented to the definition content column ends the definition.
337        let (indent_cols, _) = leading_indent(raw_content);
338        if is_blank_line(raw_content) && indent_cols < content_indent {
339            return false;
340        }
341        let min_block_indent = self.definition_min_block_indent(content_indent);
342        if prev_line_blank && indent_cols < min_block_indent {
343            return false;
344        }
345
346        // If it's a block element marker, don't continue as plain.
347        if definition_lists::try_parse_definition_marker(stripped_content).is_some()
348            && leading_indent(raw_content).0 <= 3
349            && !stripped_content.starts_with(':')
350        {
351            let is_next_definition = {
352                let prefix = ContainerPrefix::from_ctx(block_ctx);
353                let stripped = StrippedLines::new(lines, pos, &prefix);
354                self.block_registry
355                    .detect_prepared(block_ctx, &stripped)
356                    .map(|match_result| {
357                        match_result.effect
358                            == crate::parser::block_dispatcher::BlockEffect::OpenDefinitionList
359                    })
360                    .unwrap_or(false)
361            };
362            if is_next_definition {
363                return false;
364            }
365        }
366        if lists::try_parse_list_marker(stripped_content, self.config, block_ctx.open_alpha_hint)
367            .is_some()
368        {
369            if prev_line_blank {
370                return false;
371            }
372            if block_ctx.in_list {
373                return false;
374            }
375            // A list marker indented to the definition's content column opens a
376            // nested list inside the definition (matches pandoc-native), even
377            // without a separating blank line.
378            let (raw_indent_cols, _) = leading_indent(raw_content);
379            if content_indent > 0 && raw_indent_cols >= content_indent {
380                return false;
381            }
382        }
383        if count_blockquote_markers(stripped_content).0 > 0 {
384            return false;
385        }
386        if self.config.extensions.raw_html
387            && html_blocks::try_parse_html_block_start(
388                stripped_content,
389                self.config.dialect == crate::options::Dialect::CommonMark,
390            )
391            .is_some()
392        {
393            return false;
394        }
395        if self.config.extensions.raw_tex
396            && raw_blocks::extract_environment_name(stripped_content).is_some()
397        {
398            return false;
399        }
400
401        let prefix = ContainerPrefix::from_ctx(block_ctx);
402        let stripped = StrippedLines::new(lines, pos, &prefix);
403        if let Some(match_result) = self.block_registry.detect_prepared(block_ctx, &stripped) {
404            if match_result.effect == crate::parser::block_dispatcher::BlockEffect::OpenList
405                && !prev_line_blank
406            {
407                return true;
408            }
409            if match_result.effect
410                == crate::parser::block_dispatcher::BlockEffect::OpenDefinitionList
411                && match_result
412                    .payload
413                    .as_ref()
414                    .and_then(|payload| {
415                        payload
416                            .downcast_ref::<crate::parser::block_dispatcher::DefinitionPrepared>()
417                    })
418                    .is_some_and(|prepared| {
419                        matches!(
420                            prepared,
421                            crate::parser::block_dispatcher::DefinitionPrepared::Term { .. }
422                        )
423                    })
424            {
425                return true;
426            }
427            return false;
428        }
429
430        true
431    }
432}