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}