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(), ®ion[..ws_end]);
3702 }
3703 let attrs_text = ®ion[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}