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