Skip to main content

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(), &region[..ws_end]);
3315    }
3316    let attrs_text = &region[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}