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