panache_parser/parser/utils/
list_item_buffer.rs1use crate::options::{Dialect, ParserOptions};
7use crate::parser::blocks::container_prefix::{
8 ContainerPrefixLine, ContainerPrefixState, emit_container_prefix_tokens,
9};
10use crate::parser::blocks::headings::{emit_atx_heading, try_parse_atx_heading};
11use crate::parser::blocks::horizontal_rules::{emit_horizontal_rule, try_parse_horizontal_rule};
12use crate::parser::blocks::html_blocks::{
13 HtmlBlockType, count_tag_balance, is_pandoc_matched_pair_tag, try_parse_html_block_start,
14};
15use crate::parser::blocks::paragraphs::update_display_math_state;
16use crate::parser::utils::container_stack::OpenDisplayMath;
17use crate::parser::utils::helpers::trim_end_newlines;
18use crate::parser::utils::inline_emission;
19use crate::parser::utils::text_buffer::ParagraphBuffer;
20use crate::syntax::{SyntaxKind, SyntaxNode};
21use rowan::{GreenNodeBuilder, TextSize};
22
23#[derive(Debug, Clone)]
25pub(crate) enum ListItemContent {
26 Text(String),
28 BlockquoteMarker {
30 leading_spaces: usize,
31 has_trailing_space: bool,
32 },
33}
34
35#[derive(Debug, Default, Clone)]
43pub(crate) struct ListItemBuffer {
44 segments: Vec<ListItemContent>,
46 open_display_math: Option<OpenDisplayMath>,
51}
52
53impl ListItemBuffer {
54 pub(crate) fn new() -> Self {
56 Self {
57 segments: Vec::new(),
58 open_display_math: None,
59 }
60 }
61
62 pub(crate) fn push_text(&mut self, text: impl Into<String>, config: &ParserOptions) {
65 let text = text.into();
66 if text.is_empty() {
67 return;
68 }
69 for line in text.split_inclusive('\n') {
70 update_display_math_state(trim_end_newlines(line), &mut self.open_display_math, config);
71 }
72 self.segments.push(ListItemContent::Text(text));
73 }
74
75 pub(crate) fn has_open_display_math(&self) -> bool {
77 self.open_display_math.is_some()
78 }
79
80 pub(crate) fn push_blockquote_marker(
81 &mut self,
82 leading_spaces: usize,
83 has_trailing_space: bool,
84 ) {
85 self.segments.push(ListItemContent::BlockquoteMarker {
86 leading_spaces,
87 has_trailing_space,
88 });
89 }
90
91 pub(crate) fn is_empty(&self) -> bool {
93 self.segments.is_empty()
94 }
95
96 pub(crate) fn segment_count(&self) -> usize {
98 self.segments.len()
99 }
100
101 pub(crate) fn first_text(&self) -> Option<&str> {
103 match self.segments.first()? {
104 ListItemContent::Text(t) => Some(t.as_str()),
105 ListItemContent::BlockquoteMarker { .. } => None,
106 }
107 }
108
109 pub(crate) fn unclosed_pandoc_matched_pair_tag(
121 &self,
122 config: &ParserOptions,
123 ) -> Option<String> {
124 if config.dialect != Dialect::Pandoc {
125 return None;
126 }
127 let first = self.first_text()?;
128 let first_line_with_nl = first.split_inclusive('\n').next()?;
129 let first_line_no_nl = first_line_with_nl
130 .strip_suffix("\r\n")
131 .or_else(|| first_line_with_nl.strip_suffix('\n'))
132 .unwrap_or(first_line_with_nl);
133 let HtmlBlockType::BlockTag {
134 tag_name,
135 is_closing: false,
136 ..
137 } = try_parse_html_block_start(first_line_no_nl, false)?
138 else {
139 return None;
140 };
141 if !is_pandoc_matched_pair_tag(&tag_name) {
142 return None;
143 }
144 let mut opens = 0usize;
145 let mut closes = 0usize;
146 for segment in &self.segments {
147 if let ListItemContent::Text(t) = segment {
148 let (o, c) = count_tag_balance(t, &tag_name);
149 opens += o;
150 closes += c;
151 }
152 }
153 if opens > closes { Some(tag_name) } else { None }
154 }
155
156 pub(crate) fn has_blank_lines_between_content(&self) -> bool {
161 log::trace!(
162 "has_blank_lines_between_content: segments={} result=false",
163 self.segments.len()
164 );
165
166 false
167 }
168
169 fn get_text_for_parsing(&self) -> String {
171 let mut result = String::new();
172 for segment in &self.segments {
173 if let ListItemContent::Text(text) = segment {
174 result.push_str(text);
175 }
176 }
177 result
178 }
179
180 fn to_paragraph_buffer(&self) -> ParagraphBuffer {
181 let mut paragraph_buffer = ParagraphBuffer::new();
182 for segment in &self.segments {
183 match segment {
184 ListItemContent::Text(text) => paragraph_buffer.push_text(text),
185 ListItemContent::BlockquoteMarker {
186 leading_spaces,
187 has_trailing_space,
188 } => paragraph_buffer.push_marker(*leading_spaces, *has_trailing_space),
189 }
190 }
191 paragraph_buffer
192 }
193
194 pub(crate) fn emit_as_block(
210 &self,
211 builder: &mut GreenNodeBuilder<'static>,
212 use_paragraph: bool,
213 config: &ParserOptions,
214 content_col: usize,
215 suppress_footnote_refs: bool,
216 ) {
217 if self.is_empty() {
218 return;
219 }
220
221 let text = self.get_text_for_parsing();
223
224 if !text.is_empty() {
225 let line_without_newline = text
226 .strip_suffix("\r\n")
227 .or_else(|| text.strip_suffix('\n'));
228 if let Some(line) = line_without_newline
229 && !line.contains('\n')
230 && !line.contains('\r')
231 {
232 let detect_line = &line[item_indent_prefix_len(line, content_col)..];
239 if let Some(level) = try_parse_atx_heading(detect_line) {
240 emit_atx_heading(builder, &text, level, config);
241 return;
242 }
243 if try_parse_horizontal_rule(detect_line).is_some() {
244 emit_horizontal_rule(builder, &text);
245 return;
246 }
247 }
248
249 if self
254 .segments
255 .iter()
256 .all(|s| matches!(s, ListItemContent::Text(_)))
257 && let Some(first_nl) = text.find('\n')
258 {
259 let first_line = &text[..first_nl];
260 let after_first = &text[first_nl + 1..];
261 let detect_first = &first_line[item_indent_prefix_len(first_line, content_col)..];
262 if !after_first.is_empty()
263 && let Some(level) = try_parse_atx_heading(detect_first)
264 {
265 let heading_bytes = &text[..first_nl + 1];
266 emit_atx_heading(builder, heading_bytes, level, config);
267
268 let block_kind = if use_paragraph {
269 SyntaxKind::PARAGRAPH
270 } else {
271 SyntaxKind::PLAIN
272 };
273 builder.start_node(block_kind.into());
274 inline_emission::emit_inlines(
275 builder,
276 after_first,
277 config,
278 suppress_footnote_refs,
279 );
280 builder.finish_node();
281 return;
282 }
283 }
284
285 if config.dialect == Dialect::Pandoc
300 && self
301 .segments
302 .iter()
303 .all(|s| matches!(s, ListItemContent::Text(_)))
304 && try_emit_html_block_lift(builder, &text, config, content_col, use_paragraph, "")
305 {
306 return;
307 }
308
309 if self
318 .segments
319 .iter()
320 .all(|s| matches!(s, ListItemContent::Text(_)))
321 && try_emit_table_or_div_lift(builder, &text, config, content_col)
322 {
323 return;
324 }
325 }
326
327 let block_kind = if use_paragraph {
328 SyntaxKind::PARAGRAPH
329 } else {
330 SyntaxKind::PLAIN
331 };
332
333 builder.start_node(block_kind.into());
334
335 let paragraph_buffer = self.to_paragraph_buffer();
336 if !paragraph_buffer.is_empty() {
337 paragraph_buffer.emit_with_inlines(builder, config, suppress_footnote_refs);
338 } else if !text.is_empty() {
339 inline_emission::emit_inlines(builder, &text, config, suppress_footnote_refs);
340 }
341
342 builder.finish_node(); }
344
345 pub(crate) fn clear(&mut self) {
350 self.segments.clear();
351 self.open_display_math = None;
352 }
353}
354
355pub(crate) fn try_emit_html_block_lift(
384 builder: &mut GreenNodeBuilder<'static>,
385 text: &str,
386 config: &ParserOptions,
387 content_col: usize,
388 use_paragraph: bool,
389 line0_prefix: &str,
390) -> bool {
391 let first_line = text.split_inclusive('\n').next().unwrap_or(text);
392 let first_line_no_nl = first_line
393 .strip_suffix("\r\n")
394 .or_else(|| first_line.strip_suffix('\n'))
395 .unwrap_or(first_line);
396 if try_parse_html_block_start(first_line_no_nl, false).is_none() {
397 return false;
398 }
399
400 let (parse_text, mut prefixes) = if content_col > 0 {
401 strip_list_item_indent(text, content_col)
402 } else {
403 (text.to_string(), Vec::new())
404 };
405 if !line0_prefix.is_empty() {
406 if prefixes.is_empty() {
407 prefixes.push(line0_prefix.to_string());
408 } else {
409 prefixes[0] = line0_prefix.to_string();
410 }
411 }
412
413 let refdefs = config.refdef_labels.clone().unwrap_or_default();
414 let inner_root = crate::parser::parse_with_refdefs(&parse_text, Some(config.clone()), refdefs);
415
416 let children: Vec<SyntaxNode> = inner_root.children().collect();
417 if children.is_empty() {
418 return false;
419 }
420 let first = &children[0];
421 if !matches!(
422 first.kind(),
423 SyntaxKind::HTML_BLOCK | SyntaxKind::HTML_BLOCK_RAW | SyntaxKind::HTML_BLOCK_DIV
424 ) {
425 return false;
426 }
427 let total_end = children.last().unwrap().text_range().end();
428 if total_end != TextSize::of(parse_text.as_str()) {
429 return false;
430 }
431
432 let multi_child_trailing = if children.len() == 1 {
449 false
450 } else if children.len() == 2
451 && matches!(
452 first.kind(),
453 SyntaxKind::HTML_BLOCK | SyntaxKind::HTML_BLOCK_RAW | SyntaxKind::HTML_BLOCK_DIV
454 )
455 && children[1].kind() == SyntaxKind::PARAGRAPH
456 {
457 true
458 } else {
459 return false;
460 };
461
462 if first.kind() == SyntaxKind::HTML_BLOCK_DIV {
463 let html_block_tag_count = first
464 .children()
465 .filter(|c| c.kind() == SyntaxKind::HTML_BLOCK_TAG)
466 .count();
467 if html_block_tag_count < 2 {
468 return false;
469 }
470 }
471
472 let prefix_lines: Vec<ContainerPrefixLine> = prefixes
473 .into_iter()
474 .map(ContainerPrefixLine::list_only)
475 .collect();
476 let mut prefix_state = ContainerPrefixState::new(prefix_lines);
477 if multi_child_trailing {
478 graft_node(builder, first, &mut prefix_state);
479 let trailing_kind = if use_paragraph {
480 SyntaxKind::PARAGRAPH
481 } else {
482 SyntaxKind::PLAIN
483 };
484 graft_node_retag_root(builder, &children[1], &mut prefix_state, trailing_kind);
485 } else {
486 graft_node(builder, first, &mut prefix_state);
487 }
488 true
489}
490
491fn try_emit_table_or_div_lift(
499 builder: &mut GreenNodeBuilder<'static>,
500 text: &str,
501 config: &ParserOptions,
502 content_col: usize,
503) -> bool {
504 let first_line = text.split_inclusive('\n').next().unwrap_or(text);
505 let first_line_no_nl = first_line
506 .strip_suffix("\r\n")
507 .or_else(|| first_line.strip_suffix('\n'))
508 .unwrap_or(first_line);
509 let trimmed = first_line_no_nl.trim_start();
510 let first_byte = trimmed.as_bytes().first().copied();
511 if !matches!(first_byte, Some(b'|') | Some(b'+') | Some(b':')) {
512 return false;
513 }
514
515 let (parse_text, prefixes) = if content_col > 0 {
516 strip_list_item_indent(text, content_col)
517 } else {
518 (text.to_string(), Vec::new())
519 };
520
521 let refdefs = config.refdef_labels.clone().unwrap_or_default();
522 let inner_root = crate::parser::parse_with_refdefs(&parse_text, Some(config.clone()), refdefs);
523
524 let children: Vec<SyntaxNode> = inner_root.children().collect();
525 if children.len() != 1 {
526 return false;
527 }
528 let first = &children[0];
529 if !matches!(
530 first.kind(),
531 SyntaxKind::PIPE_TABLE | SyntaxKind::GRID_TABLE | SyntaxKind::FENCED_DIV
532 ) {
533 return false;
534 }
535 if first.text_range().end() != TextSize::of(parse_text.as_str()) {
536 return false;
537 }
538
539 let prefix_lines: Vec<ContainerPrefixLine> = prefixes
540 .into_iter()
541 .map(ContainerPrefixLine::list_only)
542 .collect();
543 let mut prefix_state = ContainerPrefixState::new(prefix_lines);
544 graft_node(builder, first, &mut prefix_state);
545 true
546}
547
548fn graft_node_retag_root(
549 builder: &mut GreenNodeBuilder<'static>,
550 node: &SyntaxNode,
551 prefix: &mut Option<ContainerPrefixState>,
552 new_kind: SyntaxKind,
553) {
554 builder.start_node(new_kind.into());
555 for child in node.children_with_tokens() {
556 match child {
557 rowan::NodeOrToken::Node(n) => graft_node(builder, &n, prefix),
558 rowan::NodeOrToken::Token(t) => {
559 emit_grafted_token(builder, t.kind(), t.text(), prefix);
560 }
561 }
562 }
563 builder.finish_node();
564}
565
566fn item_indent_prefix_len(line: &str, content_col: usize) -> usize {
574 let mut consumed = 0usize;
575 let mut col = 0usize;
576 for &b in line.as_bytes() {
577 if col >= content_col {
578 break;
579 }
580 match b {
581 b' ' => {
582 col += 1;
583 consumed += 1;
584 }
585 b'\t' => {
586 let next = (col / 4 + 1) * 4;
587 if next > content_col {
588 break;
589 }
590 col = next;
591 consumed += 1;
592 }
593 _ => break,
594 }
595 }
596 consumed
597}
598
599fn strip_list_item_indent(text: &str, content_col: usize) -> (String, Vec<String>) {
600 let mut stripped = String::with_capacity(text.len());
601 let mut prefixes: Vec<String> = Vec::new();
602 for (i, line) in text.split_inclusive('\n').enumerate() {
603 if i == 0 {
604 prefixes.push(String::new());
605 stripped.push_str(line);
606 continue;
607 }
608 let consumed = item_indent_prefix_len(line, content_col);
609 prefixes.push(line[..consumed].to_string());
610 stripped.push_str(&line[consumed..]);
611 }
612 (stripped, prefixes)
613}
614
615fn graft_node(
616 builder: &mut GreenNodeBuilder<'static>,
617 node: &SyntaxNode,
618 prefix: &mut Option<ContainerPrefixState>,
619) {
620 builder.start_node(node.kind().into());
621 for child in node.children_with_tokens() {
622 match child {
623 rowan::NodeOrToken::Node(n) => graft_node(builder, &n, prefix),
624 rowan::NodeOrToken::Token(t) => {
625 emit_grafted_token(builder, t.kind(), t.text(), prefix);
626 }
627 }
628 }
629 builder.finish_node();
630}
631
632fn emit_grafted_token(
633 builder: &mut GreenNodeBuilder<'static>,
634 kind: SyntaxKind,
635 text: &str,
636 prefix: &mut Option<ContainerPrefixState>,
637) {
638 if let Some(state) = prefix.as_mut() {
639 if state.at_line_start {
640 if let Some(line_prefix) = state.prefixes.get(state.line_idx) {
641 emit_container_prefix_tokens(builder, line_prefix);
642 }
643 state.at_line_start = false;
644 }
645 builder.token(kind.into(), text);
646 if kind == SyntaxKind::NEWLINE || kind == SyntaxKind::BLANK_LINE {
647 state.line_idx += 1;
648 state.at_line_start = true;
649 }
650 } else {
651 builder.token(kind.into(), text);
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn test_new_buffer_is_empty() {
661 let buffer = ListItemBuffer::new();
662 assert!(buffer.is_empty());
663 assert!(!buffer.has_blank_lines_between_content());
664 }
665
666 #[test]
667 fn test_push_single_text() {
668 let mut buffer = ListItemBuffer::new();
669 buffer.push_text("Hello, world!", &ParserOptions::default());
670 assert!(!buffer.is_empty());
671 assert!(!buffer.has_blank_lines_between_content());
672 assert_eq!(buffer.get_text_for_parsing(), "Hello, world!");
673 }
674
675 #[test]
676 fn test_push_multiple_text_segments() {
677 let mut buffer = ListItemBuffer::new();
678 let config = ParserOptions::default();
679 buffer.push_text("Line 1\n", &config);
680 buffer.push_text("Line 2\n", &config);
681 buffer.push_text("Line 3", &config);
682 assert_eq!(buffer.get_text_for_parsing(), "Line 1\nLine 2\nLine 3");
683 }
684
685 #[test]
686 fn test_clear_buffer() {
687 let mut buffer = ListItemBuffer::new();
688 buffer.push_text("Some text", &ParserOptions::default());
689 assert!(!buffer.is_empty());
690
691 buffer.clear();
692 assert!(buffer.is_empty());
693 assert_eq!(buffer.get_text_for_parsing(), "");
694 }
695
696 #[test]
697 fn test_empty_text_ignored() {
698 let mut buffer = ListItemBuffer::new();
699 buffer.push_text("", &ParserOptions::default());
700 assert!(buffer.is_empty());
701 }
702
703 #[test]
704 fn test_display_math_state_tracks_and_resets_on_clear() {
705 let mut config = ParserOptions::default();
706 config.extensions.tex_math_single_backslash = true;
707
708 let mut buffer = ListItemBuffer::new();
709 buffer.push_text("\\[\n", &config);
710 assert!(buffer.has_open_display_math());
711 buffer.push_text("x = 1 \\]\n", &config);
712 assert!(!buffer.has_open_display_math());
713
714 buffer.push_text("$$\n", &config);
715 assert!(buffer.has_open_display_math());
716 buffer.clear();
717 assert!(!buffer.has_open_display_math());
718 }
719}