panache_parser/parser/blocks/html_blocks.rs
1//! HTML block parsing utilities.
2
3use crate::options::ParserOptions;
4use crate::parser::inlines::inline_html::{parse_close_tag, parse_open_tag};
5use crate::syntax::{SyntaxKind, SyntaxNode};
6use rowan::GreenNodeBuilder;
7
8use super::blockquotes::{count_blockquote_markers, strip_n_blockquote_markers};
9use super::container_prefix::{
10 ContainerPrefix, ContainerPrefixLine, ContainerPrefixState, emit_container_prefix_tokens,
11};
12use crate::parser::utils::attributes::emit_html_attrs_node;
13use crate::parser::utils::helpers::{strip_leading_spaces, strip_newline};
14
15/// HTML block-level tags as defined by CommonMark spec.
16/// These tags start an HTML block when found at the start of a line.
17const BLOCK_TAGS: &[&str] = &[
18 "address",
19 "article",
20 "aside",
21 "base",
22 "basefont",
23 "blockquote",
24 "body",
25 "caption",
26 "center",
27 "col",
28 "colgroup",
29 "dd",
30 "details",
31 "dialog",
32 "dir",
33 "div",
34 "dl",
35 "dt",
36 "fieldset",
37 "figcaption",
38 "figure",
39 "footer",
40 "form",
41 "frame",
42 "frameset",
43 "h1",
44 "h2",
45 "h3",
46 "h4",
47 "h5",
48 "h6",
49 "head",
50 "header",
51 "hr",
52 "html",
53 "iframe",
54 "legend",
55 "li",
56 "link",
57 "main",
58 "menu",
59 "menuitem",
60 "nav",
61 "noframes",
62 "ol",
63 "optgroup",
64 "option",
65 "p",
66 "param",
67 "section",
68 "source",
69 "summary",
70 "table",
71 "tbody",
72 "td",
73 "tfoot",
74 "th",
75 "thead",
76 "title",
77 "tr",
78 "track",
79 "ul",
80];
81
82/// Tags that contain raw/verbatim content (no Markdown processing inside).
83const VERBATIM_TAGS: &[&str] = &["script", "style", "pre", "textarea"];
84
85/// Pandoc's `blockHtmlTags` (mirrors
86/// `pandoc/src/Text/Pandoc/Readers/HTML/TagCategories.hs`). Pandoc-markdown
87/// uses this narrower set rather than CommonMark §4.6 type-6: it omits a
88/// number of CM type-6 tags (e.g. `dialog`, `legend`, `optgroup`, `option`,
89/// `frame`, `link`, `param`, `base`, `basefont`, `menuitem`) that pandoc
90/// treats as raw inline HTML, and adds a few pandoc keeps as block-level
91/// (`canvas`, `hgroup`, `isindex`, `meta`, `output`).
92///
93/// Pandoc's `eitherBlockOrInline` set (`audio`, `button`, `iframe`,
94/// `noscript`, `object`, `map`, `progress`, `video`, `del`, `ins`, `svg`,
95/// `applet`, plus the void elements `embed`, `area`, `source`, `track`
96/// and the verbatim `script`) is tracked separately as
97/// [`PANDOC_INLINE_BLOCK_TAGS`]. Those tags act as block starters at
98/// fresh-block positions but stay inline inside an existing HTML block
99/// (e.g. `<form><input><button>X</button></form>`); the projector's
100/// `split_html_block_by_tags` keys on `inline_pending` to keep them
101/// inline once an inline-only tag or text byte has been seen since the
102/// last splitter.
103const PANDOC_BLOCK_TAGS: &[&str] = &[
104 "address",
105 "article",
106 "aside",
107 "blockquote",
108 "body",
109 "canvas",
110 "caption",
111 "center",
112 "col",
113 "colgroup",
114 "dd",
115 "details",
116 "dir",
117 "div",
118 "dl",
119 "dt",
120 "fieldset",
121 "figcaption",
122 "figure",
123 "footer",
124 "form",
125 "frameset",
126 "h1",
127 "h2",
128 "h3",
129 "h4",
130 "h5",
131 "h6",
132 "head",
133 "header",
134 "hgroup",
135 "hr",
136 "html",
137 "isindex",
138 "li",
139 "main",
140 "menu",
141 "meta",
142 "nav",
143 "noframes",
144 "ol",
145 "output",
146 "p",
147 "pre",
148 "script",
149 "section",
150 "style",
151 "summary",
152 "table",
153 "tbody",
154 "td",
155 "textarea",
156 "tfoot",
157 "th",
158 "thead",
159 "tr",
160 "ul",
161];
162
163/// Whether `name` (case-insensitive) is one of the HTML block-level tags
164/// recognized by CommonMark §4.6 type-6.
165pub fn is_html_block_tag_name(name: &str) -> bool {
166 let lower = name.to_ascii_lowercase();
167 BLOCK_TAGS.contains(&lower.as_str())
168}
169
170/// Whether `name` (case-insensitive) is one of pandoc's `blockHtmlTags` —
171/// the narrower set pandoc-markdown's `htmlBlock` reader recognizes.
172/// Used by the pandoc-native projector's `split_html_block_by_tags` to
173/// decide whether a complete HTML tag inside an `HTML_BLOCK` should split
174/// the block — block-level tags emit as separate `RawBlock` entries;
175/// inline tags stay inline in the surrounding `Plain` content.
176pub fn is_pandoc_block_tag_name(name: &str) -> bool {
177 let lower = name.to_ascii_lowercase();
178 PANDOC_BLOCK_TAGS.contains(&lower.as_str())
179}
180
181/// Pandoc's `eitherBlockOrInline` set (mirrors
182/// `pandoc/src/Text/Pandoc/Readers/HTML/TagCategories.hs`): tags that
183/// `isBlockTag` accepts as block starters but `isInlineTag` ALSO accepts
184/// (because `name ∉ blockTags`). At top level (or after a blank line)
185/// pandoc treats `<iframe>foo</iframe>` as RawBlock+Plain+RawBlock, but
186/// inside an existing HTML block once a paragraph has started parsing,
187/// the same tag stays inline as `RawInline`.
188///
189/// The projector's `split_html_block_by_tags` mirrors this with an
190/// `inline_pending` flag — strict block tags ([`PANDOC_BLOCK_TAGS`])
191/// always split; inline-block tags split only when no inline content
192/// has been buffered since the last splitter.
193///
194/// Void elements (`area`, `embed`, `source`, `track`) live in
195/// [`PANDOC_VOID_BLOCK_TAGS`]; they follow the same `inline_pending`
196/// rule as non-void inline-block tags but emit a single RawBlock per
197/// instance instead of a matched-pair lift.
198/// `script` is omitted because it is already verbatim (handled by the
199/// `<script>...</script>` raw-text path) and the strict-block check
200/// fires first regardless.
201const PANDOC_INLINE_BLOCK_TAGS: &[&str] = &[
202 "applet", "audio", "button", "del", "iframe", "ins", "map", "noscript", "object", "progress",
203 "svg", "video",
204];
205
206/// Whether `name` (case-insensitive) is one of pandoc's
207/// `eitherBlockOrInline` tags (excluding void elements and `script`;
208/// see [`PANDOC_INLINE_BLOCK_TAGS`]).
209pub fn is_pandoc_inline_block_tag_name(name: &str) -> bool {
210 let lower = name.to_ascii_lowercase();
211 PANDOC_INLINE_BLOCK_TAGS.contains(&lower.as_str())
212}
213
214/// Pandoc's void-element subset of `eitherBlockOrInline` (mirrors
215/// `pandoc/src/Text/Pandoc/Readers/HTML/TagCategories.hs`'s void list
216/// minus those handled elsewhere: `br` and `wbr` are inline-only;
217/// `img` and `input` are inline-only; HTML void elements that pandoc
218/// classifies as `eitherBlockOrInline` are `area`, `embed`, `source`,
219/// `track`).
220///
221/// At fresh-block positions (or after a blank line) pandoc emits these
222/// as a single `RawBlock`; inside a running paragraph they stay inline
223/// as `RawInline`. The parser opens a depth-zero HTML block (closes
224/// immediately on the open-tag line — there is no closing tag to
225/// match) so subsequent lines start fresh blocks; the projector's
226/// `split_html_block_by_tags` handles the same-line splitting via
227/// `inline_pending`, emitting one `RawBlock` per void-tag instance.
228const PANDOC_VOID_BLOCK_TAGS: &[&str] = &["area", "embed", "source", "track"];
229
230/// Whether `name` (case-insensitive) is one of pandoc's void
231/// `eitherBlockOrInline` tags (`area`, `embed`, `source`, `track`).
232pub fn is_pandoc_void_block_tag_name(name: &str) -> bool {
233 let lower = name.to_ascii_lowercase();
234 PANDOC_VOID_BLOCK_TAGS.contains(&lower.as_str())
235}
236
237/// Whether the given tag name is eligible for the Phase 6 / Fix #4
238/// structural body lift inside an `HTML_BLOCK` wrapper: it's a Pandoc
239/// block-level tag (strict-block from `PANDOC_BLOCK_TAGS` OR non-void
240/// inline-block from `PANDOC_INLINE_BLOCK_TAGS`) that is NOT verbatim
241/// and NOT void. These are the tags where pandoc parses the body as
242/// fresh markdown between RawBlock emissions of the open/close tags —
243/// exactly the shape we can lift into structural CST children.
244///
245/// Inline-block tags (`<video>`, `<iframe>`, `<button>`, …) have an
246/// additional gate at the lift-gate site: the lift is abandoned when
247/// the body's first non-blank content is a void block tag at a
248/// fresh-block position (`<video>\n<source ...>\n</video>` projects
249/// per-tag rather than matched-pair, mirroring pandoc).
250///
251/// `<div>` is intentionally excluded — it has its own lift path
252/// (`HTML_BLOCK_DIV` wrapper retag) with different demotion rules
253/// (Plain/Para keyed on `close_butted`, not on trailing blank line).
254pub(crate) fn is_pandoc_lift_eligible_block_tag(name: &str) -> bool {
255 let lower = name.to_ascii_lowercase();
256 if VERBATIM_TAGS.contains(&lower.as_str()) {
257 return false;
258 }
259 if PANDOC_VOID_BLOCK_TAGS.contains(&lower.as_str()) {
260 return false;
261 }
262 if lower == "div" {
263 return false;
264 }
265 PANDOC_BLOCK_TAGS.contains(&lower.as_str())
266 || PANDOC_INLINE_BLOCK_TAGS.contains(&lower.as_str())
267}
268
269/// Whether `name` (case-insensitive) is a Pandoc matched-pair block tag
270/// — anything that has an opening and a matching closing form whose
271/// `</tag>` would be recognized by the dispatcher as a separate block
272/// start. Covers strict-block tags (incl. `<div>`), inline-block tags,
273/// and verbatim tags (`<pre>`, `<style>`, `<script>`, `<textarea>`).
274/// Void tags are excluded — they have no close form.
275///
276/// Used by `ListItemBuffer::unclosed_pandoc_matched_pair_tag` to detect
277/// an open inside the buffer whose close would otherwise interrupt the
278/// list item mid-construct.
279pub(crate) fn is_pandoc_matched_pair_tag(name: &str) -> bool {
280 let lower = name.to_ascii_lowercase();
281 if PANDOC_VOID_BLOCK_TAGS.contains(&lower.as_str()) {
282 return false;
283 }
284 PANDOC_BLOCK_TAGS.contains(&lower.as_str())
285 || PANDOC_INLINE_BLOCK_TAGS.contains(&lower.as_str())
286 || VERBATIM_TAGS.contains(&lower.as_str())
287}
288
289/// Open-tag-attribute tokenization gate for non-div strict-block tags
290/// inside a blockquote (`bq_depth > 0`). Returns the tag name when the
291/// open tag is eligible for finer-grained tokenization
292/// (`TEXT("<tag") + WS + HTML_ATTRS{TEXT(attrs)} + TEXT(">")`) without
293/// driving the full body lift — that's the `bq_clean_lift` path. The
294/// HTML_ATTRS region lets `AttributeNode::cast` register any `id` with
295/// the salsa anchor index.
296///
297/// `<div>` is handled by its own structural path (`HTML_BLOCK_DIV`
298/// wrapper) regardless of bq depth, so this gate skips it.
299fn bq_strict_attr_emit_tag_name(
300 wrapper_kind: SyntaxKind,
301 block_type: &HtmlBlockType,
302 bq_depth: usize,
303) -> Option<&str> {
304 if bq_depth == 0 || wrapper_kind != SyntaxKind::HTML_BLOCK {
305 return None;
306 }
307 match block_type {
308 HtmlBlockType::BlockTag {
309 tag_name,
310 is_verbatim: false,
311 closed_by_blank_line: false,
312 depth_aware: true,
313 closes_at_open_tag: false,
314 is_closing: false,
315 } if is_pandoc_lift_eligible_block_tag(tag_name) => Some(tag_name.as_str()),
316 _ => None,
317 }
318}
319
320/// Information about a detected HTML block opening.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub(crate) enum HtmlBlockType {
323 /// HTML comment: <!-- ... -->
324 Comment,
325 /// Processing instruction: <? ... ?>
326 ProcessingInstruction,
327 /// Declaration: <!...>
328 Declaration,
329 /// CDATA section: <![CDATA[ ... ]]>
330 CData,
331 /// Block-level tag (CommonMark types 6/1 — `tag_name` is one of
332 /// `BLOCK_TAGS` or `VERBATIM_TAGS`). Set `closed_by_blank_line` to use
333 /// CommonMark §4.6 type-6 end semantics (block ends at blank line);
334 /// otherwise the legacy "ends at matching `</tag>`" semantics apply.
335 /// `depth_aware` extends the matching-tag close path with balanced
336 /// open/close tracking of the same tag name (mirrors pandoc's
337 /// `htmlInBalanced`); used under Pandoc dialect to handle nested
338 /// `<div>...<div>...</div>...</div>` shapes correctly. Ignored when
339 /// `closed_by_blank_line` is true.
340 /// `closes_at_open_tag` short-circuits the close search: the block
341 /// always ends after the open-tag line. Used for void
342 /// `eitherBlockOrInline` tags (`<embed>`, `<area>`, `<source>`,
343 /// `<track>`) which have no closing tag — depth-aware matching
344 /// would walk to end-of-input.
345 /// `is_closing` records whether the tag at the start position is a
346 /// closing form (`</tag>`) rather than an opening form (`<tag>`).
347 /// The dispatcher's `cannot_interrupt` consults this to mirror
348 /// pandoc's `isInlineTag` special cases (e.g. `</script>` is inline
349 /// even when `<script>` is not — pandoc treats the close-form as
350 /// always-inline regardless of attributes).
351 BlockTag {
352 tag_name: String,
353 is_verbatim: bool,
354 closed_by_blank_line: bool,
355 depth_aware: bool,
356 closes_at_open_tag: bool,
357 is_closing: bool,
358 },
359 /// CommonMark §4.6 type 7: complete open or close tag on a line by
360 /// itself, tag name not in the type-1 verbatim list. Block ends at
361 /// blank line. Cannot interrupt a paragraph.
362 Type7,
363}
364
365/// Try to detect an HTML block opening from content.
366/// Returns block type if this is a valid HTML block start.
367///
368/// `is_commonmark` enables CommonMark §4.6 semantics: type-6 starts also
369/// accept closing tags (`</div>`), type-6 blocks end at the next blank
370/// line (rather than a matching close tag), and type 7 is recognized.
371pub(crate) fn try_parse_html_block_start(
372 content: &str,
373 is_commonmark: bool,
374) -> Option<HtmlBlockType> {
375 let trimmed = strip_leading_spaces(content);
376
377 // Must start with <
378 if !trimmed.starts_with('<') {
379 return None;
380 }
381
382 // HTML comment
383 if trimmed.starts_with("<!--") {
384 return Some(HtmlBlockType::Comment);
385 }
386
387 // Processing instruction
388 if trimmed.starts_with("<?") {
389 return Some(HtmlBlockType::ProcessingInstruction);
390 }
391
392 // CDATA section — CommonMark dialect only. Pandoc-markdown does not
393 // recognize bare CDATA as a raw HTML block; the literal bytes fall
394 // through to paragraph parsing (`<![CDATA[` becomes Str, the inner
395 // text is parsed as inline markdown, etc).
396 if is_commonmark && trimmed.starts_with("<![CDATA[") {
397 return Some(HtmlBlockType::CData);
398 }
399
400 // Declaration (DOCTYPE, etc.) — CommonMark dialect only. Pandoc-markdown
401 // does not recognize bare declarations as raw HTML blocks (its
402 // `htmlBlock` reader uses `htmlTag isBlockTag`, which only matches
403 // tag-shaped blocks); the bytes fall through to paragraph parsing.
404 if is_commonmark && trimmed.starts_with("<!") && trimmed.len() > 2 {
405 let after_bang = &trimmed[2..];
406 if after_bang.chars().next()?.is_ascii_alphabetic() {
407 return Some(HtmlBlockType::Declaration);
408 }
409 }
410
411 // Try to parse as opening tag (or closing tag, under CommonMark and Pandoc).
412 // Pandoc-native recognizes standalone closing forms of strict-block tags
413 // (`</p>`, `</nav>`, `</section>`), verbatim tags (`</pre>`, `</style>`,
414 // `</script>`, `</textarea>`), and inline-block / void tags (`</video>`,
415 // `</button>`, `</embed>`) as single-line `RawBlock`s — they always end on
416 // the open-tag line via `closes_at_open_tag: true`.
417 if let Some(tag_name) = extract_block_tag_name(trimmed, true) {
418 let tag_lower = tag_name.to_lowercase();
419 let is_closing = trimmed.starts_with("</");
420
421 // Pandoc dialect: strict-block (`PANDOC_BLOCK_TAGS`) and verbatim
422 // (`VERBATIM_TAGS`) closing forms emit as single-line `RawBlock`.
423 // Unlike inline-block / void closes, these CAN interrupt a running
424 // paragraph (the dispatcher's `cannot_interrupt` only covers the
425 // inline-block / void categories). Inline-block / void closes are
426 // handled by their own branches further below.
427 if !is_commonmark
428 && is_closing
429 && (PANDOC_BLOCK_TAGS.contains(&tag_lower.as_str())
430 || VERBATIM_TAGS.contains(&tag_lower.as_str()))
431 && !PANDOC_INLINE_BLOCK_TAGS.contains(&tag_lower.as_str())
432 && !PANDOC_VOID_BLOCK_TAGS.contains(&tag_lower.as_str())
433 {
434 return Some(HtmlBlockType::BlockTag {
435 tag_name: tag_lower,
436 is_verbatim: false,
437 closed_by_blank_line: false,
438 depth_aware: false,
439 closes_at_open_tag: true,
440 is_closing: true,
441 });
442 }
443
444 // Under Pandoc, remaining closing forms (truly inline-only tags like
445 // `</em>`, `</span>`) are not block starts — fall through to the
446 // existing inline-html path. Inline-block + void closes are caught
447 // by the dedicated branches further below.
448 if !is_commonmark
449 && is_closing
450 && !PANDOC_INLINE_BLOCK_TAGS.contains(&tag_lower.as_str())
451 && !PANDOC_VOID_BLOCK_TAGS.contains(&tag_lower.as_str())
452 {
453 return None;
454 }
455
456 // Check if it's a block-level tag. Pandoc and CommonMark disagree on
457 // membership: pandoc's `blockHtmlTags` (see
458 // `pandoc/src/Text/Pandoc/Readers/HTML/TagCategories.hs`) treats some
459 // CM type-6 tags as inline (e.g. `dialog`, `legend`, `option`) and
460 // some non-CM tags as block (e.g. `canvas`, `hgroup`, `meta`).
461 let is_block_tag = if is_commonmark {
462 BLOCK_TAGS.contains(&tag_lower.as_str())
463 } else {
464 PANDOC_BLOCK_TAGS.contains(&tag_lower.as_str())
465 };
466 if is_block_tag {
467 let is_verbatim = VERBATIM_TAGS.contains(&tag_lower.as_str());
468 return Some(HtmlBlockType::BlockTag {
469 tag_name: tag_lower,
470 is_verbatim,
471 closed_by_blank_line: is_commonmark && !is_verbatim,
472 depth_aware: !is_commonmark,
473 closes_at_open_tag: false,
474 is_closing,
475 });
476 }
477
478 // Pandoc dialect also treats `eitherBlockOrInline` tags as block
479 // starters at fresh-block positions. The block dispatcher caller
480 // gates these as `cannot_interrupt` (mirrors pandoc — they never
481 // interrupt a running paragraph; only start a fresh block when
482 // following a blank line or at document start). Closing forms
483 // (`</video>`) emit as a single-line `RawBlock` with no balanced
484 // match — pandoc-native pins this for standalone closes.
485 if !is_commonmark && PANDOC_INLINE_BLOCK_TAGS.contains(&tag_lower.as_str()) {
486 return Some(HtmlBlockType::BlockTag {
487 tag_name: tag_lower,
488 is_verbatim: false,
489 closed_by_blank_line: false,
490 depth_aware: !is_closing,
491 closes_at_open_tag: is_closing,
492 is_closing,
493 });
494 }
495
496 // Pandoc dialect also recognizes the void subset of
497 // `eitherBlockOrInline` (`area`, `embed`, `source`, `track`).
498 // These have no closing tag, so the parser closes the block
499 // immediately on the open-tag line; the projector's
500 // `split_html_block_by_tags` handles the same-line splitting
501 // (e.g. `<embed src="a"> trailing` → RawBlock + Para). Like
502 // non-void inline-block tags, void tags never interrupt a
503 // running paragraph (gated as `cannot_interrupt` in the
504 // dispatcher). Closing forms (`</embed>`) — semantically
505 // nonsensical for void elements — pandoc still emits as a
506 // single-line `RawBlock`; mirror that.
507 if !is_commonmark && PANDOC_VOID_BLOCK_TAGS.contains(&tag_lower.as_str()) {
508 return Some(HtmlBlockType::BlockTag {
509 tag_name: tag_lower,
510 is_verbatim: false,
511 closed_by_blank_line: false,
512 depth_aware: false,
513 closes_at_open_tag: true,
514 is_closing,
515 });
516 }
517
518 // Also accept verbatim tags even if not in BLOCK_TAGS list — but
519 // only as opening tags. CommonMark §4.6 type 1 starts with `<pre`,
520 // `<script`, `<style`, or `<textarea`; closing forms like `</pre>`
521 // do not start a type-1 block. Letting `</pre>` through here would
522 // wrongly interrupt a paragraph.
523 if !is_closing && VERBATIM_TAGS.contains(&tag_lower.as_str()) {
524 return Some(HtmlBlockType::BlockTag {
525 tag_name: tag_lower,
526 is_verbatim: true,
527 closed_by_blank_line: false,
528 depth_aware: !is_commonmark,
529 closes_at_open_tag: false,
530 is_closing: false,
531 });
532 }
533 }
534
535 // Type 7 (CommonMark only): complete open or close tag on a line by
536 // itself, tag name not in the type-1 verbatim list.
537 if is_commonmark && let Some(end) = parse_open_tag(trimmed).or_else(|| parse_close_tag(trimmed))
538 {
539 let rest = &trimmed[end..];
540 let only_ws = rest
541 .bytes()
542 .all(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'));
543 if only_ws {
544 // Reject if the tag name belongs to the type-1 verbatim set
545 // (`<pre>`, `<script>`, `<style>`, `<textarea>`) — those are
546 // type-1 starts above, so seeing one here means the opener
547 // had a different shape (e.g. `<pre/>` self-closing) that
548 // shouldn't trigger type 7 either. Conservatively skip.
549 let leading = trimmed.strip_prefix("</").unwrap_or_else(|| &trimmed[1..]);
550 let name_end = leading
551 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
552 .unwrap_or(leading.len());
553 let name = leading[..name_end].to_ascii_lowercase();
554 if !VERBATIM_TAGS.contains(&name.as_str()) {
555 return Some(HtmlBlockType::Type7);
556 }
557 }
558 }
559
560 None
561}
562
563/// Extract the tag name for HTML-block-start detection.
564///
565/// Accepts both opening (`<tag>`) and closing (`</tag>`) forms when
566/// `accept_closing` is true (CommonMark §4.6 type 6 allows either). The
567/// tag must be followed by a space, tab, line ending, `>`, or `/>` per
568/// the spec — we approximate that with the space/`>`/`/` boundary check.
569fn extract_block_tag_name(text: &str, accept_closing: bool) -> Option<String> {
570 if !text.starts_with('<') {
571 return None;
572 }
573
574 let after_bracket = &text[1..];
575
576 let after_slash = if let Some(stripped) = after_bracket.strip_prefix('/') {
577 if !accept_closing {
578 return None;
579 }
580 stripped
581 } else {
582 after_bracket
583 };
584
585 // Extract tag name (alphanumeric, ends at space, >, or /)
586 let tag_end = after_slash
587 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
588 .unwrap_or(after_slash.len());
589
590 if tag_end == 0 {
591 return None;
592 }
593
594 let tag_name = &after_slash[..tag_end];
595
596 // Tag name must be valid (ASCII alphabetic start, alphanumeric)
597 if !tag_name.chars().next()?.is_ascii_alphabetic() {
598 return None;
599 }
600
601 if !tag_name.chars().all(|c| c.is_ascii_alphanumeric()) {
602 return None;
603 }
604
605 Some(tag_name.to_string())
606}
607
608/// Whether this block type ends at a blank line (CommonMark types 6 & 7
609/// in CommonMark dialect). Such blocks do NOT close on a matching tag /
610/// marker — only at end of input or the next blank line.
611fn ends_at_blank_line(block_type: &HtmlBlockType) -> bool {
612 matches!(
613 block_type,
614 HtmlBlockType::Type7
615 | HtmlBlockType::BlockTag {
616 closed_by_blank_line: true,
617 ..
618 }
619 )
620}
621
622/// Check if a line contains the closing marker for the given HTML block type.
623/// Only meaningful for types 1–5 and the legacy "type 6 closed by tag" path;
624/// blank-line-terminated types (6 in CommonMark, 7) never match here.
625fn is_closing_marker(line: &str, block_type: &HtmlBlockType) -> bool {
626 match block_type {
627 HtmlBlockType::Comment => line.contains("-->"),
628 HtmlBlockType::ProcessingInstruction => line.contains("?>"),
629 HtmlBlockType::Declaration => line.contains('>'),
630 HtmlBlockType::CData => line.contains("]]>"),
631 HtmlBlockType::BlockTag {
632 tag_name,
633 closed_by_blank_line: false,
634 ..
635 } => {
636 // Look for closing tag </tagname>
637 let closing_tag = format!("</{}>", tag_name);
638 line.to_lowercase().contains(&closing_tag)
639 }
640 HtmlBlockType::BlockTag {
641 closed_by_blank_line: true,
642 ..
643 }
644 | HtmlBlockType::Type7 => false,
645 }
646}
647
648/// Count occurrences of `<tag_name ...>` (open) and `</tag_name>` (close) in
649/// `line`. Self-closing forms (`<tag .../>`) and tags whose name appears
650/// inside a quoted attribute value are NOT counted — the scanner walks
651/// `<...>` brackets and respects `"`/`'` quoting.
652///
653/// Used by [`parse_html_block_with_wrapper`] to balance nested same-name
654/// tags under Pandoc dialect (mirrors pandoc's `htmlInBalanced`), and by
655/// `ListItemBuffer::unclosed_pandoc_matched_pair_tag` to suppress the
656/// close-form dispatch that would otherwise break the list-item buffer
657/// mid-`<div>...</div>`.
658pub(crate) fn count_tag_balance(line: &str, tag_name: &str) -> (usize, usize) {
659 let bytes = line.as_bytes();
660 let lower_line = line.to_ascii_lowercase();
661 let lower_bytes = lower_line.as_bytes();
662 let tag_lower = tag_name.to_ascii_lowercase();
663 let tag_bytes = tag_lower.as_bytes();
664
665 let mut opens = 0usize;
666 let mut closes = 0usize;
667 let mut i = 0usize;
668
669 while i < bytes.len() {
670 if bytes[i] != b'<' {
671 i += 1;
672 continue;
673 }
674 let after = i + 1;
675 let is_close = after < bytes.len() && bytes[after] == b'/';
676 let name_start = if is_close { after + 1 } else { after };
677 let matched = name_start + tag_bytes.len() <= bytes.len()
678 && &lower_bytes[name_start..name_start + tag_bytes.len()] == tag_bytes;
679 let after_name = name_start + tag_bytes.len();
680 let is_boundary = matched
681 && matches!(
682 bytes.get(after_name).copied(),
683 Some(b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/') | None
684 );
685
686 // Walk forward to the closing `>` of this tag bracket, skipping
687 // inside quoted attribute values. Self-closing form ends with `/>`.
688 let mut j = if matched { after_name } else { after };
689 let mut quote: Option<u8> = None;
690 let mut self_close = false;
691 let mut found_gt = false;
692 while j < bytes.len() {
693 let b = bytes[j];
694 match (quote, b) {
695 (Some(q), x) if x == q => quote = None,
696 (None, b'"') | (None, b'\'') => quote = Some(b),
697 (None, b'>') => {
698 found_gt = true;
699 if j > i + 1 && bytes[j - 1] == b'/' {
700 self_close = true;
701 }
702 break;
703 }
704 _ => {}
705 }
706 j += 1;
707 }
708
709 if matched && is_boundary {
710 if is_close {
711 closes += 1;
712 } else if !self_close {
713 opens += 1;
714 }
715 }
716
717 if found_gt {
718 i = j + 1;
719 } else {
720 // Unterminated `<...` — bail out to avoid an infinite loop.
721 // The remaining bytes don't form a complete tag.
722 break;
723 }
724 }
725
726 (opens, closes)
727}
728
729/// Pandoc-dialect lift for HTML comments / processing instructions
730/// whose close marker is followed by additional bytes (same-line
731/// trailing or following lines). Pandoc-native emits a `RawBlock` for
732/// the marker bytes only, then parses the remainder as fresh blocks.
733///
734/// Returns `Some(consumed_lines)` when the split fires (caller must
735/// NOT enter the legacy emission); `None` to fall back to the legacy
736/// path (no close marker found, or no trailing content to split).
737///
738/// CST shape on success:
739/// ```text
740/// HTML_BLOCK
741/// HTML_BLOCK_TAG (open) // line[0] up to and incl close marker
742/// TEXT "<!-- hi -->" // or with HTML_BLOCK_CONTENT in between
743/// ... // for multi-line `<!--\n…\n-->` shape
744/// <sibling blocks> // recursive parse of trailing + lines[M+1..]
745/// ```
746/// The CST node kind to emit for an opaque single-construct HTML block.
747/// Under `Dialect::Pandoc`, comments, processing instructions, and
748/// verbatim raw-text elements (`<pre>`/`<script>`/`<style>`/`<textarea>`)
749/// each project to exactly one `RawBlock "html"`; tagging the wrapper
750/// `HTML_BLOCK_RAW` lets the pandoc-native projector route by kind instead
751/// of re-sniffing the leading bytes. This changes only the wrapper `u16` —
752/// the child tokens are emitted byte-for-byte identically, so the CST stays
753/// lossless (the `HTML_BLOCK_DIV` precedent). The behavioral `wrapper_kind`
754/// stays `HTML_BLOCK` everywhere else in `parse_html_block_with_wrapper`, so
755/// no lift gate changes. CommonMark dialect keeps the opaque `HTML_BLOCK`
756/// shape.
757fn html_block_node_kind(
758 wrapper_kind: SyntaxKind,
759 block_type: &HtmlBlockType,
760 dialect: crate::options::Dialect,
761) -> SyntaxKind {
762 if wrapper_kind == SyntaxKind::HTML_BLOCK
763 && dialect == crate::options::Dialect::Pandoc
764 && matches!(
765 block_type,
766 HtmlBlockType::Comment
767 | HtmlBlockType::ProcessingInstruction
768 | HtmlBlockType::BlockTag {
769 is_verbatim: true,
770 ..
771 }
772 )
773 {
774 SyntaxKind::HTML_BLOCK_RAW
775 } else {
776 wrapper_kind
777 }
778}
779
780fn try_parse_comment_pi_with_trailing_split(
781 builder: &mut GreenNodeBuilder<'static>,
782 lines: &[&str],
783 start_pos: usize,
784 block_type: &HtmlBlockType,
785 wrapper_kind: SyntaxKind,
786 bq_depth: usize,
787 config: &ParserOptions,
788) -> Option<usize> {
789 let marker: &str = match block_type {
790 HtmlBlockType::Comment => "-->",
791 HtmlBlockType::ProcessingInstruction => "?>",
792 _ => return None,
793 };
794
795 // Find the close marker in the bq-stripped line content. For
796 // bq_depth == 0 the inner content equals the raw line; for
797 // bq_depth > 0 we look past the `>` markers stripped by the
798 // outer dispatcher (line 0) and emitted as bq prefix below
799 // (lines > 0). `marker_end_in_inner` is the byte offset of the
800 // first byte AFTER the close marker, measured from the start
801 // of the inner (post-strip) content.
802 let mut close_line_idx: Option<usize> = None;
803 let mut marker_end_in_inner: usize = 0;
804 for (offset, line) in lines[start_pos..].iter().enumerate() {
805 let inner = if bq_depth > 0 {
806 strip_n_blockquote_markers(line, bq_depth)
807 } else {
808 line
809 };
810 if let Some(pos) = inner.find(marker) {
811 close_line_idx = Some(start_pos + offset);
812 marker_end_in_inner = pos + marker.len();
813 break;
814 }
815 }
816 let close_line_idx = close_line_idx?;
817 let close_line = lines[close_line_idx];
818 let close_inner = if bq_depth > 0 {
819 strip_n_blockquote_markers(close_line, bq_depth)
820 } else {
821 close_line
822 };
823 let close_prefix_len = close_line.len() - close_inner.len();
824 let trailing = &close_inner[marker_end_in_inner..];
825
826 // Only fire when there is non-whitespace content AFTER the close
827 // marker on the close line. The legacy path correctly handles
828 // the close-line-ends-at-close-marker shapes (`-->\n` followed
829 // by separate blocks); only the same-line-trailing case needs
830 // structural splitting. Trailing-whitespace-only handling
831 // (`--> \n`) is a projector-side trim — separate concern.
832 let has_non_ws_trailing = trailing.bytes().any(|b| !b.is_ascii_whitespace());
833 if !has_non_ws_trailing {
834 return None;
835 }
836
837 builder.start_node(html_block_node_kind(wrapper_kind, block_type, config.dialect).into());
838
839 // Emit open `HTML_BLOCK_TAG` (the opening marker line(s)) and any
840 // middle `HTML_BLOCK_CONTENT` lines between open and close. The
841 // close `HTML_BLOCK_TAG` carries only the bytes up to and
842 // including the close marker — trailing bytes go to the sibling.
843 if close_line_idx == start_pos {
844 // Same-line shape: one HTML_BLOCK_TAG containing the close
845 // marker's bytes. The newline lives on the trailing sibling.
846 // Line 0's bq prefix (if any) was already emitted by the
847 // outer dispatcher; emit only the inner marker bytes.
848 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
849 let close_part = &close_inner[..marker_end_in_inner];
850 if !close_part.is_empty() {
851 builder.token(SyntaxKind::TEXT.into(), close_part);
852 }
853 builder.finish_node();
854 } else {
855 // Multi-line shape: open tag covers lines[start_pos..close],
856 // middle lines go inside HTML_BLOCK_CONTENT, close tag holds
857 // only the marker bytes. Line 0's bq prefix was emitted by
858 // the outer dispatcher; subsequent lines (middle + close)
859 // need bq prefix re-emission inside the wrapper.
860 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
861 let first_line = lines[start_pos];
862 let first_inner = if bq_depth > 0 {
863 strip_n_blockquote_markers(first_line, bq_depth)
864 } else {
865 first_line
866 };
867 let (line_no_nl, nl) = strip_newline(first_inner);
868 if !line_no_nl.is_empty() {
869 builder.token(SyntaxKind::TEXT.into(), line_no_nl);
870 }
871 if !nl.is_empty() {
872 builder.token(SyntaxKind::NEWLINE.into(), nl);
873 }
874 builder.finish_node();
875
876 if close_line_idx > start_pos + 1 {
877 builder.start_node(SyntaxKind::HTML_BLOCK_CONTENT.into());
878 for content_line in &lines[start_pos + 1..close_line_idx] {
879 emit_html_block_line(builder, content_line, bq_depth);
880 }
881 builder.finish_node();
882 }
883
884 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
885 if bq_depth > 0 && close_prefix_len > 0 {
886 emit_bq_prefix_tokens(builder, &close_line[..close_prefix_len]);
887 }
888 let close_part = &close_inner[..marker_end_in_inner];
889 if !close_part.is_empty() {
890 builder.token(SyntaxKind::TEXT.into(), close_part);
891 }
892 builder.finish_node();
893 }
894
895 builder.finish_node(); // HTML_BLOCK
896
897 // Recursively parse JUST the trailing bytes on the close line
898 // and graft top-level children as siblings of the HTML_BLOCK we
899 // just closed. We do NOT consume subsequent lines here — the
900 // outer dispatcher continues from `close_line_idx + 1` and
901 // handles container-boundary lines (`:::` div closes, blockquote
902 // markers, list-marker continuations) correctly. Multi-line
903 // softbreak continuation (`<!-- --> trailing\nmore\n` →
904 // `Para [trailing, SoftBreak, more]`) is NOT modeled — the
905 // outer dispatcher sees `more` after the close line and starts
906 // a fresh paragraph. Refdefs flow through from the outer config
907 // (same pattern as `emit_html_block_body_lifted_inner`).
908 if !trailing.is_empty() {
909 let mut inner_options = config.clone();
910 let refdefs = config.refdef_labels.clone().unwrap_or_default();
911 inner_options.refdef_labels = Some(refdefs.clone());
912 let inner_root = crate::parser::parse_with_refdefs(trailing, Some(inner_options), refdefs);
913 let mut bq = None;
914 graft_document_children(builder, &inner_root, LastParaDemote::Never, &mut bq);
915 }
916
917 Some(close_line_idx + 1)
918}
919
920/// One source-order piece of a standalone-tag line: either a complete
921/// HTML tag or a run of inter-tag/leading/trailing whitespace.
922enum StandaloneTagSegment<'a> {
923 Whitespace(&'a str),
924 Tag(&'a str),
925}
926
927/// Extract the tag name from an open-tag slice (`<name ...>`), or `None`
928/// if `tag` is not a well-formed open tag start.
929fn open_tag_name(tag: &str) -> Option<&str> {
930 let bytes = tag.as_bytes();
931 if bytes.first() != Some(&b'<') {
932 return None;
933 }
934 let start = 1;
935 let mut i = start;
936 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-') {
937 i += 1;
938 }
939 if i == start {
940 return None;
941 }
942 Some(&tag[start..i])
943}
944
945/// Recognize a line consisting entirely of two or more complete
946/// standalone block-level HTML tags — closing tags (`</p>`, `</div>`)
947/// and void block tags (`<embed>`, `<source>`, …) — separated by
948/// optional inter-tag whitespace, with optional leading indent and
949/// trailing whitespace. Returns the source-order segments (tags +
950/// whitespace) when the whole line is consumed by ≥ 2 such tags;
951/// `None` otherwise (markdown text, strict/inline-block opens, or a
952/// single tag — those stay on the legacy emission path).
953fn split_line_into_standalone_tags(line: &str) -> Option<Vec<StandaloneTagSegment<'_>>> {
954 let bytes = line.as_bytes();
955 let mut i = 0;
956 let mut segments = Vec::new();
957 let mut tag_count = 0usize;
958 let take_ws = |line: &str, from: usize| -> usize {
959 let bytes = line.as_bytes();
960 let mut j = from;
961 while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
962 j += 1;
963 }
964 j
965 };
966 let ws_end = take_ws(line, i);
967 if ws_end > i {
968 segments.push(StandaloneTagSegment::Whitespace(&line[i..ws_end]));
969 i = ws_end;
970 }
971 while i < bytes.len() {
972 let rest = &line[i..];
973 // Any closing tag is an unconditional block splitter. For open
974 // tags, only void block tags (`<embed>`, `<source>`, …) split
975 // unconditionally at a fresh-block position — strict-block and
976 // inline-block opens start a region (matched-pair lift /
977 // `inline_pending` context), so leave those to the legacy byte
978 // walker.
979 let len = parse_close_tag(rest).or_else(|| {
980 parse_open_tag(rest).filter(|&len| {
981 open_tag_name(&rest[..len]).is_some_and(is_pandoc_void_block_tag_name)
982 })
983 })?;
984 segments.push(StandaloneTagSegment::Tag(&line[i..i + len]));
985 tag_count += 1;
986 i += len;
987 let ws_end = take_ws(line, i);
988 if ws_end > i {
989 segments.push(StandaloneTagSegment::Whitespace(&line[i..ws_end]));
990 i = ws_end;
991 }
992 }
993 (tag_count >= 2).then_some(segments)
994}
995
996/// Pandoc-dialect Phase 7b lift: a single-line opaque HTML block whose
997/// content is two or more complete standalone block-level tags (void
998/// tags and/or closing tags) — e.g. `</p></div>`, `<embed><embed>`.
999/// Pandoc's `markdown_in_html_blocks` splits these into one `RawBlock`
1000/// per tag. The legacy emission bakes them into a single
1001/// `HTML_BLOCK_TAG` TEXT token, forcing the projector to re-tokenize
1002/// the bytes; this lift emits one `HTML_BLOCK_TAG` per tag so the CST
1003/// structurally encodes the split and the projector can route by kind.
1004///
1005/// Single-tag blocks (`</p>`, `<embed>`) stay on the legacy path —
1006/// their CST is already faithful (one tag, one `HTML_BLOCK_TAG`) and
1007/// changing it would churn snapshots with no fidelity gain. Blockquote
1008/// context (`bq_depth > 0`) also stays on the legacy path. Returns the
1009/// number of lines consumed (always 1) on success.
1010fn try_parse_standalone_block_tags_split(
1011 builder: &mut GreenNodeBuilder<'static>,
1012 lines: &[&str],
1013 start_pos: usize,
1014 block_type: &HtmlBlockType,
1015 wrapper_kind: SyntaxKind,
1016 prefix: &ContainerPrefix,
1017 config: &ParserOptions,
1018) -> Option<usize> {
1019 if config.dialect != crate::options::Dialect::Pandoc {
1020 return None;
1021 }
1022 // Void/close forms keep the opaque `HTML_BLOCK` wrapper; `<div>`
1023 // and lifted strict/inline-block tags carry their own wrappers.
1024 if wrapper_kind != SyntaxKind::HTML_BLOCK {
1025 return None;
1026 }
1027 if !matches!(
1028 block_type,
1029 HtmlBlockType::BlockTag {
1030 closes_at_open_tag: true,
1031 ..
1032 }
1033 ) {
1034 return None;
1035 }
1036 if prefix.bq_depth() != 0 {
1037 return None;
1038 }
1039 let first_inner = prefix.strip_line_0_for_emission(lines[start_pos]);
1040 let (line, nl) = strip_newline(first_inner);
1041 let segments = split_line_into_standalone_tags(line)?;
1042
1043 builder.start_node(SyntaxKind::HTML_BLOCK.into());
1044 for segment in segments {
1045 match segment {
1046 StandaloneTagSegment::Whitespace(ws) => {
1047 builder.token(SyntaxKind::WHITESPACE.into(), ws);
1048 }
1049 StandaloneTagSegment::Tag(tag) => {
1050 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1051 builder.token(SyntaxKind::TEXT.into(), tag);
1052 builder.finish_node();
1053 }
1054 }
1055 }
1056 if !nl.is_empty() {
1057 builder.token(SyntaxKind::NEWLINE.into(), nl);
1058 }
1059 builder.finish_node(); // HTML_BLOCK
1060
1061 Some(start_pos + 1)
1062}
1063
1064/// Parse an HTML block, allowing the caller to pick the wrapper SyntaxKind
1065/// (`HTML_BLOCK` for opaque preservation, `HTML_BLOCK_DIV` for the
1066/// Pandoc-dialect `<div>` lift). Children are emitted byte-for-byte
1067/// identical to the source either way; only the wrapper retag changes.
1068pub(crate) fn parse_html_block_with_wrapper(
1069 builder: &mut GreenNodeBuilder<'static>,
1070 lines: &[&str],
1071 start_pos: usize,
1072 block_type: HtmlBlockType,
1073 prefix: &ContainerPrefix,
1074 wrapper_kind: SyntaxKind,
1075 config: &ParserOptions,
1076) -> usize {
1077 let bq_depth = prefix.bq_depth();
1078 // Pandoc-dialect Comment / PI trailing-text split. Pandoc-native
1079 // closes the RawBlock at the close marker (`-->` / `?>`) and parses
1080 // any subsequent bytes (same-line trailing or following lines) as
1081 // fresh blocks. The legacy path absorbs them into the HTML block
1082 // wrapper, producing one oversized RawBlock. Handle the split here
1083 // before entering the legacy emission so the CST encodes the
1084 // sibling structure.
1085 if config.dialect == crate::options::Dialect::Pandoc
1086 && matches!(
1087 block_type,
1088 HtmlBlockType::Comment | HtmlBlockType::ProcessingInstruction
1089 )
1090 && let Some(consumed) = try_parse_comment_pi_with_trailing_split(
1091 builder,
1092 lines,
1093 start_pos,
1094 &block_type,
1095 wrapper_kind,
1096 bq_depth,
1097 config,
1098 )
1099 {
1100 return consumed;
1101 }
1102
1103 // Pandoc-dialect Phase 7b standalone-tag split. A single line of two
1104 // or more complete standalone block-level tags (`</p></div>`,
1105 // `<embed><embed>`) projects to one `RawBlock` per tag; emit one
1106 // `HTML_BLOCK_TAG` per tag so the CST encodes the split structurally
1107 // instead of leaving the projector to re-tokenize a baked TEXT token.
1108 if let Some(consumed) = try_parse_standalone_block_tags_split(
1109 builder,
1110 lines,
1111 start_pos,
1112 &block_type,
1113 wrapper_kind,
1114 prefix,
1115 config,
1116 ) {
1117 return consumed;
1118 }
1119
1120 // Start HTML block. The node kind may retag to `HTML_BLOCK_RAW` for
1121 // single-construct opaque shapes (comment / PI / verbatim) under
1122 // Pandoc; `wrapper_kind` itself stays the behavioral gate below so no
1123 // lift logic changes and the child tokens stay byte-identical.
1124 builder.start_node(html_block_node_kind(wrapper_kind, &block_type, config.dialect).into());
1125
1126 let first_line = lines[start_pos];
1127 let blank_terminated = ends_at_blank_line(&block_type);
1128
1129 // The block dispatcher has already emitted the bq prefix tokens for
1130 // the first line; emit only the inner content as TEXT to keep the
1131 // CST byte-equal to the source. List-marker bytes are stripped only
1132 // when this dispatch fires on a list-marker line — for
1133 // continuation-line dispatches (the much more common case) the
1134 // leading indent is inner content, not upstream-emitted prefix.
1135 let first_inner = prefix.strip_line_0_for_emission(first_line);
1136
1137 // Detect a multi-line open tag.
1138 // - `<div>` (Pandoc lift): we tokenize each line structurally so the
1139 // salsa anchor walk picks up `id` from the HTML_ATTRS region.
1140 // - Pandoc strict-block tags eligible for the Fix #4 lift (`<form>`,
1141 // `<section>`, `<header>`, …): same structural emission, exposing
1142 // `id` to the salsa anchor walk and enabling the body lift below.
1143 // - Void block tags (`<embed>`, `<area>`, `<source>`, `<track>`):
1144 // without this, the parser closes the block after line 0 and the
1145 // remainder of the open tag falls into following paragraphs;
1146 // pandoc-native treats the whole multi-line open tag as a single
1147 // `RawBlock`. Emission for void tags uses simple per-line
1148 // TEXT + NEWLINE (no HTML_ATTRS — the projector doesn't read attrs
1149 // from void tags).
1150 let multiline_open_end = match (wrapper_kind, &block_type) {
1151 (SyntaxKind::HTML_BLOCK_DIV, _) => {
1152 find_multiline_open_end(lines, start_pos, first_inner, "div", prefix)
1153 }
1154 (
1155 _,
1156 HtmlBlockType::BlockTag {
1157 tag_name,
1158 closes_at_open_tag: true,
1159 ..
1160 },
1161 ) => find_multiline_open_end(lines, start_pos, first_inner, tag_name, prefix),
1162 (
1163 _,
1164 HtmlBlockType::BlockTag {
1165 tag_name,
1166 is_verbatim: false,
1167 closed_by_blank_line: false,
1168 depth_aware: true,
1169 closes_at_open_tag: false,
1170 is_closing: false,
1171 },
1172 ) if is_pandoc_lift_eligible_block_tag(tag_name) => {
1173 find_multiline_open_end(lines, start_pos, first_inner, tag_name, prefix)
1174 }
1175 _ => None,
1176 };
1177
1178 // Set up depth-aware close tracking when the block type asks for it
1179 // (Pandoc dialect, balanced same-name tag matching). A `None` means
1180 // we fall back to the legacy "first matching close" path via
1181 // `is_closing_marker`. Computed up front so the lift-mode gate
1182 // below can decide whether the open line already balances the
1183 // block (same-line `<div>...</div>`).
1184 let depth_aware_tag: Option<String> = match &block_type {
1185 HtmlBlockType::BlockTag {
1186 tag_name,
1187 closed_by_blank_line: false,
1188 depth_aware: true,
1189 ..
1190 } => Some(tag_name.clone()),
1191 _ => None,
1192 };
1193 let mut depth: i64 = 1;
1194 if let Some(tag_name) = &depth_aware_tag {
1195 // Sum opens/closes across all open-tag lines (single-line: just
1196 // line 0; multi-line: lines 0..=end_line_idx).
1197 let last_open_line = multiline_open_end.unwrap_or(start_pos);
1198 let mut opens = 0usize;
1199 let mut closes = 0usize;
1200 for line in &lines[start_pos..=last_open_line] {
1201 let inner = prefix.strip(line);
1202 let (o, c) = count_tag_balance(inner, tag_name);
1203 opens += o;
1204 closes += c;
1205 }
1206 depth = opens as i64 - closes as i64;
1207 }
1208
1209 // Same-line `<div>foo</div>` shape: the open line balances the
1210 // block under depth-aware tracking. We can lift this structurally
1211 // only when the open-tag trailing has exactly one `</div>` close,
1212 // zero `<div>` opens, and no non-whitespace content after the
1213 // close. Other same-line shapes (nested, trailing text, malformed)
1214 // fall through to the byte-reparse path.
1215 let is_same_line_div = wrapper_kind == SyntaxKind::HTML_BLOCK_DIV
1216 && multiline_open_end.is_none()
1217 && depth_aware_tag.is_some()
1218 && depth <= 0;
1219 let same_line_div_lift_safe = is_same_line_div && bq_depth == 0 && {
1220 let (line_without_newline, _) = strip_newline(first_inner);
1221 probe_same_line_lift(line_without_newline, "div")
1222 };
1223
1224 // Strict-block-tag Fix #4 lift (`<form>`, `<section>`, `<header>`,
1225 // `<nav>`, …): the body parses as fresh markdown between RawBlock
1226 // emissions of the open/close tags. Covers the clean multi-line
1227 // shape (open tag stands alone on its line), open-trailing
1228 // (`<form>foo\n…\n</form>`), butted-close (`<form>\n…\nfoo</form>`),
1229 // and same-line (`<form>foo</form>`). Multi-line open and
1230 // blockquote-wrapped non-div shapes still fall through to the
1231 // byte-walker path.
1232 let strict_block_tag_name: Option<&str> =
1233 if wrapper_kind == SyntaxKind::HTML_BLOCK && bq_depth == 0 {
1234 match &block_type {
1235 HtmlBlockType::BlockTag {
1236 tag_name,
1237 is_verbatim: false,
1238 closed_by_blank_line: false,
1239 depth_aware: true,
1240 closes_at_open_tag: false,
1241 is_closing: false,
1242 } if is_pandoc_lift_eligible_block_tag(tag_name) => Some(tag_name.as_str()),
1243 _ => None,
1244 }
1245 } else {
1246 None
1247 };
1248 // Same-line `<form>foo</form>` shape: the open line already
1249 // balances the block (`depth <= 0`). Lift only when the trailing
1250 // bytes after the open `>` end with `</tag>` and contain exactly
1251 // one close + zero nested opens.
1252 let same_line_strict_lift_safe = strict_block_tag_name.is_some_and(|name| {
1253 multiline_open_end.is_none() && depth <= 0 && {
1254 let (line_no_nl, _) = strip_newline(first_inner);
1255 probe_same_line_lift(line_no_nl, name)
1256 }
1257 });
1258 // Strict-block lift gate: accept (a) a multi-line open tag spanning
1259 // `lines[start_pos..=multiline_open_end]`, or (b) a clean / open-
1260 // trailing single-line open (depth > 0, open `>` is present with
1261 // quote-aware matching), or (c) a safe same-line shape. For
1262 // inline-block matched-pair tags (`<video>`, `<iframe>`, `<button>`,
1263 // …) the lift additionally abandons when the body starts at a
1264 // fresh-block position with a void block tag — pandoc-native pins
1265 // per-tag emission rather than a matched-pair lift in that case.
1266 let strict_block_lift = strict_block_tag_name.is_some_and(|name| {
1267 let (line_no_nl, _) = strip_newline(first_inner);
1268 let shape_ok = if multiline_open_end.is_some() {
1269 // `find_multiline_open_end` already verified the open tag
1270 // closes with a quote-aware `>` somewhere in lines
1271 // `start_pos+1..=end`. No same-line trailing content to
1272 // probe; defer trailing-on-close-`>`-line handling to a
1273 // future session (rare in practice).
1274 true
1275 } else if depth > 0 {
1276 probe_open_tag_line_has_close_gt(line_no_nl, name)
1277 } else {
1278 same_line_strict_lift_safe
1279 };
1280 if !shape_ok {
1281 return false;
1282 }
1283 if !is_pandoc_inline_block_tag_name(name) {
1284 return true;
1285 }
1286 !inline_block_void_interior_abandons(
1287 first_inner,
1288 lines,
1289 start_pos,
1290 multiline_open_end,
1291 bq_depth,
1292 name,
1293 )
1294 });
1295
1296 // Same-line lift inside a blockquote (`> <tag>body</tag>`). Bytes
1297 // are byte-equal to the non-bq same-line shape minus the leading
1298 // `> ` (which sits on the outer BLOCK_QUOTE, not inside HTML_BLOCK).
1299 // The body has no inner newlines, so no bq prefix re-injection is
1300 // needed when grafting — `emit_html_block_body_lifted` (passing
1301 // `bq: &mut None`) is enough. Other bq shapes (butted-close,
1302 // open-trailing) still fall through to the projector's byte
1303 // walker — they need per-line prefix injection.
1304 let same_line_bq_lift_tag: Option<&str> = if bq_depth > 0
1305 && multiline_open_end.is_none()
1306 && depth_aware_tag.is_some()
1307 && depth <= 0
1308 {
1309 let (line_no_nl, _) = strip_newline(first_inner);
1310 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1311 if probe_same_line_lift(line_no_nl, "div") {
1312 Some("div")
1313 } else {
1314 None
1315 }
1316 } else if wrapper_kind == SyntaxKind::HTML_BLOCK {
1317 match &block_type {
1318 HtmlBlockType::BlockTag {
1319 tag_name,
1320 is_verbatim: false,
1321 closed_by_blank_line: false,
1322 depth_aware: true,
1323 closes_at_open_tag: false,
1324 is_closing: false,
1325 } if is_pandoc_lift_eligible_block_tag(tag_name)
1326 && probe_same_line_lift(line_no_nl, tag_name.as_str()) =>
1327 {
1328 // Inline-block tags (`<video>`, `<iframe>`, …) skip
1329 // the void-interior check at same-line — the shape
1330 // has no inner block content to interfere with.
1331 Some(tag_name.as_str())
1332 }
1333 _ => None,
1334 }
1335 } else {
1336 None
1337 }
1338 } else {
1339 None
1340 };
1341
1342 // Messy-shape lift inside a blockquote — covers open-trailing
1343 // (`> <div>foo\n> </div>`), butted-close (`> <div>\n> foo</div>`),
1344 // and open-trailing + butted-close (`> <div>foo\n> bar</div>`),
1345 // including the multi-line-open variants (`> <div\n> id="x">foo\n>
1346 // body\n> </div>`) where the trailing is captured into `pre_content`
1347 // by `emit_multiline_open_tag_with_attrs` with `lift_trailing=true`.
1348 // The open line does NOT balance the block (depth > 0 after the
1349 // open line, distinguishing this from `same_line_bq_lift_tag` which
1350 // requires depth <= 0). The close line — possibly with leading body
1351 // text — closes the block when depth returns to 0. Body lines (incl.
1352 // open trailing and close leading) graft via prefix re-injection.
1353 let bq_messy_lift_tag: Option<&str> = if bq_depth > 0 && depth_aware_tag.is_some() && depth > 0
1354 {
1355 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1356 Some("div")
1357 } else if wrapper_kind == SyntaxKind::HTML_BLOCK {
1358 match &block_type {
1359 HtmlBlockType::BlockTag {
1360 tag_name,
1361 is_verbatim: false,
1362 closed_by_blank_line: false,
1363 depth_aware: true,
1364 closes_at_open_tag: false,
1365 is_closing: false,
1366 } if is_pandoc_lift_eligible_block_tag(tag_name) => {
1367 // Inline-block matched-pair tags (`<video>`, `<iframe>`,
1368 // …) abandon the lift when the body starts at a
1369 // fresh-block position with a void block tag. Same gate
1370 // as the non-bq matched-pair lift (`strict_block_lift`).
1371 if is_pandoc_inline_block_tag_name(tag_name)
1372 && inline_block_void_interior_abandons(
1373 first_inner,
1374 lines,
1375 start_pos,
1376 multiline_open_end,
1377 bq_depth,
1378 tag_name,
1379 )
1380 {
1381 None
1382 } else {
1383 Some(tag_name.as_str())
1384 }
1385 }
1386 _ => None,
1387 }
1388 } else {
1389 None
1390 }
1391 } else {
1392 None
1393 };
1394
1395 // Multi-line open + matched close-on-the-open's-last-line shape inside
1396 // a blockquote (`> <div\n> id="x">foo</div>` and depth-aware variants:
1397 // nested same-tag, trailing close, trailing text, strict-block `<form>`).
1398 // Mirrors the non-bq `pre_content`-close branch (line ~1363) but inside
1399 // a blockquote. Distinguishing features from `bq_messy_lift_tag`: the
1400 // close is on the open's last line (`depth <= 0` after the open lines)
1401 // AND `multiline_open_end.is_some()`. The trailing bytes after the
1402 // last `>` get lifted into `pre_content` via
1403 // `emit_multiline_open_tag_with_attrs(... lift_trailing=true)`, then the
1404 // new branch below splits `pre_content` at the matched close marker
1405 // and grafts body + close + any trailing siblings.
1406 let bq_multiline_close_lift_tag: Option<&str> = if bq_depth > 0
1407 && multiline_open_end.is_some()
1408 && depth_aware_tag.is_some()
1409 && depth <= 0
1410 {
1411 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1412 Some("div")
1413 } else if wrapper_kind == SyntaxKind::HTML_BLOCK {
1414 match &block_type {
1415 HtmlBlockType::BlockTag {
1416 tag_name,
1417 is_verbatim: false,
1418 closed_by_blank_line: false,
1419 depth_aware: true,
1420 closes_at_open_tag: false,
1421 is_closing: false,
1422 } if is_pandoc_lift_eligible_block_tag(tag_name) => {
1423 if is_pandoc_inline_block_tag_name(tag_name)
1424 && inline_block_void_interior_abandons(
1425 first_inner,
1426 lines,
1427 start_pos,
1428 multiline_open_end,
1429 bq_depth,
1430 tag_name,
1431 )
1432 {
1433 None
1434 } else {
1435 Some(tag_name.as_str())
1436 }
1437 }
1438 _ => None,
1439 }
1440 } else {
1441 None
1442 }
1443 } else {
1444 None
1445 };
1446
1447 // Whether this block participates in the Phase 6 structural lift
1448 // (recursively parse body as Pandoc markdown and graft children).
1449 // Covers `<div>` outside blockquote context. For same-line shapes
1450 // the lift is gated on `same_line_*_lift_safe` — when unsafe we
1451 // keep the legacy single-HTML_BLOCK_TAG shape and let the
1452 // byte-reparse path handle projection.
1453 let lift_mode = (wrapper_kind == SyntaxKind::HTML_BLOCK_DIV
1454 && bq_depth == 0
1455 && (!is_same_line_div || same_line_div_lift_safe))
1456 || strict_block_lift
1457 || same_line_bq_lift_tag.is_some()
1458 || bq_messy_lift_tag.is_some()
1459 || bq_multiline_close_lift_tag.is_some();
1460
1461 // Trailing content from the open tag (after `>`). When the lift is
1462 // active and the open line is `<div ATTRS>foo\n`, this captures
1463 // `"foo\n"` so it becomes the leading bytes of the recursive-parse
1464 // input. Stays empty for clean opens (`<div>\n`) and for non-lift
1465 // shapes (same-line / blockquote-wrapped).
1466 let mut pre_content = String::new();
1467
1468 // Emit opening line(s)
1469 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1470
1471 if let Some(end_line_idx) = multiline_open_end {
1472 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1473 emit_multiline_open_tag_with_attrs(
1474 builder,
1475 lines,
1476 start_pos,
1477 end_line_idx,
1478 "div",
1479 bq_depth,
1480 lift_mode,
1481 &mut pre_content,
1482 );
1483 } else if let Some(name) = strict_block_tag_name
1484 && strict_block_lift
1485 {
1486 emit_multiline_open_tag_with_attrs(
1487 builder,
1488 lines,
1489 start_pos,
1490 end_line_idx,
1491 name,
1492 bq_depth,
1493 lift_mode,
1494 &mut pre_content,
1495 );
1496 } else if let Some(name) = bq_strict_attr_emit_tag_name(wrapper_kind, &block_type, bq_depth)
1497 {
1498 // Multi-line open of a lift-eligible strict-block tag inside a
1499 // blockquote (`> <section\n> id=...>`). The non-bq
1500 // `strict_block_tag_name` gate is `bq_depth == 0`; this branch
1501 // covers the bq side so the open tag emits HTML_ATTRS regions
1502 // for `AttributeNode::cast` and the projector's canonicalizer.
1503 //
1504 // `lift_trailing` mirrors the single-line `emit_open_tag_tokens`
1505 // call below: only push trailing bytes into `pre_content` when
1506 // the structural lift will consume them (bq messy lift). The
1507 // bq clean-lift requires `pre_content.is_empty()`, so for clean
1508 // multi-line opens the trailing is empty anyway and this is
1509 // a no-op.
1510 let lift_trailing =
1511 bq_messy_lift_tag == Some(name) || bq_multiline_close_lift_tag == Some(name);
1512 emit_multiline_open_tag_with_attrs(
1513 builder,
1514 lines,
1515 start_pos,
1516 end_line_idx,
1517 name,
1518 bq_depth,
1519 lift_trailing,
1520 &mut pre_content,
1521 );
1522 } else {
1523 emit_multiline_open_tag_simple(builder, lines, start_pos, end_line_idx, bq_depth);
1524 }
1525 } else {
1526 let (line_without_newline, newline_str) = strip_newline(first_inner);
1527 if !line_without_newline.is_empty() {
1528 // For HTML_BLOCK_DIV, expose the open tag's attributes
1529 // structurally so `AttributeNode::cast(HTML_ATTRS)` finds them
1530 // via the same descendants walk that handles fenced-div /
1531 // heading attrs. CST bytes stay byte-equal to source — we only
1532 // tokenize at finer granularity for matched div opens.
1533 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1534 let trailing =
1535 emit_open_tag_tokens(builder, line_without_newline, "div", lift_mode);
1536 if !trailing.is_empty() {
1537 pre_content.push_str(trailing);
1538 pre_content.push_str(newline_str);
1539 }
1540 } else if let Some(name) = strict_block_tag_name
1541 && strict_block_lift
1542 {
1543 let trailing = emit_open_tag_tokens(builder, line_without_newline, name, lift_mode);
1544 if !trailing.is_empty() {
1545 pre_content.push_str(trailing);
1546 pre_content.push_str(newline_str);
1547 }
1548 } else if let Some(name) =
1549 bq_strict_attr_emit_tag_name(wrapper_kind, &block_type, bq_depth)
1550 {
1551 // Inside a blockquote, lift trailing bytes into
1552 // `pre_content` when either the same-line bq gate fires
1553 // (`> <tag>body</tag>` — handled by `same_line_closed`)
1554 // or the messy-shape bq gate fires (`> <tag>foo\n…\n>
1555 // </tag>` and butted-close — handled at the close-marker
1556 // site below). For the clean-shape bq lift the open has
1557 // no trailing bytes regardless, so `lift_trailing=true`
1558 // is a no-op there.
1559 let lift_trailing =
1560 same_line_bq_lift_tag == Some(name) || bq_messy_lift_tag == Some(name);
1561 let trailing =
1562 emit_open_tag_tokens(builder, line_without_newline, name, lift_trailing);
1563 if lift_trailing && !trailing.is_empty() {
1564 pre_content.push_str(trailing);
1565 pre_content.push_str(newline_str);
1566 }
1567 } else {
1568 builder.token(SyntaxKind::TEXT.into(), line_without_newline);
1569 }
1570 }
1571 // When the open tag has trailing content under lift mode, the
1572 // newline belongs to that trailing line (it terminates the
1573 // synthetic body line, not the open tag). Don't double-emit.
1574 if pre_content.is_empty() && !newline_str.is_empty() {
1575 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
1576 }
1577 }
1578
1579 builder.finish_node(); // HtmlBlockTag
1580
1581 // Check if opening line also contains closing marker. Blank-line-terminated
1582 // blocks (CommonMark types 6 & 7) ignore inline close markers — they only
1583 // end at a blank line or end of input. Void `eitherBlockOrInline` tags
1584 // (`closes_at_open_tag: true`) close immediately — the block always
1585 // ends on the open-tag line since there is no closing tag to find.
1586 let void_block = matches!(
1587 &block_type,
1588 HtmlBlockType::BlockTag {
1589 closes_at_open_tag: true,
1590 ..
1591 }
1592 );
1593 // Void tags with a multi-line open close immediately after the open
1594 // tag's last line. The HTML_BLOCK_TAG already covers all open-tag
1595 // lines (`emit_multiline_open_tag_simple` above); pandoc-native emits
1596 // a single RawBlock for the whole multi-line tag, with no following
1597 // content.
1598 if void_block && let Some(end_line_idx) = multiline_open_end {
1599 log::trace!(
1600 "HTML void block at line {} closes after multi-line open ending at line {}",
1601 start_pos + 1,
1602 end_line_idx + 1
1603 );
1604 builder.finish_node(); // HtmlBlock
1605 return end_line_idx + 1;
1606 }
1607 // Multi-line open with all matched closes on the open's last line:
1608 // `pre_content` holds the bytes after the last open `>` (lifted there
1609 // by `emit_multiline_open_tag_with_attrs` when `lift_trailing=true`).
1610 // When `depth <= 0` after the multi-line open and the trailing bytes
1611 // contain the depth-zero matched close, do the same-line lift on
1612 // `pre_content` directly. Mirrors the single-line `same_line_closed`
1613 // lift below — same body / close-marker / trailing-graft shape, just
1614 // consuming `end_line_idx + 1` lines instead of `start_pos + 1`.
1615 //
1616 // The body bytes of `pre_content` come from the open's last line,
1617 // which `emit_multiline_open_tag_with_attrs` already prefixed with the
1618 // re-emitted bq prefix tokens (for `bq_depth > 0`). The body and close
1619 // tag thus inherit the bq context without per-line prefix injection,
1620 // so `emit_html_block_body_lifted` (with `bq: &mut None`) suffices for
1621 // both the non-bq and bq variants of this shape.
1622 if let Some(end_line_idx) = multiline_open_end
1623 && !blank_terminated
1624 && depth_aware_tag.is_some()
1625 && depth <= 0
1626 && lift_mode
1627 && (bq_depth == 0 || bq_multiline_close_lift_tag.is_some())
1628 && !pre_content.is_empty()
1629 {
1630 let tag_name_opt: Option<&str> = if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1631 Some("div")
1632 } else if strict_block_lift {
1633 strict_block_tag_name
1634 } else if let Some(name) = bq_multiline_close_lift_tag {
1635 Some(name)
1636 } else {
1637 None
1638 };
1639 if let Some(tag_name) = tag_name_opt {
1640 let (pre_no_nl, post_nl) = strip_newline(&pre_content);
1641 if let Some((leading, close_part)) =
1642 try_split_close_line_depth_aware(pre_no_nl, tag_name)
1643 {
1644 let close_marker_end =
1645 split_close_marker_end(close_part, tag_name).unwrap_or(close_part.len());
1646 let close_marker = &close_part[..close_marker_end];
1647 let same_line_trailing = &close_part[close_marker_end..];
1648 let policy = if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1649 LastParaDemote::SkipTrailingBlanks
1650 } else {
1651 LastParaDemote::OnlyIfLast
1652 };
1653 emit_html_block_body_lifted(builder, "", &[], leading, policy, config);
1654 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1655 if same_line_trailing.is_empty() {
1656 let mut close_line = String::with_capacity(close_marker.len() + post_nl.len());
1657 close_line.push_str(close_marker);
1658 close_line.push_str(post_nl);
1659 emit_html_block_line(builder, &close_line, 0);
1660 builder.finish_node();
1661 builder.finish_node(); // HtmlBlock
1662 } else {
1663 builder.token(SyntaxKind::TEXT.into(), close_marker);
1664 builder.finish_node(); // HTML_BLOCK_TAG
1665 builder.finish_node(); // HtmlBlock
1666
1667 let mut trailing_text =
1668 String::with_capacity(same_line_trailing.len() + post_nl.len());
1669 trailing_text.push_str(same_line_trailing);
1670 trailing_text.push_str(post_nl);
1671 let mut inner_options = config.clone();
1672 let refdefs = config.refdef_labels.clone().unwrap_or_default();
1673 inner_options.refdef_labels = Some(refdefs.clone());
1674 let inner_root = crate::parser::parse_with_refdefs(
1675 &trailing_text,
1676 Some(inner_options),
1677 refdefs,
1678 );
1679 let mut bq = None;
1680 graft_document_children(builder, &inner_root, LastParaDemote::Never, &mut bq);
1681 }
1682 return end_line_idx + 1;
1683 }
1684 }
1685 }
1686
1687 let same_line_closed = !blank_terminated
1688 && multiline_open_end.is_none()
1689 && (void_block
1690 || match &depth_aware_tag {
1691 Some(_) => depth <= 0,
1692 None => is_closing_marker(first_inner, &block_type),
1693 });
1694 if same_line_closed {
1695 log::trace!(
1696 "HTML block at line {} opens and closes on same line",
1697 start_pos + 1
1698 );
1699 // Same-line structural lift (div or non-div strict-block):
1700 // pre_content holds the bytes after the open `>` (including
1701 // the close `</tag>` and the trailing newline). Split into
1702 // body + close tag, emit body via recursive parse, emit close
1703 // tag as a sibling `HTML_BLOCK_TAG`.
1704 let same_line_lift_tag: Option<&str> = if !lift_mode || pre_content.is_empty() {
1705 None
1706 } else if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV && same_line_div_lift_safe {
1707 Some("div")
1708 } else if same_line_strict_lift_safe {
1709 strict_block_tag_name
1710 } else if let Some(name) = same_line_bq_lift_tag {
1711 // Bq same-line: body has no inner newlines so the standard
1712 // `emit_html_block_body_lifted` (with `bq: &mut None`) is
1713 // sufficient. The bq prefix `> ` lives on the outer
1714 // BLOCK_QUOTE, outside the HTML_BLOCK[_DIV] span.
1715 Some(name)
1716 } else {
1717 None
1718 };
1719 if let Some(tag_name) = same_line_lift_tag {
1720 let (pre_no_nl, post_nl) = strip_newline(&pre_content);
1721 // Depth-aware split: handles `<tag>foo</tag>bar` (single
1722 // close, trailing text), `<tag>foo</tag></tag>` (matched
1723 // close + unmatched trailing close → sibling RawBlock),
1724 // and `<tag><tag>x</tag></tag>bar` (nested same-tag,
1725 // recursive body parse).
1726 if let Some((leading, close_part)) =
1727 try_split_close_line_depth_aware(pre_no_nl, tag_name)
1728 {
1729 // `close_part` starts with `</tag` and contains the close
1730 // marker followed by any same-line trailing text. Split
1731 // off the close marker bytes (`</tag>`) so the close
1732 // `HTML_BLOCK_TAG` carries only those bytes; trailing
1733 // text is parsed and grafted as a sibling block at the
1734 // parent level (matches pandoc-native shape:
1735 // `<div>foo</div>bar` → `Div [Plain[foo]] + Para [bar]`).
1736 let close_marker_end =
1737 split_close_marker_end(close_part, tag_name).unwrap_or(close_part.len());
1738 let close_marker = &close_part[..close_marker_end];
1739 let same_line_trailing = &close_part[close_marker_end..];
1740
1741 // Same-line is always close-butted; div demotes the
1742 // trailing Para→Plain via `SkipTrailingBlanks`.
1743 // Non-div strict-block uses `OnlyIfLast` (consistent
1744 // with butted-close — no trailing BLANK_LINE before
1745 // the close means the trailing Para demotes).
1746 let policy = if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1747 LastParaDemote::SkipTrailingBlanks
1748 } else {
1749 LastParaDemote::OnlyIfLast
1750 };
1751 emit_html_block_body_lifted(builder, "", &[], leading, policy, config);
1752 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1753 if same_line_trailing.is_empty() {
1754 let mut close_line = String::with_capacity(close_marker.len() + post_nl.len());
1755 close_line.push_str(close_marker);
1756 close_line.push_str(post_nl);
1757 emit_html_block_line(builder, &close_line, 0);
1758 builder.finish_node();
1759 builder.finish_node(); // HtmlBlock
1760 } else {
1761 // Close tag holds only the close-marker bytes;
1762 // trailing + newline graft as siblings of the
1763 // wrapper (matches pandoc's per-tag block split).
1764 builder.token(SyntaxKind::TEXT.into(), close_marker);
1765 builder.finish_node(); // HTML_BLOCK_TAG
1766 builder.finish_node(); // HtmlBlock
1767
1768 let mut trailing_text =
1769 String::with_capacity(same_line_trailing.len() + post_nl.len());
1770 trailing_text.push_str(same_line_trailing);
1771 trailing_text.push_str(post_nl);
1772 let mut inner_options = config.clone();
1773 let refdefs = config.refdef_labels.clone().unwrap_or_default();
1774 inner_options.refdef_labels = Some(refdefs.clone());
1775 let inner_root = crate::parser::parse_with_refdefs(
1776 &trailing_text,
1777 Some(inner_options),
1778 refdefs,
1779 );
1780 let mut bq = None;
1781 graft_document_children(builder, &inner_root, LastParaDemote::Never, &mut bq);
1782 }
1783 return start_pos + 1;
1784 }
1785 }
1786 builder.finish_node(); // HtmlBlock
1787 return start_pos + 1;
1788 }
1789
1790 let mut current_pos = multiline_open_end
1791 .map(|end| end + 1)
1792 .unwrap_or(start_pos + 1);
1793 let mut content_lines: Vec<&str> = Vec::new();
1794 let mut found_closing = false;
1795
1796 // Parse content until we find the closing marker
1797 while current_pos < lines.len() {
1798 let line = lines[current_pos];
1799 let (line_bq_depth, inner) = count_blockquote_markers(line);
1800
1801 // Only process lines at the same or deeper blockquote depth
1802 if line_bq_depth < bq_depth {
1803 break;
1804 }
1805
1806 // Blank-line-terminated blocks (types 6/7) end before the blank line.
1807 // The blank line itself is not part of the block.
1808 if blank_terminated && inner.trim().is_empty() {
1809 break;
1810 }
1811
1812 // Check for closing marker. Under depth-aware mode (Pandoc dialect)
1813 // count opens/closes of the same tag name and only close when depth
1814 // returns to 0; otherwise fall back to substring-match on the line.
1815 let line_closes = match &depth_aware_tag {
1816 Some(tag_name) => {
1817 let (opens, closes) = count_tag_balance(inner, tag_name);
1818 depth += opens as i64;
1819 depth -= closes as i64;
1820 depth <= 0
1821 }
1822 None => is_closing_marker(inner, &block_type),
1823 };
1824
1825 if line_closes {
1826 log::trace!("Found HTML block closing at line {}", current_pos + 1);
1827 found_closing = true;
1828
1829 // Pandoc-dialect blockquote-wrapped clean-shape lift: when
1830 // the open and close tags stand alone on their source lines
1831 // (no trailing on open, no body content on close after
1832 // stripping bq markers), lift the body lines structurally
1833 // so the projector walks CST children instead of
1834 // byte-reparsing via `collect_html_block_text_skip_bq_markers`.
1835 //
1836 // Covers `<div>` (HTML_BLOCK_DIV → Block::Div with body
1837 // grafted, Para preserved), non-div strict-block tags
1838 // (`<form>`, `<section>`, …) and inline-block matched-pair
1839 // tags (`<video>`, `<iframe>`, …) — the latter two under
1840 // HTML_BLOCK with the structural lift hitting pandoc's
1841 // RawBlock + Plain + RawBlock shape via `OnlyIfLast`
1842 // demotion. Inline-block additionally bails if the body
1843 // starts at a fresh-block position with a void block tag
1844 // (mirrors the non-bq matched-pair gate).
1845 //
1846 // Other bq-wrapped shapes (butted-close / open-trailing /
1847 // same-line) still fall through to the opaque path.
1848 // Multi-line opens are allowed here as of 2026-05-12: the
1849 // open `HTML_BLOCK_TAG` was emitted (potentially with HTML_ATTRS
1850 // per attr line and per-line bq prefix tokens) by the bq-aware
1851 // `emit_multiline_open_tag_with_attrs`. `pre_content` stays
1852 // empty for multi-line opens (the emitter writes any trailing
1853 // bytes on the last open line directly as TEXT inside
1854 // HTML_BLOCK_TAG, not into `pre_content`) — so multi-line +
1855 // trailing falls through to the opaque path, matching the non-
1856 // bq deferral.
1857 let bq_lift_tag: Option<&str> = if bq_depth > 0 && pre_content.is_empty() {
1858 if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1859 Some("div")
1860 } else if wrapper_kind == SyntaxKind::HTML_BLOCK {
1861 match &block_type {
1862 HtmlBlockType::BlockTag {
1863 tag_name,
1864 is_verbatim: false,
1865 closed_by_blank_line: false,
1866 depth_aware: true,
1867 closes_at_open_tag: false,
1868 is_closing: false,
1869 } if is_pandoc_lift_eligible_block_tag(tag_name) => Some(tag_name.as_str()),
1870 _ => None,
1871 }
1872 } else {
1873 None
1874 }
1875 } else {
1876 None
1877 };
1878
1879 let bq_clean_lift = bq_lift_tag.is_some_and(|tag_name| {
1880 // Open-shape: last open line must end with `>` (clean
1881 // close-of-open). For single-line, that's `first_inner`
1882 // (already bq-stripped); for multi-line, strip bq markers
1883 // from `lines[end_line_idx]` and check the same.
1884 let last_open_line: &str = match multiline_open_end {
1885 None => first_inner,
1886 Some(end) if prefix.bq_depth() > 0 || prefix.list_content_col() > 0 => {
1887 prefix.strip(lines[end])
1888 }
1889 Some(end) => lines[end],
1890 };
1891 let (open_no_nl, _) = strip_newline(last_open_line);
1892 if !open_no_nl.trim_end_matches([' ', '\t']).ends_with('>') {
1893 return false;
1894 }
1895 let close_stripped = prefix.strip(line);
1896 let (close_no_nl, _) = strip_newline(close_stripped);
1897 if !close_no_nl
1898 .trim_start_matches([' ', '\t'])
1899 .starts_with("</")
1900 {
1901 return false;
1902 }
1903 if is_pandoc_inline_block_tag_name(tag_name)
1904 && inline_block_void_interior_abandons(
1905 first_inner,
1906 lines,
1907 start_pos,
1908 multiline_open_end,
1909 bq_depth,
1910 tag_name,
1911 )
1912 {
1913 return false;
1914 }
1915 true
1916 });
1917
1918 if bq_clean_lift {
1919 let demote_policy = if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1920 LastParaDemote::Never
1921 } else {
1922 LastParaDemote::OnlyIfLast
1923 };
1924 emit_html_block_body_lifted_bq(
1925 builder,
1926 &content_lines,
1927 prefix,
1928 demote_policy,
1929 config,
1930 );
1931 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1932 emit_html_block_line(builder, line, bq_depth);
1933 builder.finish_node();
1934 current_pos += 1;
1935 break;
1936 }
1937
1938 // Bq messy-shape lift — single-line open with trailing or
1939 // butted-close (or both). `pre_content` already captures any
1940 // open-trailing bytes (open `HTML_BLOCK_TAG` ends at `>`);
1941 // strip the close line's bq markers before splitting so
1942 // `leading` and `close_part` are bq-prefix-free. Body parses
1943 // recursively from `pre_content + stripped(content_lines) +
1944 // leading`, with per-line bq prefixes re-injected so the CST
1945 // stays byte-equal to the source. Demote: div is keyed on
1946 // close-butted-ness (Plain when leading non-empty, Para
1947 // otherwise); non-div uses OnlyIfLast either way.
1948 if let Some(tag_name) = bq_messy_lift_tag {
1949 let close_stripped = prefix.strip(line);
1950 let close_prefix_len = line.len() - close_stripped.len();
1951 let close_prefix = &line[..close_prefix_len];
1952 if let Some((leading, close_part)) = try_split_close_line(close_stripped, tag_name)
1953 {
1954 let policy = if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
1955 if leading.is_empty() {
1956 LastParaDemote::Never
1957 } else {
1958 LastParaDemote::SkipTrailingBlanks
1959 }
1960 } else {
1961 LastParaDemote::OnlyIfLast
1962 };
1963 emit_html_block_body_lifted_bq_messy(
1964 builder,
1965 &pre_content,
1966 &content_lines,
1967 leading,
1968 close_prefix,
1969 prefix,
1970 policy,
1971 config,
1972 );
1973 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
1974 // When `leading` is empty, no recursive-parse output carries
1975 // the close line's bq prefix, so emit it here before the
1976 // close tag. When `leading` is non-empty,
1977 // `emit_html_block_body_lifted_bq_messy` already injected
1978 // the prefix at the start of the leading bytes (via the
1979 // BqPrefixState entry); emitting again would double the
1980 // prefix bytes and break losslessness.
1981 if leading.is_empty() {
1982 emit_bq_prefix_tokens(builder, close_prefix);
1983 }
1984 emit_html_block_line(builder, close_part, 0);
1985 builder.finish_node();
1986 current_pos += 1;
1987 break;
1988 }
1989 }
1990
1991 // Under lift mode, try to split the close line into a
1992 // leading "body content" prefix and the close-marker
1993 // remainder using depth-aware matching. Walks at depth 1
1994 // (we're inside the open tag) so nested same-tag opens
1995 // (e.g. `<inner></inner></tag>` style with a nested div)
1996 // are absorbed into the body and parsed recursively, and
1997 // multi-close shapes (`foo</div></div>` on the close line)
1998 // peel off the matched-pair close — the unmatched
1999 // trailing close projects as a sibling `RawBlock` per
2000 // pandoc-native. For `<div>`, non-empty `leading`
2001 // propagates pandoc's `markdown_in_html_blocks` Plain
2002 // demotion rule. For non-div strict-block tags, demotion
2003 // follows pandoc's `OnlyIfLast` rule (demote the trailing
2004 // Para only when no blank line precedes the close).
2005 let close_split_tag = if lift_mode {
2006 if strict_block_lift {
2007 strict_block_tag_name
2008 } else if wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
2009 Some("div")
2010 } else {
2011 None
2012 }
2013 } else {
2014 None
2015 };
2016 let (close_no_nl, close_post_nl) = strip_newline(line);
2017 let close_split = close_split_tag
2018 .and_then(|name| try_split_close_line_depth_aware(close_no_nl, name));
2019
2020 if let Some((leading, close_part)) = close_split {
2021 // Close-line leading that is whitespace-only is close-tag
2022 // indentation, not body content (pandoc-native strips it
2023 // from the close RawBlock and treats the close as butted —
2024 // see ` </tag>` shapes). Route those bytes into the
2025 // close `HTML_BLOCK_TAG` as a WHITESPACE token so the
2026 // projector strips them; keep the demote policy keyed on
2027 // the original leading so butted-close detection (Plain
2028 // demotion for div, OnlyIfLast for non-div) still fires.
2029 let leading_is_ws_only =
2030 !leading.is_empty() && leading.bytes().all(|b| b == b' ' || b == b'\t');
2031 let body_leading = if leading_is_ws_only { "" } else { leading };
2032 let policy = if strict_block_lift {
2033 LastParaDemote::OnlyIfLast
2034 } else if !leading.is_empty() {
2035 LastParaDemote::SkipTrailingBlanks
2036 } else {
2037 LastParaDemote::Never
2038 };
2039 // Split close_part into close-marker bytes (`</tag>`)
2040 // and trailing bytes (e.g. an extra `</div>` for the
2041 // double-close case, or `bar` for trailing text after
2042 // a normal close). Trailing bytes are recursively
2043 // parsed and grafted as siblings of the HTML_BLOCK_DIV
2044 // wrapper.
2045 let close_tag_name = close_split_tag.expect("close_split_tag present");
2046 let close_marker_end =
2047 split_close_marker_end(close_part, close_tag_name).unwrap_or(close_part.len());
2048 let close_marker = &close_part[..close_marker_end];
2049 let close_trailing = &close_part[close_marker_end..];
2050
2051 emit_html_block_body_lifted(
2052 builder,
2053 &pre_content,
2054 &content_lines,
2055 body_leading,
2056 policy,
2057 config,
2058 );
2059 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
2060 if leading_is_ws_only {
2061 builder.token(SyntaxKind::WHITESPACE.into(), leading);
2062 }
2063 if close_trailing.is_empty() {
2064 let mut close_line =
2065 String::with_capacity(close_marker.len() + close_post_nl.len());
2066 close_line.push_str(close_marker);
2067 close_line.push_str(close_post_nl);
2068 emit_html_block_line(builder, &close_line, 0);
2069 builder.finish_node();
2070 } else {
2071 // Close tag holds only the close-marker bytes;
2072 // trailing + newline graft as siblings.
2073 builder.token(SyntaxKind::TEXT.into(), close_marker);
2074 builder.finish_node(); // HTML_BLOCK_TAG
2075 builder.finish_node(); // HtmlBlock
2076
2077 let mut trailing_text =
2078 String::with_capacity(close_trailing.len() + close_post_nl.len());
2079 trailing_text.push_str(close_trailing);
2080 trailing_text.push_str(close_post_nl);
2081 let mut inner_options = config.clone();
2082 let refdefs = config.refdef_labels.clone().unwrap_or_default();
2083 inner_options.refdef_labels = Some(refdefs.clone());
2084 let inner_root = crate::parser::parse_with_refdefs(
2085 &trailing_text,
2086 Some(inner_options),
2087 refdefs,
2088 );
2089 let mut bq = None;
2090 graft_document_children(builder, &inner_root, LastParaDemote::Never, &mut bq);
2091 current_pos += 1;
2092 return current_pos;
2093 }
2094 } else {
2095 emit_html_block_body(
2096 builder,
2097 &pre_content,
2098 &content_lines,
2099 bq_depth,
2100 wrapper_kind,
2101 lift_mode,
2102 config,
2103 );
2104 builder.start_node(SyntaxKind::HTML_BLOCK_TAG.into());
2105 emit_html_block_line(builder, line, bq_depth);
2106 builder.finish_node();
2107 }
2108
2109 current_pos += 1;
2110 break;
2111 }
2112
2113 // Regular content line
2114 content_lines.push(line);
2115 current_pos += 1;
2116 }
2117
2118 // If we didn't find a closing marker, emit what we collected
2119 if !found_closing {
2120 log::trace!("HTML block at line {} has no closing marker", start_pos + 1);
2121 emit_html_block_body(
2122 builder,
2123 &pre_content,
2124 &content_lines,
2125 bq_depth,
2126 wrapper_kind,
2127 lift_mode,
2128 config,
2129 );
2130 }
2131
2132 builder.finish_node(); // HtmlBlock
2133 current_pos
2134}
2135
2136/// Emit the collected inner content lines for an HTML block.
2137///
2138/// For `HTML_BLOCK_DIV` under Pandoc with `lift_mode == true` (single-
2139/// line `<div>` open outside blockquote), recursively parse the inner
2140/// content (including any open-tag trailing) as Pandoc-flavored
2141/// markdown and graft the resulting top-level blocks as direct children
2142/// of the wrapper. This is the Phase 6 structural lift — the projector
2143/// and downstream consumers (linter, salsa, LSP) can walk the
2144/// structural children instead of re-tokenizing the body bytes.
2145///
2146/// All other shapes — opaque `HTML_BLOCK`, `HTML_BLOCK_DIV` inside a
2147/// blockquote, multi-line open, or no content at all — fall through to
2148/// the legacy `HTML_BLOCK_CONTENT`-with-TEXT capture.
2149///
2150/// CST bytes remain byte-identical to source: the recursive parser is
2151/// lossless on the same byte slice the legacy path would have captured
2152/// as TEXT.
2153fn emit_html_block_body(
2154 builder: &mut GreenNodeBuilder<'static>,
2155 pre_content: &str,
2156 content_lines: &[&str],
2157 bq_depth: usize,
2158 wrapper_kind: SyntaxKind,
2159 lift_mode: bool,
2160 config: &ParserOptions,
2161) {
2162 if pre_content.is_empty() && content_lines.is_empty() {
2163 return;
2164 }
2165 if lift_mode && wrapper_kind == SyntaxKind::HTML_BLOCK_DIV {
2166 // Reached when the parser walked to end-of-input without finding
2167 // `</div>` (unbalanced div) — no close tag, no Plain demotion.
2168 emit_html_block_body_lifted(
2169 builder,
2170 pre_content,
2171 content_lines,
2172 "",
2173 LastParaDemote::Never,
2174 config,
2175 );
2176 return;
2177 }
2178 // Legacy path: opaque TEXT capture. `pre_content` is always empty
2179 // here (lift_mode is the only path that populates it), but be
2180 // defensive — if a trailing prefix snuck in, emit it as TEXT so
2181 // bytes are preserved.
2182 builder.start_node(SyntaxKind::HTML_BLOCK_CONTENT.into());
2183 if !pre_content.is_empty() {
2184 builder.token(SyntaxKind::TEXT.into(), pre_content);
2185 }
2186 for content_line in content_lines {
2187 emit_html_block_line(builder, content_line, bq_depth);
2188 }
2189 builder.finish_node();
2190}
2191
2192/// Rule for promoting the trailing `PARAGRAPH` of an HTML-block body
2193/// to `PLAIN` when grafting children into the structural CST.
2194#[derive(Copy, Clone, Debug)]
2195enum LastParaDemote {
2196 /// Never demote — pandoc preserves the trailing `Para`.
2197 Never,
2198 /// Demote the LAST `PARAGRAPH` child, skipping any trailing
2199 /// `BLANK_LINE` children. Used for `<div>` shapes where the close
2200 /// tag is butted against the paragraph text on its source line —
2201 /// pandoc's `markdown_in_html_blocks` Plain demotion.
2202 SkipTrailingBlanks,
2203 /// Demote the LAST top-level child only when it is a `PARAGRAPH`
2204 /// (i.e. no trailing `BLANK_LINE` precedes the close tag). Used
2205 /// for non-div strict-block tags whose body emits at top-level
2206 /// adjacent to the close-tag `RawBlock`; pandoc's rule there
2207 /// demotes the trailing `Para` to `Plain` unless a blank line
2208 /// separates them.
2209 OnlyIfLast,
2210}
2211
2212/// Lift the HTML-block body into structural CST children: build the
2213/// inner text from `pre_content` + `content_lines` + `post_content`
2214/// (in order), recursively parse it as Pandoc-flavored markdown, and
2215/// graft the resulting top-level blocks into `builder`. `demote_policy`
2216/// controls whether the trailing paragraph is retagged as `PLAIN` to
2217/// encode pandoc's Plain/Para adjacency rules structurally.
2218fn emit_html_block_body_lifted(
2219 builder: &mut GreenNodeBuilder<'static>,
2220 pre_content: &str,
2221 content_lines: &[&str],
2222 post_content: &str,
2223 demote_policy: LastParaDemote,
2224 config: &ParserOptions,
2225) {
2226 emit_html_block_body_lifted_inner(
2227 builder,
2228 pre_content,
2229 content_lines,
2230 post_content,
2231 demote_policy,
2232 config,
2233 &mut None,
2234 )
2235}
2236
2237/// Body-lift variant for `<div>` inside a blockquote. Strips
2238/// `bq_depth` levels of blockquote markers from each `content_line`,
2239/// captures the per-line prefix bytes, and grafts the recursive parse
2240/// with prefix injection so the output CST stays byte-equal to the
2241/// source. `pre_content` and `post_content` must be empty (the bq
2242/// clean lift only handles the shape where the open and close tags
2243/// stand alone on their source lines).
2244fn emit_html_block_body_lifted_bq(
2245 builder: &mut GreenNodeBuilder<'static>,
2246 content_lines: &[&str],
2247 prefix: &ContainerPrefix,
2248 demote_policy: LastParaDemote,
2249 config: &ParserOptions,
2250) {
2251 let mut prefix_lines: Vec<ContainerPrefixLine> = Vec::with_capacity(content_lines.len());
2252 let mut stripped_lines: Vec<&str> = Vec::with_capacity(content_lines.len());
2253 for cl in content_lines {
2254 let (li, bq, inner) = prefix.split(cl);
2255 prefix_lines.push(ContainerPrefixLine {
2256 list_indent: li.to_string(),
2257 bq_prefix: bq.to_string(),
2258 });
2259 stripped_lines.push(inner);
2260 }
2261 let mut state = ContainerPrefixState::new(prefix_lines);
2262 emit_html_block_body_lifted_inner(
2263 builder,
2264 "",
2265 &stripped_lines,
2266 "",
2267 demote_policy,
2268 config,
2269 &mut state,
2270 )
2271}
2272
2273/// Body-lift variant for the bq messy-shape lift — open-trailing,
2274/// butted-close, or both. The open-trailing bytes (if any) sit in
2275/// `pre_content` (line 0 of the body — no bq prefix in source because
2276/// line 0's `> ` is consumed by the outer BLOCK_QUOTE). Content lines
2277/// each carry their own bq prefix. The close line's `leading` (body
2278/// bytes before `</tag>`) sits on the close line, prefixed in source
2279/// by `close_line_prefix` (the bq prefix captured from `line`).
2280///
2281/// Builds `prefixes` so each emitted line in the recursive parse
2282/// output gets the right per-line bq prefix re-injected at line start:
2283/// `pre_content` → empty prefix (no source `> ` precedes it); each
2284/// content line → its stripped prefix; `leading` → `close_line_prefix`.
2285/// Result CST stays byte-equal to source.
2286#[allow(clippy::too_many_arguments)]
2287fn emit_html_block_body_lifted_bq_messy(
2288 builder: &mut GreenNodeBuilder<'static>,
2289 pre_content: &str,
2290 content_lines: &[&str],
2291 leading: &str,
2292 close_line_prefix: &str,
2293 prefix: &ContainerPrefix,
2294 demote_policy: LastParaDemote,
2295 config: &ParserOptions,
2296) {
2297 let mut prefix_lines: Vec<ContainerPrefixLine> = Vec::new();
2298 if !pre_content.is_empty() {
2299 prefix_lines.push(ContainerPrefixLine::default());
2300 }
2301 let mut stripped_lines: Vec<&str> = Vec::with_capacity(content_lines.len());
2302 for cl in content_lines {
2303 let (li, bq, inner) = prefix.split(cl);
2304 prefix_lines.push(ContainerPrefixLine {
2305 list_indent: li.to_string(),
2306 bq_prefix: bq.to_string(),
2307 });
2308 stripped_lines.push(inner);
2309 }
2310 if !leading.is_empty() {
2311 // The close line carries its own captured prefix bytes; treat
2312 // them as bq-prefix only (no list-indent split applied) to keep
2313 // the legacy bq-only re-injection behavior for messy-shape
2314 // close-line lifts.
2315 prefix_lines.push(ContainerPrefixLine::bq_only(close_line_prefix.to_string()));
2316 }
2317 let mut state = ContainerPrefixState::new(prefix_lines);
2318 emit_html_block_body_lifted_inner(
2319 builder,
2320 pre_content,
2321 &stripped_lines,
2322 leading,
2323 demote_policy,
2324 config,
2325 &mut state,
2326 )
2327}
2328
2329fn emit_html_block_body_lifted_inner(
2330 builder: &mut GreenNodeBuilder<'static>,
2331 pre_content: &str,
2332 content_lines: &[&str],
2333 post_content: &str,
2334 demote_policy: LastParaDemote,
2335 config: &ParserOptions,
2336 bq: &mut Option<ContainerPrefixState>,
2337) {
2338 if pre_content.is_empty() && content_lines.is_empty() && post_content.is_empty() {
2339 return;
2340 }
2341 let mut inner_text = String::with_capacity(
2342 pre_content.len()
2343 + content_lines.iter().map(|s| s.len()).sum::<usize>()
2344 + post_content.len(),
2345 );
2346 inner_text.push_str(pre_content);
2347 for line in content_lines {
2348 inner_text.push_str(line);
2349 }
2350 inner_text.push_str(post_content);
2351
2352 let mut inner_options = config.clone();
2353 let refdefs = config.refdef_labels.clone().unwrap_or_default();
2354 inner_options.refdef_labels = Some(refdefs.clone());
2355 let inner_root = crate::parser::parse_with_refdefs(&inner_text, Some(inner_options), refdefs);
2356 graft_document_children(builder, &inner_root, demote_policy, bq);
2357}
2358
2359/// Walk a parsed inner document's top-level children and re-emit them
2360/// into `builder`. The document's wrapper node is skipped — only its
2361/// children are grafted.
2362///
2363/// `demote_policy` controls whether a trailing `PARAGRAPH` is retagged
2364/// as `PLAIN` — see [`LastParaDemote`].
2365///
2366/// `bq` is `Some` when grafting a body that lived inside an outer
2367/// container (blockquote, list-item, or both) — token emission then
2368/// injects the captured per-line prefix tokens at line starts so the
2369/// CST stays byte-equal to source. See
2370/// [`super::container_prefix::ContainerPrefixState`].
2371fn graft_document_children(
2372 builder: &mut GreenNodeBuilder<'static>,
2373 doc: &SyntaxNode,
2374 demote_policy: LastParaDemote,
2375 bq: &mut Option<ContainerPrefixState>,
2376) {
2377 let children: Vec<rowan::NodeOrToken<SyntaxNode, _>> = doc.children_with_tokens().collect();
2378
2379 let mut demote_idx: Option<usize> = None;
2380 match demote_policy {
2381 LastParaDemote::Never => {}
2382 LastParaDemote::SkipTrailingBlanks => {
2383 for (i, c) in children.iter().enumerate().rev() {
2384 if let rowan::NodeOrToken::Node(n) = c {
2385 if n.kind() == SyntaxKind::BLANK_LINE {
2386 continue;
2387 }
2388 if n.kind() == SyntaxKind::PARAGRAPH {
2389 demote_idx = Some(i);
2390 }
2391 break;
2392 }
2393 }
2394 }
2395 LastParaDemote::OnlyIfLast => {
2396 for (i, c) in children.iter().enumerate().rev() {
2397 if let rowan::NodeOrToken::Node(n) = c {
2398 if n.kind() == SyntaxKind::PARAGRAPH {
2399 demote_idx = Some(i);
2400 }
2401 break;
2402 }
2403 }
2404 }
2405 }
2406
2407 for (i, child) in children.into_iter().enumerate() {
2408 match child {
2409 rowan::NodeOrToken::Node(n) => {
2410 if Some(i) == demote_idx {
2411 graft_subtree_as(builder, &n, SyntaxKind::PLAIN, bq);
2412 } else {
2413 graft_subtree(builder, &n, bq);
2414 }
2415 }
2416 rowan::NodeOrToken::Token(t) => {
2417 emit_grafted_token(builder, t.kind(), t.text(), bq);
2418 }
2419 }
2420 }
2421}
2422
2423/// Recursively re-emit `node` and its descendants into `builder`.
2424/// Token text is copied verbatim so the result is byte-identical to
2425/// the input span (modulo bq prefix tokens injected at line starts
2426/// when `bq` is `Some`).
2427fn graft_subtree(
2428 builder: &mut GreenNodeBuilder<'static>,
2429 node: &SyntaxNode,
2430 bq: &mut Option<ContainerPrefixState>,
2431) {
2432 graft_subtree_as(builder, node, node.kind(), bq);
2433}
2434
2435/// Like `graft_subtree` but the outer wrapper's `SyntaxKind` is
2436/// overridden. Used to retag a top-level `PARAGRAPH` as `PLAIN` for
2437/// the close-butted demotion rule.
2438fn graft_subtree_as(
2439 builder: &mut GreenNodeBuilder<'static>,
2440 node: &SyntaxNode,
2441 kind: SyntaxKind,
2442 bq: &mut Option<ContainerPrefixState>,
2443) {
2444 builder.start_node(kind.into());
2445 for child in node.children_with_tokens() {
2446 match child {
2447 rowan::NodeOrToken::Node(n) => graft_subtree(builder, &n, bq),
2448 rowan::NodeOrToken::Token(t) => {
2449 emit_grafted_token(builder, t.kind(), t.text(), bq);
2450 }
2451 }
2452 }
2453 builder.finish_node();
2454}
2455
2456/// Emit a single token while optionally injecting blockquote prefix
2457/// tokens at line starts. When `bq` is `None`, this is a plain
2458/// `builder.token()` passthrough.
2459fn emit_grafted_token(
2460 builder: &mut GreenNodeBuilder<'static>,
2461 kind: SyntaxKind,
2462 text: &str,
2463 bq: &mut Option<ContainerPrefixState>,
2464) {
2465 if let Some(state) = bq.as_mut() {
2466 if state.at_line_start {
2467 if let Some(line_prefix) = state.prefixes.get(state.line_idx) {
2468 emit_container_prefix_tokens(builder, line_prefix);
2469 }
2470 state.at_line_start = false;
2471 }
2472 builder.token(kind.into(), text);
2473 // `BLANK_LINE` token represents an entirely blank source line —
2474 // its text is `\n`. Treat both `NEWLINE` and the `BLANK_LINE`
2475 // token as line-ending so the per-line prefix index advances
2476 // correctly.
2477 if kind == SyntaxKind::NEWLINE || kind == SyntaxKind::BLANK_LINE {
2478 state.line_idx += 1;
2479 state.at_line_start = true;
2480 }
2481 } else {
2482 builder.token(kind.into(), text);
2483 }
2484}
2485
2486/// Emit a captured per-line bq prefix as a stream of `BLOCK_QUOTE_MARKER`
2487/// (`>`) and `WHITESPACE` (everything else, byte-by-byte) tokens.
2488fn emit_bq_prefix_tokens(builder: &mut GreenNodeBuilder<'static>, prefix: &str) {
2489 for ch in prefix.chars() {
2490 if ch == '>' {
2491 builder.token(SyntaxKind::BLOCK_QUOTE_MARKER.into(), ">");
2492 } else {
2493 let mut buf = [0u8; 4];
2494 builder.token(SyntaxKind::WHITESPACE.into(), ch.encode_utf8(&mut buf));
2495 }
2496 }
2497}
2498
2499/// Locate the byte index (within `line`) of the open-tag's closing `>`
2500/// after a quote-aware scan of `<tag_name ATTRS>`. Returns `None` when
2501/// the line doesn't fit the expected shape. Mirrors the inner scan of
2502/// `probe_open_tag_line_has_close_gt` but exposes the position so the
2503/// caller can slice off the trailing bytes.
2504fn locate_open_tag_close_gt(line: &str, tag_name: &str) -> Option<usize> {
2505 let bytes = line.as_bytes();
2506 let indent_end = bytes
2507 .iter()
2508 .position(|&b| b != b' ' && b != b'\t')
2509 .unwrap_or(bytes.len());
2510 let rest = &line[indent_end..];
2511 let rest_bytes = rest.as_bytes();
2512 let prefix_len = 1 + tag_name.len();
2513 if rest_bytes.len() < prefix_len + 1
2514 || rest_bytes[0] != b'<'
2515 || !rest_bytes[1..prefix_len].eq_ignore_ascii_case(tag_name.as_bytes())
2516 {
2517 return None;
2518 }
2519 let after_name = &rest[prefix_len..];
2520 let after_name_bytes = after_name.as_bytes();
2521 let mut i = 0usize;
2522 let mut quote: Option<u8> = None;
2523 while i < after_name_bytes.len() {
2524 match (quote, after_name_bytes[i]) {
2525 (None, b'"') | (None, b'\'') => quote = Some(after_name_bytes[i]),
2526 (Some(q), b2) if b2 == q => quote = None,
2527 (None, b'>') => return Some(indent_end + prefix_len + i),
2528 _ => {}
2529 }
2530 i += 1;
2531 }
2532 None
2533}
2534
2535/// Whether `slice` begins (after leading ASCII whitespace) with an
2536/// open tag whose name is a Pandoc void block tag (`<source>`,
2537/// `<embed>`, `<area>`, `<track>`). Close tags (`</...>`) and non-void
2538/// open tags return false.
2539///
2540/// Used by the inline-block matched-pair lift gate: pandoc-native
2541/// abandons the lift when the body's first non-blank content is a
2542/// fresh-block void tag (e.g. `<video>\n<source ...>\n</video>`
2543/// projects as RawBlock+RawBlock+Plain[..,RawInline</video>], not a
2544/// matched-pair lift).
2545fn slice_starts_with_void_block_tag(slice: &str) -> bool {
2546 let trimmed = slice.trim_start_matches([' ', '\t', '\n', '\r']);
2547 if !trimmed.starts_with('<') || trimmed.starts_with("</") {
2548 return false;
2549 }
2550 let Some(tag_end) = parse_open_tag(trimmed) else {
2551 return false;
2552 };
2553 let bytes = trimmed.as_bytes();
2554 let mut name_end = 1usize;
2555 while name_end < tag_end && (bytes[name_end].is_ascii_alphanumeric() || bytes[name_end] == b'-')
2556 {
2557 name_end += 1;
2558 }
2559 if name_end == 1 {
2560 return false;
2561 }
2562 is_pandoc_void_block_tag_name(&trimmed[1..name_end])
2563}
2564
2565/// Whether the body of an inline-block matched-pair (`<video>...`,
2566/// `<iframe>...`, `<button>...`) begins at a fresh-block position with
2567/// a void block tag — the condition under which pandoc-native abandons
2568/// the matched-pair lift. Probes three shapes:
2569///
2570/// - **Same-line** (`<video><source ...></video>`): trailing bytes
2571/// after the open `>` on `first_inner` start with `<source`.
2572/// - **Single-line open + multi-line body**: open-trailing on the open
2573/// line is empty/whitespace AND the first non-blank body line
2574/// (`lines[start_pos+1..]`) starts with a void tag.
2575/// - **Multi-line open**: same body-line scan starting at
2576/// `lines[multiline_open_end+1..]`.
2577///
2578/// Returns `false` when the body begins with text, with a close tag,
2579/// or with a non-void block tag — those cases all proceed with the
2580/// matched-pair lift.
2581fn inline_block_void_interior_abandons(
2582 first_inner: &str,
2583 lines: &[&str],
2584 start_pos: usize,
2585 multiline_open_end: Option<usize>,
2586 bq_depth: usize,
2587 tag_name: &str,
2588) -> bool {
2589 let (line_no_nl, _) = strip_newline(first_inner);
2590 let (body_start_line_idx, open_trailing) = match multiline_open_end {
2591 Some(end) => (end + 1, ""),
2592 None => {
2593 let gt = locate_open_tag_close_gt(line_no_nl, tag_name);
2594 let trailing = gt.map(|i| &line_no_nl[i + 1..]).unwrap_or("");
2595 (start_pos + 1, trailing)
2596 }
2597 };
2598 let trimmed = open_trailing.trim_start_matches([' ', '\t']);
2599 if !trimmed.is_empty() {
2600 return slice_starts_with_void_block_tag(trimmed);
2601 }
2602 for line in &lines[body_start_line_idx..] {
2603 let inner = if bq_depth > 0 {
2604 strip_n_blockquote_markers(line, bq_depth)
2605 } else {
2606 line
2607 };
2608 let trimmed = inner.trim_start_matches([' ', '\t', '\n', '\r']);
2609 if trimmed.is_empty() {
2610 continue;
2611 }
2612 return slice_starts_with_void_block_tag(trimmed);
2613 }
2614 false
2615}
2616
2617/// Probe whether the open-tag line has a valid (quote-aware) closing
2618/// `>` after the tag name. Admits trailing content after `>` (the
2619/// open-trailing shape `<form>foo`) — the caller is expected to capture
2620/// that trailing into the structural lift's `pre_content`.
2621pub(crate) fn probe_open_tag_line_has_close_gt(line: &str, tag_name: &str) -> bool {
2622 let bytes = line.as_bytes();
2623 let indent_end = bytes
2624 .iter()
2625 .position(|&b| b != b' ' && b != b'\t')
2626 .unwrap_or(bytes.len());
2627 let rest = &line[indent_end..];
2628 let rest_bytes = rest.as_bytes();
2629 let prefix_len = 1 + tag_name.len();
2630 if rest_bytes.len() < prefix_len + 1
2631 || rest_bytes[0] != b'<'
2632 || !rest_bytes[1..prefix_len].eq_ignore_ascii_case(tag_name.as_bytes())
2633 {
2634 return false;
2635 }
2636 let after_name = &rest[prefix_len..];
2637 let after_name_bytes = after_name.as_bytes();
2638 let mut i = 0usize;
2639 let mut quote: Option<u8> = None;
2640 while i < after_name_bytes.len() {
2641 match (quote, after_name_bytes[i]) {
2642 (None, b'"') | (None, b'\'') => quote = Some(after_name_bytes[i]),
2643 (Some(q), b2) if b2 == q => quote = None,
2644 (None, b'>') => return true,
2645 _ => {}
2646 }
2647 i += 1;
2648 }
2649 false
2650}
2651
2652/// Probe whether the same-line `<tag>BODY</tag>` shape on `line` can
2653/// be lifted structurally. Returns `true` only when:
2654/// - The line starts with `<tag_name` (modulo leading whitespace).
2655/// - The open tag's `>` exists with proper quote handling.
2656/// - The bytes after the open `>` contain a depth-zero matched
2657/// `</tag_name>` close (depth-aware: nested `<tag>` opens
2658/// increment depth; matching is case-insensitive, quote-aware).
2659///
2660/// Trailing bytes after the matched close are accepted and grafted
2661/// as a sibling block by the caller. Examples:
2662/// - `<div>foo</div>bar` → body=`foo`, trailing=`bar`.
2663/// - `<div>foo</div></div>` → body=`foo`, trailing=`</div>` (which
2664/// recursively parses to a `RawBlock`).
2665/// - `<div><div>x</div></div>bar` → body=`<div>x</div>` (nested div
2666/// parsed recursively), trailing=`bar`.
2667fn probe_same_line_lift(line: &str, tag_name: &str) -> bool {
2668 let bytes = line.as_bytes();
2669 let indent_end = bytes
2670 .iter()
2671 .position(|&b| b != b' ' && b != b'\t')
2672 .unwrap_or(bytes.len());
2673 let rest = &line[indent_end..];
2674 let rest_bytes = rest.as_bytes();
2675 let prefix_len = 1 + tag_name.len();
2676 if rest_bytes.len() < prefix_len
2677 || rest_bytes[0] != b'<'
2678 || !rest_bytes[1..prefix_len].eq_ignore_ascii_case(tag_name.as_bytes())
2679 {
2680 return false;
2681 }
2682 let after_name = &rest[prefix_len..];
2683 let after_name_bytes = after_name.as_bytes();
2684 let mut i = 0usize;
2685 let mut quote: Option<u8> = None;
2686 let mut gt_idx: Option<usize> = None;
2687 while i < after_name_bytes.len() {
2688 match (quote, after_name_bytes[i]) {
2689 (None, b'"') | (None, b'\'') => quote = Some(after_name_bytes[i]),
2690 (Some(q), b2) if b2 == q => quote = None,
2691 (None, b'>') => {
2692 gt_idx = Some(i);
2693 break;
2694 }
2695 _ => {}
2696 }
2697 i += 1;
2698 }
2699 let Some(gt_idx) = gt_idx else {
2700 return false;
2701 };
2702 let trailing = &after_name[gt_idx + 1..];
2703 // Depth-aware: walk `trailing` (we begin inside the open tag at
2704 // depth 1). Return true iff a matched `</tag>` exists where depth
2705 // returns to 0. Self-closing `<tag/>` opens don't bump depth.
2706 matched_close_offset(trailing, tag_name).is_some()
2707}
2708
2709/// Walk `trailing` (the bytes after an open `<tag ...>`'s closing `>`)
2710/// looking for the depth-zero matched `</tag>` close. Counts `<tag>`
2711/// opens and `</tag>` closes case-insensitively, quote-aware. Depth
2712/// starts at 1 (we begin inside the open tag). Self-closing opens
2713/// (`<tag/>`) do not increment depth.
2714///
2715/// Returns `Some((close_start, close_end))` where:
2716/// - `close_start` is the byte offset of `<` in the matched `</tag>`.
2717/// - `close_end` is one past the matched `>`.
2718///
2719/// Returns `None` when no matched close is present (unclosed tag,
2720/// depth never returns to 0).
2721fn matched_close_offset(trailing: &str, tag_name: &str) -> Option<(usize, usize)> {
2722 let bytes = trailing.as_bytes();
2723 let lower_line = trailing.to_ascii_lowercase();
2724 let lower_bytes = lower_line.as_bytes();
2725 let tag_lower = tag_name.to_ascii_lowercase();
2726 let tag_bytes = tag_lower.as_bytes();
2727
2728 let mut depth: i32 = 1;
2729 let mut i = 0usize;
2730
2731 while i < bytes.len() {
2732 if bytes[i] != b'<' {
2733 i += 1;
2734 continue;
2735 }
2736 let after = i + 1;
2737 let is_close = after < bytes.len() && bytes[after] == b'/';
2738 let name_start = if is_close { after + 1 } else { after };
2739 let matched = name_start + tag_bytes.len() <= bytes.len()
2740 && &lower_bytes[name_start..name_start + tag_bytes.len()] == tag_bytes;
2741 let after_name = name_start + tag_bytes.len();
2742 let is_boundary = matched
2743 && matches!(
2744 bytes.get(after_name).copied(),
2745 Some(b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/') | None
2746 );
2747
2748 // Scan forward to this tag bracket's `>`, respecting quoted
2749 // attribute values; track self-closing form (`/>`).
2750 let mut j = if matched { after_name } else { after };
2751 let mut quote: Option<u8> = None;
2752 let mut self_close = false;
2753 let mut found_gt = false;
2754 while j < bytes.len() {
2755 let b = bytes[j];
2756 match (quote, b) {
2757 (Some(q), x) if x == q => quote = None,
2758 (None, b'"') | (None, b'\'') => quote = Some(b),
2759 (None, b'>') => {
2760 found_gt = true;
2761 if j > i + 1 && bytes[j - 1] == b'/' {
2762 self_close = true;
2763 }
2764 break;
2765 }
2766 _ => {}
2767 }
2768 j += 1;
2769 }
2770
2771 if matched && is_boundary {
2772 if is_close {
2773 depth -= 1;
2774 if depth == 0 && found_gt {
2775 return Some((i, j + 1));
2776 }
2777 } else if !self_close {
2778 depth += 1;
2779 }
2780 }
2781
2782 if found_gt {
2783 i = j + 1;
2784 } else {
2785 // Unterminated `<...` — give up.
2786 break;
2787 }
2788 }
2789 None
2790}
2791
2792/// Locate the byte offset of the first `>` after a `</tag` prefix at
2793/// the start of `close_part`. Returns `Some(end_of_close_marker)` so
2794/// the caller can split `close_part` into the close-marker bytes
2795/// (`</tag>`) and any same-line trailing text. Returns `None` if the
2796/// expected prefix shape is missing — caller treats the whole slice
2797/// as the close marker (no trailing).
2798fn split_close_marker_end(close_part: &str, tag_name: &str) -> Option<usize> {
2799 let prefix_len = 2 + tag_name.len();
2800 let bytes = close_part.as_bytes();
2801 if bytes.len() < prefix_len
2802 || bytes[0] != b'<'
2803 || bytes[1] != b'/'
2804 || !bytes[2..prefix_len].eq_ignore_ascii_case(tag_name.as_bytes())
2805 {
2806 return None;
2807 }
2808 // Scan from after `</tag` to the first unquoted `>`.
2809 let mut i = prefix_len;
2810 let mut quote: Option<u8> = None;
2811 while i < bytes.len() {
2812 match (quote, bytes[i]) {
2813 (None, b'"') | (None, b'\'') => quote = Some(bytes[i]),
2814 (Some(q), b2) if b2 == q => quote = None,
2815 (None, b'>') => return Some(i + 1),
2816 _ => {}
2817 }
2818 i += 1;
2819 }
2820 None
2821}
2822
2823/// Try to split the close line of an HTML_BLOCK_DIV body into a
2824/// leading content prefix and a clean `</tag>...` remainder. Returns
2825/// `Some((leading, close_part))` only when the line contains exactly
2826/// one `</tag>` and no `<tag>` opens — the safe shape for the lift.
2827/// Returns `None` for nested closes (e.g. `<inner></inner></div>`),
2828/// for missing close tags, or for compound shapes the parser
2829/// shouldn't attempt to lift in this pass.
2830///
2831/// `leading` may be empty (close starts at column 0) or pure
2832/// whitespace (close on an indented line). Both count as "butted" per
2833/// pandoc's `markdown_in_html_blocks` rule — if leading is non-empty
2834/// the trailing paragraph inside the div demotes Para→Plain.
2835fn try_split_close_line<'a>(line: &'a str, tag_name: &str) -> Option<(&'a str, &'a str)> {
2836 let (opens, closes) = count_tag_balance(line, tag_name);
2837 if opens != 0 || closes != 1 {
2838 return None;
2839 }
2840 // Locate the close tag's opening `<` by lowercased substring search.
2841 // Safe because we've already established (above) that the line has
2842 // exactly one `</tag>` and no `<tag>` opens, so the first match is
2843 // THE close.
2844 let needle = format!("</{}", tag_name);
2845 let lower = line.to_ascii_lowercase();
2846 let close_lt = lower.find(&needle)?;
2847 Some((&line[..close_lt], &line[close_lt..]))
2848}
2849
2850/// Depth-aware variant of `try_split_close_line` used by the same-line
2851/// lift path. Walks `line` starting at depth 1 (we begin inside the
2852/// open `<tag>`) and splits at the byte position where the matched
2853/// `</tag>` close brings depth to 0. Returns `Some((body,
2854/// close_part))` where `body` is the bytes before the matched-close
2855/// start and `close_part` is the bytes from the matched close onward.
2856///
2857/// Unlike `try_split_close_line` this accepts nested same-tag opens
2858/// and multiple closes: for `<div><div>x</div></div>bar` it returns
2859/// body=`<div>x</div>` (a nested div the body lift parses
2860/// recursively) and close_part=`</div>bar`. For `<div>foo</div></div>`
2861/// it returns body=`foo`, close_part=`</div></div>` — the unmatched
2862/// trailing close projects as a sibling `RawBlock` per pandoc-native.
2863fn try_split_close_line_depth_aware<'a>(
2864 line: &'a str,
2865 tag_name: &str,
2866) -> Option<(&'a str, &'a str)> {
2867 let (close_start, _close_end) = matched_close_offset(line, tag_name)?;
2868 Some((&line[..close_start], &line[close_start..]))
2869}
2870
2871/// Emit the open-tag line of a lift-eligible HTML block (div or non-div
2872/// strict-block tag), splitting the bytes `[ws]<tag[ ws ATTRS]>[trailing]`
2873/// into `WHITESPACE? + TEXT("<tag") + (WHITESPACE + HTML_ATTRS{TEXT(attrs)})?
2874/// + TEXT(">") + TEXT(trailing)?`.
2875///
2876/// Bytes are byte-identical to the source — this only tokenizes at finer
2877/// granularity so `AttributeNode::cast(HTML_ATTRS)` can read the attribute
2878/// region structurally. Falls back to a single TEXT token if the line
2879/// doesn't fit the expected `<tag ...>` shape (defensive — the parser
2880/// only retags as the lift kind when this shape was matched).
2881///
2882/// `lift_trailing`: when true, bytes after `>` are NOT emitted as TEXT —
2883/// returned as `&str` instead so the caller can splice them into the
2884/// recursive-parse input for the structural body lift. When false
2885/// (legacy / non-lift path), trailing bytes are emitted as TEXT and an
2886/// empty slice is returned.
2887fn emit_open_tag_tokens<'a>(
2888 builder: &mut GreenNodeBuilder<'static>,
2889 line: &'a str,
2890 tag_name: &str,
2891 lift_trailing: bool,
2892) -> &'a str {
2893 let bytes = line.as_bytes();
2894 // Leading indent (CommonMark allows up to 3 spaces).
2895 let indent_end = bytes.iter().position(|&b| b != b' ').unwrap_or(bytes.len());
2896 if indent_end > 0 {
2897 builder.token(SyntaxKind::WHITESPACE.into(), &line[..indent_end]);
2898 }
2899 let rest = &line[indent_end..];
2900 // Match the literal `<tag_name` prefix (ASCII case-insensitive on the tag name).
2901 let prefix_len = 1 + tag_name.len();
2902 if !rest.starts_with('<')
2903 || rest.len() < prefix_len
2904 || !rest.as_bytes()[1..prefix_len].eq_ignore_ascii_case(tag_name.as_bytes())
2905 {
2906 builder.token(SyntaxKind::TEXT.into(), rest);
2907 return "";
2908 }
2909 let after_name = &rest[prefix_len..];
2910 let after_name_bytes = after_name.as_bytes();
2911 // Find the closing `>` of the open tag, respecting quoted attribute values.
2912 let mut i = 0usize;
2913 let mut quote: Option<u8> = None;
2914 let mut tag_close: Option<usize> = None;
2915 while i < after_name_bytes.len() {
2916 let b = after_name_bytes[i];
2917 match (quote, b) {
2918 (None, b'"') | (None, b'\'') => quote = Some(b),
2919 (Some(q), b2) if b2 == q => quote = None,
2920 (None, b'>') => {
2921 tag_close = Some(i);
2922 break;
2923 }
2924 _ => {}
2925 }
2926 i += 1;
2927 }
2928 let Some(tag_close) = tag_close else {
2929 // Open tag has no closing `>` on this line — defensive fallback.
2930 builder.token(SyntaxKind::TEXT.into(), rest);
2931 return "";
2932 };
2933 // Whitespace between the tag name and the attribute region.
2934 let attrs_inner = &after_name[..tag_close];
2935 let ws_end = attrs_inner
2936 .as_bytes()
2937 .iter()
2938 .position(|&b| !matches!(b, b' ' | b'\t'))
2939 .unwrap_or(attrs_inner.len());
2940 let leading_ws = &attrs_inner[..ws_end];
2941 // Strip a trailing self-closing slash and the whitespace before it
2942 // from the attribute region; emit them as TEXT outside the
2943 // HTML_ATTRS node so the structural region only holds attribute
2944 // bytes (not formatting punctuation).
2945 let attrs_after_ws = &attrs_inner[ws_end..];
2946 let mut attr_end = attrs_after_ws.len();
2947 let attr_bytes = attrs_after_ws.as_bytes();
2948 let mut self_close_start = attr_end;
2949 if attr_end > 0 && attr_bytes[attr_end - 1] == b'/' {
2950 self_close_start = attr_end - 1;
2951 attr_end = self_close_start;
2952 while attr_end > 0 && matches!(attr_bytes[attr_end - 1], b' ' | b'\t') {
2953 attr_end -= 1;
2954 }
2955 }
2956 let attrs_text = &attrs_after_ws[..attr_end];
2957 let trailing_text = &attrs_after_ws[attr_end..self_close_start.max(attr_end)];
2958 let after_self_close = &attrs_after_ws[self_close_start..];
2959
2960 // Use the original source bytes for the `<tag` prefix (preserves
2961 // source casing — losslessness).
2962 builder.token(SyntaxKind::TEXT.into(), &rest[..prefix_len]);
2963 if !leading_ws.is_empty() {
2964 builder.token(SyntaxKind::WHITESPACE.into(), leading_ws);
2965 }
2966 if !attrs_text.is_empty() {
2967 emit_html_attrs_node(builder, attrs_text);
2968 }
2969 if !trailing_text.is_empty() {
2970 builder.token(SyntaxKind::WHITESPACE.into(), trailing_text);
2971 }
2972 if !after_self_close.is_empty() {
2973 builder.token(SyntaxKind::TEXT.into(), after_self_close);
2974 }
2975 builder.token(SyntaxKind::TEXT.into(), ">");
2976 let after_gt = &after_name[tag_close + 1..];
2977 if lift_trailing {
2978 // Return trailing bytes to the caller (will be spliced into the
2979 // recursive-parse input for the body lift).
2980 return after_gt;
2981 }
2982 if !after_gt.is_empty() {
2983 builder.token(SyntaxKind::TEXT.into(), after_gt);
2984 }
2985 ""
2986}
2987
2988/// Detect a multi-line HTML open tag for `tag_name`. Returns
2989/// `Some(end_line_idx)` when the open tag's closing `>` is on a line *after*
2990/// `start_pos` and within `lines`; `None` for single-line opens (handled by
2991/// the existing path) or when the `>` is missing entirely.
2992///
2993/// Quoted attribute values (`"..."`, `'...'`) are honored so a `>` inside an
2994/// attribute value doesn't terminate the open tag. Quote state carries
2995/// across line boundaries.
2996fn find_multiline_open_end(
2997 lines: &[&str],
2998 start_pos: usize,
2999 first_inner: &str,
3000 tag_name: &str,
3001 prefix: &ContainerPrefix,
3002) -> Option<usize> {
3003 // Locate the `<tag_name` literal in `first_inner` to start scanning past
3004 // it. Match is ASCII case-insensitive; the parser preserves source casing.
3005 // `first_inner` is already bq-stripped by the caller; subsequent lines are
3006 // stripped inline below via `strip_n_blockquote_markers`.
3007 let trimmed = strip_leading_spaces(first_inner);
3008 let prefix_len = 1 + tag_name.len();
3009 if !trimmed.starts_with('<')
3010 || trimmed.len() < prefix_len
3011 || !trimmed[1..prefix_len].eq_ignore_ascii_case(tag_name)
3012 {
3013 return None;
3014 }
3015 let leading_indent = first_inner.len() - trimmed.len();
3016 let mut i = leading_indent + prefix_len; // past `<tag_name`
3017 let mut quote: Option<u8> = None;
3018
3019 // Scan first line for an unquoted `>`.
3020 let line0_bytes = first_inner.as_bytes();
3021 while i < line0_bytes.len() {
3022 match (quote, line0_bytes[i]) {
3023 (None, b'"') | (None, b'\'') => quote = Some(line0_bytes[i]),
3024 (Some(q), x) if x == q => quote = None,
3025 (None, b'>') => return None, // single-line case
3026 _ => {}
3027 }
3028 i += 1;
3029 }
3030
3031 // No `>` on first line. Scan subsequent lines, stripping `bq_depth`
3032 // blockquote markers per line so `> ` prefixes don't count toward the
3033 // quote-aware scan. Mirrors `pandoc_html_open_tag_closes`.
3034 let mut line_idx = start_pos + 1;
3035 while line_idx < lines.len() {
3036 let raw = lines[line_idx];
3037 let inner = prefix.strip(raw);
3038 for &b in inner.as_bytes() {
3039 match (quote, b) {
3040 (None, b'"') | (None, b'\'') => quote = Some(b),
3041 (Some(q), x) if x == q => quote = None,
3042 (None, b'>') => return Some(line_idx),
3043 _ => {}
3044 }
3045 }
3046 line_idx += 1;
3047 }
3048
3049 None
3050}
3051
3052/// Pandoc-only: validate that the HTML open tag starting at `lines[start_pos]`
3053/// is syntactically complete — i.e. an unquoted `>` exists somewhere from the
3054/// `<` onward, possibly spanning subsequent lines. Pandoc treats an unclosed
3055/// open tag (no `>` in the remaining input) as paragraph text rather than
3056/// starting a `RawBlock`; recognizing it as an HTML block makes the projector
3057/// reparse the same content recursively, causing a stack overflow.
3058///
3059/// Quote state (`"..."` / `'...'`) is threaded across line boundaries so a
3060/// `>` inside an attribute value doesn't count. Blank lines do not stop the
3061/// scan — pandoc's `htmlTag` reads across them, just emitting a warning when
3062/// the tag eventually closes far away.
3063pub(crate) fn pandoc_html_open_tag_closes(
3064 lines: &[&str],
3065 start_pos: usize,
3066 prefix: &ContainerPrefix,
3067) -> bool {
3068 if start_pos >= lines.len() {
3069 return false;
3070 }
3071 let mut quote: Option<u8> = None;
3072 for (offset, line) in lines.iter().enumerate().skip(start_pos) {
3073 let inner = prefix.strip(line);
3074 let bytes = inner.as_bytes();
3075 let mut i = 0usize;
3076 if offset == start_pos {
3077 while i < bytes.len() && bytes[i] == b' ' {
3078 i += 1;
3079 }
3080 if bytes.get(i) != Some(&b'<') {
3081 return false;
3082 }
3083 i += 1;
3084 }
3085 while i < bytes.len() {
3086 match (quote, bytes[i]) {
3087 (None, b'"') | (None, b'\'') => quote = Some(bytes[i]),
3088 (Some(q), x) if x == q => quote = None,
3089 (None, b'>') => return true,
3090 _ => {}
3091 }
3092 i += 1;
3093 }
3094 }
3095 false
3096}
3097
3098/// Emit a multi-line open tag spanning `lines[start_pos..=end_line_idx]` as
3099/// structural CST tokens, exposing the attribute region as `HTML_ATTRS` for
3100/// `AttributeNode::cast` to find. Bytes are byte-identical to the source —
3101/// only tokenization granularity changes. Used for `<div>` (Pandoc dialect)
3102/// and non-div strict-block tags (`<form>`, `<section>`, …) under the
3103/// Phase 6 structural lift.
3104///
3105/// Per-line layout (with `prefix_len = 1 + tag_name.len()`):
3106/// - Line 0: TEXT("<{tag_name}") + (optional WHITESPACE + HTML_ATTRS) + NEWLINE
3107/// - Lines 1..N-1: (optional WHITESPACE indent) + HTML_ATTRS + NEWLINE
3108/// - Line N (last): (optional WHITESPACE indent) + (HTML_ATTRS + WHITESPACE)?
3109/// + TEXT(">") + (TEXT(trailing))? + NEWLINE
3110///
3111/// Bytes inside HTML_ATTRS may include trailing whitespace before the next
3112/// newline; `parse_html_attribute_list` tolerates whitespace.
3113#[allow(clippy::too_many_arguments)]
3114fn emit_multiline_open_tag_with_attrs(
3115 builder: &mut GreenNodeBuilder<'static>,
3116 lines: &[&str],
3117 start_pos: usize,
3118 end_line_idx: usize,
3119 tag_name: &str,
3120 bq_depth: usize,
3121 lift_trailing: bool,
3122 pre_content: &mut String,
3123) {
3124 let prefix_len = 1 + tag_name.len();
3125 for (line_idx, raw) in lines
3126 .iter()
3127 .enumerate()
3128 .take(end_line_idx + 1)
3129 .skip(start_pos)
3130 {
3131 // Strip `bq_depth` blockquote markers from the source line so
3132 // indent/HTML_ATTRS/TEXT splitting ignores the bq prefix bytes.
3133 // Re-emit the stripped prefix as `BLOCK_QUOTE_MARKER` /
3134 // `WHITESPACE` tokens — but ONLY for lines past `start_pos`.
3135 // Line 0's bq prefix is consumed by the outer BLOCK_QUOTE node
3136 // before this parser runs; re-emitting it here would double
3137 // the bytes and break losslessness.
3138 let stripped = if bq_depth > 0 {
3139 strip_n_blockquote_markers(raw, bq_depth)
3140 } else {
3141 raw
3142 };
3143 let bq_prefix_len = raw.len() - stripped.len();
3144 if bq_prefix_len > 0 && line_idx != start_pos {
3145 emit_bq_prefix_tokens(builder, &raw[..bq_prefix_len]);
3146 }
3147 let line = stripped;
3148 let (line_no_nl, newline_str) = strip_newline(line);
3149
3150 if line_idx == start_pos {
3151 // Line 0: leading indent (if any) + "<{tag_name}" + (whitespace
3152 // + attrs)?. The closing `>` is on a later line, so any
3153 // remaining bytes after "<{tag_name}" on this line are the
3154 // start of the attribute region.
3155 let bytes = line_no_nl.as_bytes();
3156 let indent_end = bytes.iter().position(|&b| b != b' ').unwrap_or(bytes.len());
3157 if indent_end > 0 {
3158 builder.token(SyntaxKind::WHITESPACE.into(), &line_no_nl[..indent_end]);
3159 }
3160 // Defensive: caller verified the line starts with `<{tag_name}`.
3161 let after_indent = &line_no_nl[indent_end..];
3162 if after_indent.len() >= prefix_len {
3163 builder.token(SyntaxKind::TEXT.into(), &after_indent[..prefix_len]);
3164 let rest = &after_indent[prefix_len..];
3165 emit_attr_region(builder, rest);
3166 } else {
3167 builder.token(SyntaxKind::TEXT.into(), after_indent);
3168 }
3169 } else if line_idx < end_line_idx {
3170 // Pure attribute line.
3171 let bytes = line_no_nl.as_bytes();
3172 let indent_end = bytes
3173 .iter()
3174 .position(|&b| !matches!(b, b' ' | b'\t'))
3175 .unwrap_or(bytes.len());
3176 if indent_end > 0 {
3177 builder.token(SyntaxKind::WHITESPACE.into(), &line_no_nl[..indent_end]);
3178 }
3179 let attrs_text = &line_no_nl[indent_end..];
3180 if !attrs_text.is_empty() {
3181 emit_html_attrs_node(builder, attrs_text);
3182 }
3183 } else {
3184 // Last line: indent + attrs + ">" + trailing.
3185 let bytes = line_no_nl.as_bytes();
3186 let indent_end = bytes
3187 .iter()
3188 .position(|&b| !matches!(b, b' ' | b'\t'))
3189 .unwrap_or(bytes.len());
3190 if indent_end > 0 {
3191 builder.token(SyntaxKind::WHITESPACE.into(), &line_no_nl[..indent_end]);
3192 }
3193 // Find the unquoted `>` byte position in this line.
3194 let mut quote: Option<u8> = None;
3195 let mut gt_pos: Option<usize> = None;
3196 for (j, &b) in line_no_nl.as_bytes()[indent_end..].iter().enumerate() {
3197 let actual_j = indent_end + j;
3198 match (quote, b) {
3199 (None, b'"') | (None, b'\'') => quote = Some(b),
3200 (Some(q), x) if x == q => quote = None,
3201 (None, b'>') => {
3202 gt_pos = Some(actual_j);
3203 break;
3204 }
3205 _ => {}
3206 }
3207 }
3208 let Some(gt) = gt_pos else {
3209 // Defensive — caller said `>` is on this line.
3210 builder.token(SyntaxKind::TEXT.into(), &line_no_nl[indent_end..]);
3211 if !newline_str.is_empty() {
3212 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3213 }
3214 continue;
3215 };
3216 // Attribute region: between indent_end and gt, with possibly
3217 // trailing whitespace before `>`.
3218 let attrs_region = &line_no_nl[indent_end..gt];
3219 let region_bytes = attrs_region.as_bytes();
3220 // Strip trailing whitespace from attrs region; emit as
3221 // separate WHITESPACE so HTML_ATTRS only contains attribute
3222 // bytes.
3223 let mut attr_end = region_bytes.len();
3224 while attr_end > 0 && matches!(region_bytes[attr_end - 1], b' ' | b'\t') {
3225 attr_end -= 1;
3226 }
3227 let attrs_text = &attrs_region[..attr_end];
3228 let trailing_ws = &attrs_region[attr_end..];
3229 if !attrs_text.is_empty() {
3230 emit_html_attrs_node(builder, attrs_text);
3231 }
3232 if !trailing_ws.is_empty() {
3233 builder.token(SyntaxKind::WHITESPACE.into(), trailing_ws);
3234 }
3235 builder.token(SyntaxKind::TEXT.into(), ">");
3236 let after_gt = &line_no_nl[gt + 1..];
3237 if lift_trailing && !after_gt.is_empty() {
3238 // Lift trailing bytes (and the trailing newline) into
3239 // `pre_content` so the open `HTML_BLOCK_TAG` ends cleanly
3240 // with `TEXT(">")`. The recursive parse at the close-marker
3241 // site treats `pre_content` as the leading bytes of the
3242 // structural body — same shape produced by `emit_open_tag_tokens`
3243 // for single-line opens.
3244 pre_content.push_str(after_gt);
3245 pre_content.push_str(newline_str);
3246 continue;
3247 }
3248 if !after_gt.is_empty() {
3249 builder.token(SyntaxKind::TEXT.into(), after_gt);
3250 }
3251 }
3252
3253 if !newline_str.is_empty() {
3254 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3255 }
3256 }
3257}
3258
3259/// Emit a multi-line HTML open tag spanning `lines[start_pos..=end_line_idx]`
3260/// for non-`<div>` tags (void tags `<embed>`/`<area>`/`<source>`/`<track>`).
3261/// Each line is emitted as plain TEXT + NEWLINE; no `HTML_ATTRS` structural
3262/// node is added. Pandoc's projector reads attributes only for `<div>` /
3263/// `<span>` lifts, so non-div multi-line opens just need byte preservation.
3264fn emit_multiline_open_tag_simple(
3265 builder: &mut GreenNodeBuilder<'static>,
3266 lines: &[&str],
3267 start_pos: usize,
3268 end_line_idx: usize,
3269 bq_depth: usize,
3270) {
3271 for (line_idx, raw) in lines
3272 .iter()
3273 .enumerate()
3274 .take(end_line_idx + 1)
3275 .skip(start_pos)
3276 {
3277 let stripped = if bq_depth > 0 {
3278 strip_n_blockquote_markers(raw, bq_depth)
3279 } else {
3280 raw
3281 };
3282 let bq_prefix_len = raw.len() - stripped.len();
3283 // Line 0's bq prefix is owned by the outer BLOCK_QUOTE node;
3284 // re-emit prefixes only for subsequent lines.
3285 if bq_prefix_len > 0 && line_idx != start_pos {
3286 emit_bq_prefix_tokens(builder, &raw[..bq_prefix_len]);
3287 }
3288 let (line_no_nl, newline_str) = strip_newline(stripped);
3289 if !line_no_nl.is_empty() {
3290 builder.token(SyntaxKind::TEXT.into(), line_no_nl);
3291 }
3292 if !newline_str.is_empty() {
3293 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3294 }
3295 }
3296}
3297
3298/// Emit the trailing portion of `<div`'s line 0 — i.e. anything after the
3299/// `<div` literal up to end-of-line. Called only from
3300/// `emit_multiline_open_tag_with_attrs`. The `>` is on a later line, so this is
3301/// pure attribute (and possibly inter-attribute whitespace).
3302fn emit_attr_region(builder: &mut GreenNodeBuilder<'static>, region: &str) {
3303 if region.is_empty() {
3304 return;
3305 }
3306 let bytes = region.as_bytes();
3307 // Split a leading run of whitespace into a WHITESPACE token so the
3308 // HTML_ATTRS node holds only attribute bytes.
3309 let ws_end = bytes
3310 .iter()
3311 .position(|&b| !matches!(b, b' ' | b'\t'))
3312 .unwrap_or(bytes.len());
3313 if ws_end > 0 {
3314 builder.token(SyntaxKind::WHITESPACE.into(), ®ion[..ws_end]);
3315 }
3316 let attrs_text = ®ion[ws_end..];
3317 if !attrs_text.is_empty() {
3318 emit_html_attrs_node(builder, attrs_text);
3319 }
3320}
3321
3322/// Emit one continuation line of an HTML block, preserving any blockquote
3323/// markers as structural tokens (so the CST stays byte-equal to the source
3324/// and downstream consumers can strip them per-context).
3325fn emit_html_block_line(builder: &mut GreenNodeBuilder<'static>, line: &str, bq_depth: usize) {
3326 let inner = if bq_depth > 0 {
3327 let stripped = strip_n_blockquote_markers(line, bq_depth);
3328 let prefix_len = line.len() - stripped.len();
3329 if prefix_len > 0 {
3330 for ch in line[..prefix_len].chars() {
3331 if ch == '>' {
3332 builder.token(SyntaxKind::BLOCK_QUOTE_MARKER.into(), ">");
3333 } else {
3334 let mut buf = [0u8; 4];
3335 builder.token(SyntaxKind::WHITESPACE.into(), ch.encode_utf8(&mut buf));
3336 }
3337 }
3338 }
3339 stripped
3340 } else {
3341 line
3342 };
3343
3344 let (line_without_newline, newline_str) = strip_newline(inner);
3345 if !line_without_newline.is_empty() {
3346 builder.token(SyntaxKind::TEXT.into(), line_without_newline);
3347 }
3348 if !newline_str.is_empty() {
3349 builder.token(SyntaxKind::NEWLINE.into(), newline_str);
3350 }
3351}
3352
3353#[cfg(test)]
3354mod tests {
3355 use super::*;
3356
3357 #[test]
3358 fn test_try_parse_html_comment() {
3359 assert_eq!(
3360 try_parse_html_block_start("<!-- comment -->", false),
3361 Some(HtmlBlockType::Comment)
3362 );
3363 assert_eq!(
3364 try_parse_html_block_start(" <!-- comment -->", false),
3365 Some(HtmlBlockType::Comment)
3366 );
3367 }
3368
3369 #[test]
3370 fn test_try_parse_div_tag() {
3371 assert_eq!(
3372 try_parse_html_block_start("<div>", false),
3373 Some(HtmlBlockType::BlockTag {
3374 tag_name: "div".to_string(),
3375 is_verbatim: false,
3376 closed_by_blank_line: false,
3377 depth_aware: true,
3378 closes_at_open_tag: false,
3379 is_closing: false,
3380 })
3381 );
3382 assert_eq!(
3383 try_parse_html_block_start("<div class=\"test\">", false),
3384 Some(HtmlBlockType::BlockTag {
3385 tag_name: "div".to_string(),
3386 is_verbatim: false,
3387 closed_by_blank_line: false,
3388 depth_aware: true,
3389 closes_at_open_tag: false,
3390 is_closing: false,
3391 })
3392 );
3393 }
3394
3395 #[test]
3396 fn test_try_parse_script_tag() {
3397 assert_eq!(
3398 try_parse_html_block_start("<script>", false),
3399 Some(HtmlBlockType::BlockTag {
3400 tag_name: "script".to_string(),
3401 is_verbatim: true,
3402 closed_by_blank_line: false,
3403 depth_aware: true,
3404 closes_at_open_tag: false,
3405 is_closing: false,
3406 })
3407 );
3408 }
3409
3410 #[test]
3411 fn test_try_parse_processing_instruction() {
3412 assert_eq!(
3413 try_parse_html_block_start("<?xml version=\"1.0\"?>", false),
3414 Some(HtmlBlockType::ProcessingInstruction)
3415 );
3416 }
3417
3418 #[test]
3419 fn test_try_parse_declaration() {
3420 // CommonMark dialect recognizes declarations as type-4 HTML blocks.
3421 assert_eq!(
3422 try_parse_html_block_start("<!DOCTYPE html>", true),
3423 Some(HtmlBlockType::Declaration)
3424 );
3425 // CommonMark §4.6 type 4 accepts any ASCII letter after `<!`, not
3426 // just uppercase. Lowercase doctype must match too.
3427 assert_eq!(
3428 try_parse_html_block_start("<!doctype html>", true),
3429 Some(HtmlBlockType::Declaration)
3430 );
3431 // Pandoc dialect does not — bare declarations fall through to
3432 // paragraph parsing.
3433 assert_eq!(try_parse_html_block_start("<!DOCTYPE html>", false), None);
3434 assert_eq!(try_parse_html_block_start("<!doctype html>", false), None);
3435 }
3436
3437 #[test]
3438 fn test_dialect_specific_block_tag_membership() {
3439 // Pandoc-markdown's `blockHtmlTags` is a strict subset of
3440 // CommonMark §4.6 type-6 plus a few additions. These tags
3441 // diverge between dialects:
3442 // CM-only block tags (Pandoc treats as inline raw HTML):
3443 // dialog, legend, menuitem, optgroup, option, frame,
3444 // base, basefont, link, param
3445 // Pandoc-only block tags (CM doesn't recognize):
3446 // canvas, hgroup, isindex, meta, output
3447 for cm_only in [
3448 "<dialog>",
3449 "<legend>",
3450 "<menuitem>",
3451 "<optgroup>",
3452 "<option>",
3453 "<frame>",
3454 "<base>",
3455 "<basefont>",
3456 "<link>",
3457 "<param>",
3458 ] {
3459 assert!(
3460 matches!(
3461 try_parse_html_block_start(cm_only, true),
3462 Some(HtmlBlockType::BlockTag { .. })
3463 ),
3464 "{cm_only} should be a block-tag start under CommonMark",
3465 );
3466 assert_eq!(
3467 try_parse_html_block_start(cm_only, false),
3468 None,
3469 "{cm_only} should NOT be a block-tag start under Pandoc",
3470 );
3471 }
3472 for pandoc_only in ["<canvas>", "<hgroup>", "<isindex>", "<meta>", "<output>"] {
3473 // Under CM these are not type-6 BlockTags; they may still match
3474 // type-7 (complete tag on a line) which has different semantics.
3475 assert!(
3476 !matches!(
3477 try_parse_html_block_start(pandoc_only, true),
3478 Some(HtmlBlockType::BlockTag { .. })
3479 ),
3480 "{pandoc_only} should NOT be a type-6 block-tag start under CommonMark",
3481 );
3482 assert!(
3483 matches!(
3484 try_parse_html_block_start(pandoc_only, false),
3485 Some(HtmlBlockType::BlockTag { .. })
3486 ),
3487 "{pandoc_only} should be a block-tag start under Pandoc",
3488 );
3489 }
3490 }
3491
3492 #[test]
3493 fn test_pandoc_inline_block_tag_membership() {
3494 // Pandoc's `eitherBlockOrInline` tags start an HTML block at
3495 // fresh-block positions under Pandoc dialect. We list the
3496 // non-void, non-script subset (verbatim `script` is handled
3497 // via the verbatim path; void elements are deferred — see
3498 // PANDOC_INLINE_BLOCK_TAGS docs).
3499 for tag in [
3500 "<button>",
3501 "<iframe>",
3502 "<video>",
3503 "<audio>",
3504 "<noscript>",
3505 "<object>",
3506 "<map>",
3507 "<progress>",
3508 "<del>",
3509 "<ins>",
3510 "<svg>",
3511 "<applet>",
3512 ] {
3513 assert!(
3514 matches!(
3515 try_parse_html_block_start(tag, false),
3516 Some(HtmlBlockType::BlockTag {
3517 depth_aware: true,
3518 ..
3519 })
3520 ),
3521 "{tag} should be a depth-aware block-tag start under Pandoc",
3522 );
3523 }
3524 // Closing forms of inline-block tags also start a block under
3525 // Pandoc — pandoc-native pins `</button>` standalone as a
3526 // single-line `RawBlock`. These use `closes_at_open_tag: true`
3527 // (no balanced match — the close emits as a one-line block on
3528 // its own).
3529 for closing in ["</button>", "</iframe>", "</video>", "</audio>"] {
3530 assert!(
3531 matches!(
3532 try_parse_html_block_start(closing, false),
3533 Some(HtmlBlockType::BlockTag {
3534 depth_aware: false,
3535 closes_at_open_tag: true,
3536 ..
3537 })
3538 ),
3539 "{closing} (closing form) should be a single-line block-tag start under Pandoc",
3540 );
3541 }
3542 }
3543
3544 #[test]
3545 fn test_pandoc_void_block_tag_membership() {
3546 // Pandoc's void `eitherBlockOrInline` tags start an HTML block
3547 // at fresh-block positions under Pandoc dialect, with
3548 // `closes_at_open_tag: true` — the block always ends on the
3549 // open-tag line (no closing tag to match).
3550 for tag in [
3551 "<area>",
3552 "<embed>",
3553 "<source>",
3554 "<track>",
3555 "<embed src=\"foo.swf\">",
3556 "<source src=\"foo.mp4\" type=\"video/mp4\">",
3557 ] {
3558 assert!(
3559 matches!(
3560 try_parse_html_block_start(tag, false),
3561 Some(HtmlBlockType::BlockTag {
3562 depth_aware: false,
3563 closes_at_open_tag: true,
3564 ..
3565 })
3566 ),
3567 "{tag} should be a void block-tag start under Pandoc",
3568 );
3569 }
3570 // Closing forms of void tags also start a single-line block
3571 // under Pandoc. Void elements have no closing tag in HTML, but
3572 // `</embed>` etc. can appear in the wild — pandoc-native still
3573 // emits them as `RawBlock`s at fresh-block positions; mirror
3574 // that with the same `closes_at_open_tag: true` shape.
3575 for closing in ["</area>", "</embed>", "</source>", "</track>"] {
3576 assert!(
3577 matches!(
3578 try_parse_html_block_start(closing, false),
3579 Some(HtmlBlockType::BlockTag {
3580 depth_aware: false,
3581 closes_at_open_tag: true,
3582 ..
3583 })
3584 ),
3585 "{closing} (closing form) should be a single-line void block-tag start under Pandoc",
3586 );
3587 }
3588 // Under CommonMark dialect, the void-tag block-start path is
3589 // skipped. `<source>` and `<track>` are in the CM type-6
3590 // BLOCK_TAGS set so they DO start a block, but with CM type-6
3591 // semantics (`closed_by_blank_line: true`,
3592 // `closes_at_open_tag: false`), not the Pandoc void-tag path.
3593 // `<embed>` and `<area>` aren't in the CM type-6 list — they
3594 // fall through to type 7 (complete tag on a line by itself).
3595 assert_eq!(
3596 try_parse_html_block_start("<embed>", true),
3597 Some(HtmlBlockType::Type7)
3598 );
3599 assert_eq!(
3600 try_parse_html_block_start("<area>", true),
3601 Some(HtmlBlockType::Type7)
3602 );
3603 assert!(matches!(
3604 try_parse_html_block_start("<source src=\"x\">", true),
3605 Some(HtmlBlockType::BlockTag {
3606 closed_by_blank_line: true,
3607 closes_at_open_tag: false,
3608 ..
3609 })
3610 ));
3611 assert!(matches!(
3612 try_parse_html_block_start("<track src=\"x\">", true),
3613 Some(HtmlBlockType::BlockTag {
3614 closed_by_blank_line: true,
3615 closes_at_open_tag: false,
3616 ..
3617 })
3618 ));
3619 }
3620
3621 #[test]
3622 fn test_find_multiline_open_end() {
3623 // Single-line opens return None (caller takes the regular path).
3624 assert_eq!(
3625 find_multiline_open_end(
3626 &["<div id=\"x\">"],
3627 0,
3628 "<div id=\"x\">",
3629 "div",
3630 &ContainerPrefix::default()
3631 ),
3632 None
3633 );
3634 assert_eq!(
3635 find_multiline_open_end(
3636 &["<embed src=\"x\">"],
3637 0,
3638 "<embed src=\"x\">",
3639 "embed",
3640 &ContainerPrefix::default()
3641 ),
3642 None
3643 );
3644 // Multi-line opens return the line index of the closing `>`.
3645 assert_eq!(
3646 find_multiline_open_end(
3647 &["<embed", " src=\"x\">"],
3648 0,
3649 "<embed",
3650 "embed",
3651 &ContainerPrefix::default()
3652 ),
3653 Some(1)
3654 );
3655 assert_eq!(
3656 find_multiline_open_end(
3657 &["<embed", " src=\"x\"", " type=\"video\">"],
3658 0,
3659 "<embed",
3660 "embed",
3661 &ContainerPrefix::default()
3662 ),
3663 Some(2)
3664 );
3665 // Tag-name mismatch returns None (case-insensitive on the tag name).
3666 assert_eq!(
3667 find_multiline_open_end(
3668 &["<embed", " src=\"x\">"],
3669 0,
3670 "<embed",
3671 "div",
3672 &ContainerPrefix::default()
3673 ),
3674 None
3675 );
3676 assert_eq!(
3677 find_multiline_open_end(
3678 &["<EMBED", " src=\"x\">"],
3679 0,
3680 "<EMBED",
3681 "embed",
3682 &ContainerPrefix::default()
3683 ),
3684 Some(1)
3685 );
3686 // Quoted `>` does not terminate the open tag; quote state threads
3687 // across line boundaries.
3688 assert_eq!(
3689 find_multiline_open_end(
3690 &["<embed title=\"a>b", " c\">"],
3691 0,
3692 "<embed title=\"a>b",
3693 "embed",
3694 &ContainerPrefix::default()
3695 ),
3696 Some(1)
3697 );
3698 // No `>` anywhere returns None.
3699 assert_eq!(
3700 find_multiline_open_end(
3701 &["<embed", " src=\"x\""],
3702 0,
3703 "<embed",
3704 "embed",
3705 &ContainerPrefix::default()
3706 ),
3707 None
3708 );
3709 // Subsequent lines inside a blockquote: bq markers stripped before
3710 // scanning so `> ` prefixes don't count.
3711 assert_eq!(
3712 find_multiline_open_end(
3713 &["<div", "> id=\"x\">"],
3714 0,
3715 "<div",
3716 "div",
3717 &ContainerPrefix::bq_only(1)
3718 ),
3719 Some(1)
3720 );
3721 // Nested bq: strips two `> ` per line.
3722 assert_eq!(
3723 find_multiline_open_end(
3724 &["<section", "> > id=\"x\">"],
3725 0,
3726 "<section",
3727 "section",
3728 &ContainerPrefix::bq_only(2)
3729 ),
3730 Some(1)
3731 );
3732 }
3733
3734 #[test]
3735 fn test_pandoc_html_open_tag_closes() {
3736 // Single-line complete: scanner finds `>` on the first line.
3737 assert!(pandoc_html_open_tag_closes(
3738 &["<div>"],
3739 0,
3740 &ContainerPrefix::default()
3741 ));
3742 assert!(pandoc_html_open_tag_closes(
3743 &["<embed src=\"x\">"],
3744 0,
3745 &ContainerPrefix::default()
3746 ));
3747 // Multi-line complete: scanner finds `>` on a later line.
3748 assert!(pandoc_html_open_tag_closes(
3749 &["<div", " id=\"x\">", "body", "</div>"],
3750 0,
3751 &ContainerPrefix::default()
3752 ));
3753 assert!(pandoc_html_open_tag_closes(
3754 &["<embed", " src=\"x.png\" alt=\"y\">"],
3755 0,
3756 &ContainerPrefix::default()
3757 ));
3758 // Quoted `>` does not close: scanner threads quote state.
3759 assert!(!pandoc_html_open_tag_closes(
3760 &["<div title=\"a>b", " c\""],
3761 0,
3762 &ContainerPrefix::default()
3763 ));
3764 assert!(pandoc_html_open_tag_closes(
3765 &["<div title=\"a>b", " c\">"],
3766 0,
3767 &ContainerPrefix::default()
3768 ));
3769 // Incomplete: no `>` anywhere — pandoc treats as paragraph text.
3770 assert!(!pandoc_html_open_tag_closes(
3771 &["<embed"],
3772 0,
3773 &ContainerPrefix::default()
3774 ));
3775 assert!(!pandoc_html_open_tag_closes(
3776 &["<div", "foo", "bar"],
3777 0,
3778 &ContainerPrefix::default()
3779 ));
3780 // Pandoc tolerates blank lines mid-open-tag (its `htmlTag` reads
3781 // across them); the scan continues until EOF or `>`.
3782 assert!(pandoc_html_open_tag_closes(
3783 &["<div", "", "id=\"x\">"],
3784 0,
3785 &ContainerPrefix::default()
3786 ));
3787 }
3788
3789 #[test]
3790 fn test_try_parse_cdata() {
3791 // CommonMark dialect recognizes CDATA as type-5 HTML blocks.
3792 assert_eq!(
3793 try_parse_html_block_start("<![CDATA[content]]>", true),
3794 Some(HtmlBlockType::CData)
3795 );
3796 // Pandoc dialect does not.
3797 assert_eq!(
3798 try_parse_html_block_start("<![CDATA[content]]>", false),
3799 None
3800 );
3801 }
3802
3803 #[test]
3804 fn test_extract_block_tag_name_open_only() {
3805 assert_eq!(
3806 extract_block_tag_name("<div>", false),
3807 Some("div".to_string())
3808 );
3809 assert_eq!(
3810 extract_block_tag_name("<div class=\"test\">", false),
3811 Some("div".to_string())
3812 );
3813 assert_eq!(
3814 extract_block_tag_name("<div/>", false),
3815 Some("div".to_string())
3816 );
3817 assert_eq!(extract_block_tag_name("</div>", false), None);
3818 assert_eq!(extract_block_tag_name("<>", false), None);
3819 assert_eq!(extract_block_tag_name("< div>", false), None);
3820 }
3821
3822 #[test]
3823 fn test_extract_block_tag_name_with_closing() {
3824 // CommonMark §4.6 type-6 starts also accept closing tags.
3825 assert_eq!(
3826 extract_block_tag_name("</div>", true),
3827 Some("div".to_string())
3828 );
3829 assert_eq!(
3830 extract_block_tag_name("</div >", true),
3831 Some("div".to_string())
3832 );
3833 }
3834
3835 #[test]
3836 fn test_commonmark_type6_closing_tag_start() {
3837 assert_eq!(
3838 try_parse_html_block_start("</div>", true),
3839 Some(HtmlBlockType::BlockTag {
3840 tag_name: "div".to_string(),
3841 is_verbatim: false,
3842 closed_by_blank_line: true,
3843 depth_aware: false,
3844 closes_at_open_tag: false,
3845 is_closing: true,
3846 })
3847 );
3848 }
3849
3850 #[test]
3851 fn test_commonmark_type7_open_tag() {
3852 // `<a>` (not a type-6 tag) on a line by itself is type 7 under
3853 // CommonMark; rejected under non-CommonMark.
3854 assert_eq!(
3855 try_parse_html_block_start("<a href=\"foo\">", true),
3856 Some(HtmlBlockType::Type7)
3857 );
3858 assert_eq!(try_parse_html_block_start("<a href=\"foo\">", false), None);
3859 }
3860
3861 #[test]
3862 fn test_commonmark_type7_close_tag() {
3863 assert_eq!(
3864 try_parse_html_block_start("</ins>", true),
3865 Some(HtmlBlockType::Type7)
3866 );
3867 }
3868
3869 #[test]
3870 fn test_commonmark_type7_rejects_with_trailing_text() {
3871 // A complete tag must be followed only by whitespace.
3872 assert_eq!(try_parse_html_block_start("<a> hi", true), None);
3873 }
3874
3875 #[test]
3876 fn test_is_closing_marker_comment() {
3877 let block_type = HtmlBlockType::Comment;
3878 assert!(is_closing_marker("-->", &block_type));
3879 assert!(is_closing_marker("end -->", &block_type));
3880 assert!(!is_closing_marker("<!--", &block_type));
3881 }
3882
3883 #[test]
3884 fn test_is_closing_marker_tag() {
3885 let block_type = HtmlBlockType::BlockTag {
3886 tag_name: "div".to_string(),
3887 is_verbatim: false,
3888 closed_by_blank_line: false,
3889 depth_aware: false,
3890 closes_at_open_tag: false,
3891 is_closing: false,
3892 };
3893 assert!(is_closing_marker("</div>", &block_type));
3894 assert!(is_closing_marker("</DIV>", &block_type)); // Case insensitive
3895 assert!(is_closing_marker("content</div>", &block_type));
3896 assert!(!is_closing_marker("<div>", &block_type));
3897 }
3898
3899 #[test]
3900 fn test_parse_html_comment_block() {
3901 let input = "<!-- comment -->\n";
3902 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
3903 let mut builder = GreenNodeBuilder::new();
3904
3905 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
3906 let opts = ParserOptions::default();
3907 let new_pos = parse_html_block_with_wrapper(
3908 &mut builder,
3909 &lines,
3910 0,
3911 block_type,
3912 &ContainerPrefix::default(),
3913 SyntaxKind::HTML_BLOCK,
3914 &opts,
3915 );
3916
3917 assert_eq!(new_pos, 1);
3918 }
3919
3920 #[test]
3921 fn test_parse_div_block() {
3922 let input = "<div>\ncontent\n</div>\n";
3923 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
3924 let mut builder = GreenNodeBuilder::new();
3925
3926 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
3927 let opts = ParserOptions::default();
3928 let new_pos = parse_html_block_with_wrapper(
3929 &mut builder,
3930 &lines,
3931 0,
3932 block_type,
3933 &ContainerPrefix::default(),
3934 SyntaxKind::HTML_BLOCK,
3935 &opts,
3936 );
3937
3938 assert_eq!(new_pos, 3);
3939 }
3940
3941 #[test]
3942 fn test_parse_html_block_no_closing() {
3943 let input = "<div>\ncontent\n";
3944 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
3945 let mut builder = GreenNodeBuilder::new();
3946
3947 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
3948 let opts = ParserOptions::default();
3949 let new_pos = parse_html_block_with_wrapper(
3950 &mut builder,
3951 &lines,
3952 0,
3953 block_type,
3954 &ContainerPrefix::default(),
3955 SyntaxKind::HTML_BLOCK,
3956 &opts,
3957 );
3958
3959 // Should consume all lines even without closing tag
3960 assert_eq!(new_pos, 2);
3961 }
3962
3963 #[test]
3964 fn test_parse_div_block_nested_pandoc() {
3965 // Pandoc dialect: a nested `<div>...<div>...</div>...</div>` must
3966 // close on the OUTER `</div>`, not the first `</div>` seen. The
3967 // CommonMark-style "first close" scanner is wrong here; Pandoc's
3968 // div parser is depth-aware (mirrors `htmlInBalanced`).
3969 let input =
3970 "<div id=\"outer\">\n\n<div id=\"inner\">\n\ndeep content\n\n</div>\n\n</div>\n";
3971 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
3972 let mut builder = GreenNodeBuilder::new();
3973
3974 // is_commonmark = false → Pandoc dialect.
3975 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
3976 let opts = ParserOptions::default();
3977 let new_pos = parse_html_block_with_wrapper(
3978 &mut builder,
3979 &lines,
3980 0,
3981 block_type,
3982 &ContainerPrefix::default(),
3983 SyntaxKind::HTML_BLOCK_DIV,
3984 &opts,
3985 );
3986
3987 // 9 lines: outer-open, blank, inner-open, blank, content, blank,
3988 // inner-close, blank, outer-close. All consumed.
3989 assert_eq!(new_pos, 9);
3990 }
3991
3992 #[test]
3993 fn test_parse_div_block_same_line_pandoc() {
3994 // <div>foo</div> on a single line: opens=1, closes=1, depth=0 →
3995 // close on first line. Depth-aware tracking must not regress this.
3996 let input = "<div>foo</div>\n";
3997 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
3998 let mut builder = GreenNodeBuilder::new();
3999
4000 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
4001 let opts = ParserOptions::default();
4002 let new_pos = parse_html_block_with_wrapper(
4003 &mut builder,
4004 &lines,
4005 0,
4006 block_type,
4007 &ContainerPrefix::default(),
4008 SyntaxKind::HTML_BLOCK_DIV,
4009 &opts,
4010 );
4011 assert_eq!(new_pos, 1);
4012 }
4013
4014 #[test]
4015 fn test_commonmark_verbatim_first_close() {
4016 // CommonMark verbatim tag (`<script>`): per CommonMark §4.6 type-1,
4017 // ends at the first matching close — not depth-aware. Stash a
4018 // bogus inner `<script>` inside a JS string; the outer block
4019 // still closes at the first `</script>`.
4020 let input = "<script>\nlet x = '<script>';\n</script>\n";
4021 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
4022 let mut builder = GreenNodeBuilder::new();
4023
4024 // is_commonmark = true.
4025 let block_type = try_parse_html_block_start(lines[0], true).unwrap();
4026 let opts = ParserOptions::default();
4027 let new_pos = parse_html_block_with_wrapper(
4028 &mut builder,
4029 &lines,
4030 0,
4031 block_type,
4032 &ContainerPrefix::default(),
4033 SyntaxKind::HTML_BLOCK,
4034 &opts,
4035 );
4036 // Three lines, closed at first `</script>` (line 2). new_pos = 3.
4037 assert_eq!(new_pos, 3);
4038 }
4039
4040 #[test]
4041 fn test_parse_div_block_multiline_open_close_separate_line_pandoc() {
4042 // Multi-line open tag with the closing `>` on its own line:
4043 //
4044 // <div
4045 // id="x"
4046 // class="y"
4047 // >
4048 //
4049 // foo
4050 //
4051 // </div>
4052 //
4053 // Open tag spans lines 0..=3. Content starts at line 4.
4054 let input = "<div\n id=\"x\"\n class=\"y\"\n>\n\nfoo\n\n</div>\n";
4055 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
4056 let mut builder = GreenNodeBuilder::new();
4057
4058 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
4059 let opts = ParserOptions::default();
4060 let new_pos = parse_html_block_with_wrapper(
4061 &mut builder,
4062 &lines,
4063 0,
4064 block_type,
4065 &ContainerPrefix::default(),
4066 SyntaxKind::HTML_BLOCK_DIV,
4067 &opts,
4068 );
4069
4070 // 8 lines: open-line 0, open-line 1 (` id="x"`), open-line 2
4071 // (` class="y"`), open-line 3 (`>`), blank, foo, blank, </div>.
4072 assert_eq!(new_pos, 8);
4073
4074 // CST must contain a structural HTML_ATTRS region holding the
4075 // attribute bytes (so the salsa anchor walk picks up `id="x"`).
4076 let green = builder.finish();
4077 let root = crate::syntax::SyntaxNode::new_root(green);
4078 let attrs_count = root
4079 .descendants()
4080 .filter(|n| n.kind() == SyntaxKind::HTML_ATTRS)
4081 .count();
4082 assert!(attrs_count >= 1, "expected at least one HTML_ATTRS node");
4083
4084 // Byte-identical losslessness check.
4085 let collected: String = root
4086 .descendants_with_tokens()
4087 .filter_map(|n| n.into_token())
4088 .map(|t| t.text().to_string())
4089 .collect();
4090 assert_eq!(collected, input);
4091 }
4092
4093 #[test]
4094 fn test_parse_div_block_multiline_open_close_inline_pandoc() {
4095 // Multi-line open tag with the closing `>` on the last attribute
4096 // line (case 0262 already covers this pattern; pin behavior to
4097 // also ensure HTML_ATTRS structural exposure).
4098 let input = "<div\n id=\"x\"\n class=\"y\">\nfoo\n</div>\n";
4099 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
4100 let mut builder = GreenNodeBuilder::new();
4101
4102 let block_type = try_parse_html_block_start(lines[0], false).unwrap();
4103 let opts = ParserOptions::default();
4104 let new_pos = parse_html_block_with_wrapper(
4105 &mut builder,
4106 &lines,
4107 0,
4108 block_type,
4109 &ContainerPrefix::default(),
4110 SyntaxKind::HTML_BLOCK_DIV,
4111 &opts,
4112 );
4113
4114 // 5 lines: open-line 0, open-line 1, open-line 2 (with `>`), foo,
4115 // </div>.
4116 assert_eq!(new_pos, 5);
4117
4118 let green = builder.finish();
4119 let root = crate::syntax::SyntaxNode::new_root(green);
4120 let attrs_count = root
4121 .descendants()
4122 .filter(|n| n.kind() == SyntaxKind::HTML_ATTRS)
4123 .count();
4124 assert!(attrs_count >= 1, "expected at least one HTML_ATTRS node");
4125
4126 let collected: String = root
4127 .descendants_with_tokens()
4128 .filter_map(|n| n.into_token())
4129 .map(|t| t.text().to_string())
4130 .collect();
4131 assert_eq!(collected, input);
4132 }
4133
4134 #[test]
4135 fn test_commonmark_type6_blank_line_terminates() {
4136 let input = "<div>\nfoo\n\nbar\n";
4137 let lines: Vec<&str> = crate::parser::utils::helpers::split_lines_inclusive(input);
4138 let mut builder = GreenNodeBuilder::new();
4139
4140 let block_type = try_parse_html_block_start(lines[0], true).unwrap();
4141 let opts = ParserOptions::default();
4142 let new_pos = parse_html_block_with_wrapper(
4143 &mut builder,
4144 &lines,
4145 0,
4146 block_type,
4147 &ContainerPrefix::default(),
4148 SyntaxKind::HTML_BLOCK,
4149 &opts,
4150 );
4151
4152 // Block contains <div>\nfoo\n; stops at blank line (line 2).
4153 assert_eq!(new_pos, 2);
4154 }
4155}