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