Skip to main content

rumdl_lib/utils/
pandoc.rs

1//! Pandoc Markdown syntax detection.
2//!
3//! This module provides detection for Pandoc Markdown constructs that affect
4//! rumdl rule output: fenced divs (`:::`), attribute lists (`{#id .class}`),
5//! citations (`[@key]`), bracketed spans (`[text]{.class}`), and other
6//! Pandoc-specific syntax.
7//!
8//! Pandoc is the foundation; the Quarto flavor extends it with Quarto-only
9//! syntax (executable code blocks, shortcodes, cell options) elsewhere in
10//! the codebase. Anything that's pure Pandoc lives here.
11//!
12//! Common patterns this module handles:
13//! - `::: {.callout-note}` — fenced div with class
14//! - `::: {#myid .class}` — generic div with id and class
15//! - `:::` — closing marker
16//! - `{#id .class key="value"}` — Pandoc attribute lists
17//! - `@key`, `[@key]`, `[-@key]`, `[@a; @b]` — citations
18
19use regex::Regex;
20use std::sync::LazyLock;
21
22use crate::utils::skip_context::ByteRange;
23
24/// Pattern to match div opening markers
25/// Matches: ::: {.class}, ::: {#id .class}, ::: classname, etc.
26/// Does NOT match a closing ::: on its own
27static DIV_OPEN_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*(?:\{[^}]+\}|\S+)").unwrap());
28
29/// Pattern to match div closing markers
30/// Matches: ::: (with optional whitespace before and after)
31static DIV_CLOSE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*):::\s*$").unwrap());
32
33/// Pattern to match callout blocks specifically
34/// Callout types: note, warning, tip, important, caution
35static CALLOUT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
36    Regex::new(r"^(\s*):::\s*\{[^}]*\.callout-(?:note|warning|tip|important|caution)[^}]*\}").unwrap()
37});
38
39/// Pattern to match Pandoc-style attributes on any element
40/// Matches: {#id}, {.class}, {#id .class key="value"}, etc.
41/// Note: We match the entire attribute block including contents
42static PANDOC_ATTR_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{[^}]+\}").unwrap());
43
44/// Check if a line is a div opening marker
45pub fn is_div_open(line: &str) -> bool {
46    DIV_OPEN_PATTERN.is_match(line)
47}
48
49/// Check if a line is a div closing marker (just `:::`)
50pub fn is_div_close(line: &str) -> bool {
51    DIV_CLOSE_PATTERN.is_match(line)
52}
53
54/// Check if a line is a callout block opening
55pub fn is_callout_open(line: &str) -> bool {
56    CALLOUT_PATTERN.is_match(line)
57}
58
59/// Check if a line contains Pandoc-style attributes
60pub fn has_pandoc_attributes(line: &str) -> bool {
61    PANDOC_ATTR_PATTERN.is_match(line)
62}
63
64/// Return true if `lang` is a Pandoc raw-format declaration: `{=html}`,
65/// `{=latex}`, etc. The format name must be non-empty and consist only of
66/// ASCII alphanumeric characters, underscores, or hyphens.
67pub fn is_pandoc_raw_block_lang(lang: &str) -> bool {
68    let l = lang.trim();
69    l.starts_with("{=") && l.ends_with('}') && {
70        let inner = &l[2..l.len() - 1];
71        !inner.trim().is_empty() && inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
72    }
73}
74
75/// Return the language a Pandoc code-attribute list declares, if any: the first
76/// `.class` inside a brace-delimited attribute block, without its leading dot.
77/// `{.python}` yields `python`, `{#snippet .haskell startFrom="10"}` yields
78/// `haskell`.
79///
80/// Pandoc treats the first `.class` inside the attribute block as the language
81/// for syntax highlighting. Tokens are space-separated; a `.class` token is one
82/// that starts with `.` followed by a non-empty identifier.
83pub fn pandoc_code_class_lang(lang: &str) -> Option<&str> {
84    let l = lang.trim();
85    if !l.starts_with('{') || !l.ends_with('}') || l.len() < 2 {
86        return None;
87    }
88    let inner = &l[1..l.len() - 1];
89    inner
90        .split_whitespace()
91        .filter_map(|tok| tok.strip_prefix('.'))
92        .find(|class| !class.is_empty() && class.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
93}
94
95/// Return true if `lang` is a Pandoc code-attribute language declaration: a
96/// brace-delimited attribute list containing at least one `.class`, e.g.
97/// `{.python}`, `{.haskell .numberLines}`, `{#snippet .python startFrom="10"}`.
98pub fn is_pandoc_code_class_attr(lang: &str) -> bool {
99    pandoc_code_class_lang(lang).is_some()
100}
101
102/// Get the indentation level of a div marker
103pub fn get_div_indent(line: &str) -> usize {
104    let mut indent = 0;
105    for c in line.chars() {
106        match c {
107            ' ' => indent += 1,
108            '\t' => indent += 4, // Tabs expand to 4 spaces (CommonMark)
109            _ => break,
110        }
111    }
112    indent
113}
114
115/// Track div nesting state for a document
116#[derive(Debug, Clone, Default)]
117pub struct DivTracker {
118    /// Stack of div indentation levels for nesting tracking
119    indent_stack: Vec<usize>,
120}
121
122impl DivTracker {
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Process a line and return whether we're inside a div after processing
128    pub fn process_line(&mut self, line: &str) -> bool {
129        let trimmed = line.trim_start();
130
131        if trimmed.starts_with(":::") {
132            let indent = get_div_indent(line);
133
134            if is_div_close(line) {
135                // Closing marker - pop the matching div from stack
136                // Pop the top div if its indent is >= the closing marker's indent
137                if let Some(&top_indent) = self.indent_stack.last()
138                    && top_indent >= indent
139                {
140                    self.indent_stack.pop();
141                }
142            } else if is_div_open(line) {
143                // Opening marker - push to stack
144                self.indent_stack.push(indent);
145            }
146        }
147
148        !self.indent_stack.is_empty()
149    }
150
151    /// Check if we're currently inside a div
152    pub fn is_inside_div(&self) -> bool {
153        !self.indent_stack.is_empty()
154    }
155}
156
157/// Detect fenced div block ranges in content.
158/// Returns a vector of byte ranges (start, end) for each div block.
159pub fn detect_div_block_ranges(content: &str) -> Vec<ByteRange> {
160    let mut ranges = Vec::new();
161    let mut tracker = DivTracker::new();
162    let mut div_start: Option<usize> = None;
163    let mut byte_offset = 0;
164
165    for line in content.lines() {
166        let line_len = line.len();
167        let was_inside = tracker.is_inside_div();
168        let is_inside = tracker.process_line(line);
169
170        // Started a new div block
171        if !was_inside && is_inside {
172            div_start = Some(byte_offset);
173        }
174        // Exited a div block
175        else if was_inside
176            && !is_inside
177            && let Some(start) = div_start.take()
178        {
179            // End at the start of the closing line
180            ranges.push(ByteRange {
181                start,
182                end: byte_offset + line_len,
183            });
184        }
185
186        // Account for newline
187        byte_offset += line_len + 1;
188    }
189
190    // Handle unclosed divs at end of document
191    if let Some(start) = div_start {
192        ranges.push(ByteRange {
193            start,
194            end: content.len(),
195        });
196    }
197
198    ranges
199}
200
201/// Check if a byte position is within a div block
202pub fn is_within_div_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
203    ranges.iter().any(|r| position >= r.start && position < r.end)
204}
205
206// ============================================================================
207// Citation Support
208// ============================================================================
209//
210// Pandoc citation syntax:
211// - Inline citation: @smith2020
212// - Parenthetical citation: [@smith2020]
213// - Suppress author: [-@smith2020]
214// - With locator: [@smith2020, p. 10]
215// - Multiple citations: [@smith2020; @jones2021]
216// - With prefix: [see @smith2020]
217//
218// Citation keys must start with a letter, digit, or underscore, and may contain
219// alphanumerics, underscores, hyphens, periods, and colons.
220
221/// Pattern to match bracketed citations: `[@key]`, `[-@key]`, `[see @key]`, `[@a; @b]`
222///
223/// The `@` must sit at a citation boundary: immediately after `[`, or after a
224/// non-word character such as whitespace, `-`, `;`, or `,`. This excludes
225/// word-embedded `@` (e.g. emails or handles in link text like
226/// `[contact user@example.com](url)`), which are not citations.
227static BRACKETED_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
228    Regex::new(r"\[(?:[^\]@]*[^A-Za-z0-9_])?@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*[^\]]*\]").unwrap()
229});
230
231/// Pattern to match inline citations: @key (not inside brackets)
232/// Citation key: starts with letter/digit/underscore, contains alphanumerics and some punctuation
233/// The @ must be preceded by whitespace, start of line, or punctuation (not alphanumeric)
234static INLINE_CITATION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
235    // Match @ at start of string, after whitespace, or after non-alphanumeric (except @[)
236    Regex::new(r"(?:^|[\s\(\[\{,;:])(@[a-zA-Z0-9_][a-zA-Z0-9_:.#$%&\-+?<>~/]*)").unwrap()
237});
238
239/// Pattern to match the bracketed text portion of a Markdown link.
240///
241/// Matches `[...]` that is *immediately* followed by `(` (inline link) or
242/// `[` (reference link). Capture group 1 is the bracket span, including the
243/// surrounding `[` and `]`. Used by citation detection to exclude `@key`
244/// occurrences appearing inside link labels.
245static LINK_LABEL_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\[[^\]]*\])(?:\(|\[)").unwrap());
246
247/// Quick check if text might contain citations
248#[inline]
249pub fn has_citations(text: &str) -> bool {
250    text.contains('@')
251}
252
253// ============================================================================
254// Inline Footnote Support
255// ============================================================================
256//
257// Pandoc inline footnote syntax: ^[footnote text]
258//
259// The `^` must not be preceded by `!` (image) or by a word character
260// (superscript syntax: `2^10^`). The footnote body extends to the first
261// unescaped `]`; nested brackets are not supported in this detector.
262
263/// Pattern for Pandoc inline footnotes: `^[note text]`.
264/// The `^` must not be preceded by `!` (which would be an image) or by
265/// alphanumeric (which would be a superscript: `2^10^`).
266static INLINE_FOOTNOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:^|[^\w!])(\^\[[^\]]*\])").unwrap());
267
268/// Compute the Pandoc-style slug for a heading text.
269///
270/// Pandoc's `auto_identifiers` extension:
271/// 1. Remove all formatting, links, etc.
272/// 2. Remove all footnotes.
273/// 3. Remove all non-alphanumeric characters except `_`, `-`, `.`.
274/// 4. Replace all spaces with `-`.
275/// 5. Lowercase letters.
276/// 6. If nothing remains, use `section`.
277pub fn pandoc_header_slug(text: &str) -> String {
278    let mut s = String::with_capacity(text.len());
279    for c in text.chars() {
280        if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
281            for lc in c.to_lowercase() {
282                s.push(lc);
283            }
284        } else if c.is_whitespace() {
285            // Collapse runs of whitespace to a single `-`.
286            if !s.ends_with('-') {
287                s.push('-');
288            }
289        }
290        // Drop other punctuation entirely.
291    }
292    let trimmed = s.trim_matches('-').to_string();
293    if trimmed.is_empty() {
294        "section".to_string()
295    } else {
296        trimmed
297    }
298}
299
300/// Find headings in the document and return a set of their Pandoc slugs.
301///
302/// Scans ATX-style headings (lines beginning with one or more `#`) and computes
303/// a slug for each using [`pandoc_header_slug`]. The resulting set is used by
304/// the `implicit_header_references` extension detector in
305/// [`LintContext`](crate::lint_context::LintContext).
306///
307/// Pandoc's `auto_identifiers` extension disambiguates duplicate headings by
308/// appending `-1`, `-2`, etc. to the second, third, … occurrence of the same
309/// base slug. Both the base slug and its suffixed forms are inserted so that
310/// links such as `#section` and `#section-1` both resolve.
311///
312/// Lines inside fenced code blocks (delimited by ` ``` ` or `~~~`, >= 3 chars)
313/// are skipped so that bash comments and shebang lines are not mistaken for
314/// headings.
315pub fn collect_pandoc_header_slugs(content: &str) -> std::collections::HashSet<String> {
316    use std::collections::{HashMap, HashSet};
317    let mut slugs = HashSet::new();
318    let mut base_counts: HashMap<String, usize> = HashMap::new();
319    let mut in_fence = false;
320    let mut fence_marker: Option<char> = None;
321    for line in content.lines() {
322        let trimmed = line.trim_start();
323        // Detect fenced code block open/close. Pandoc fences are >= 3 backticks
324        // or >= 3 tildes at the start of a line (after optional indentation).
325        // A closing fence must use the same marker character as the opening one.
326        if let Some(c) = trimmed.chars().next()
327            && (c == '`' || c == '~')
328        {
329            let count = trimmed.chars().take_while(|&ch| ch == c).count();
330            if count >= 3 {
331                match fence_marker {
332                    None => {
333                        in_fence = true;
334                        fence_marker = Some(c);
335                    }
336                    Some(m) if m == c => {
337                        in_fence = false;
338                        fence_marker = None;
339                    }
340                    _ => {}
341                }
342                continue;
343            }
344        }
345        if in_fence {
346            continue;
347        }
348        if let Some(rest) = trimmed.strip_prefix('#') {
349            let mut text = rest.trim_start_matches('#').trim();
350            // Strip trailing `{#id .class}` attribute block only when the `{...}`
351            // extends to the end of the text (possibly followed by whitespace).
352            // This prevents `{` appearing inside heading body text (e.g.
353            // `# Some {curly} word`) from being mistaken for an attribute block.
354            if let Some(idx) = text.rfind(" {")
355                && let Some(close_rel) = text[idx + 2..].find('}')
356                && text[idx + 2 + close_rel + 1..].trim().is_empty()
357            {
358                text = &text[..idx];
359            }
360            let base = pandoc_header_slug(text);
361            let count = base_counts.entry(base.clone()).or_insert(0);
362            let slug = if *count == 0 {
363                base.clone()
364            } else {
365                format!("{base}-{count}")
366            };
367            *count += 1;
368            slugs.insert(slug);
369        }
370    }
371    slugs
372}
373
374// ============================================================================
375// Subscript and Superscript Support
376// ============================================================================
377//
378// Pandoc `subscript` extension: `~x~` where x contains no whitespace or `~`.
379// Pandoc `superscript` extension: `^x^` where x contains no whitespace or `^`.
380//
381// These are distinct from GFM strikethrough (`~~text~~`) and Pandoc inline
382// footnotes (`^[...]`). The disambiguation rule for subscript is: reject any
383// match where the opening or closing `~` is immediately adjacent to another `~`
384// (which would make it GFM strikethrough). For superscript, reject matches
385// where a `^` neighbour would form `^^`.
386
387/// Pattern for Pandoc subscript: `~x~` where x is non-whitespace, non-`~`.
388static SUBSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~[^\s~]+~").unwrap());
389
390/// Pattern for Pandoc superscript: `^x^` where x is non-whitespace, non-`^`.
391static SUPERSCRIPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\^[^\s^]+\^").unwrap());
392
393/// Detect Pandoc subscript (`~x~`) and superscript (`^x^`) ranges.
394///
395/// Returns byte ranges covering the full delimited span (including the
396/// delimiter characters). Excludes `~~strikethrough~~` and superscript-like
397/// runs of `^^`. The returned ranges are sorted by `start`.
398///
399/// Note: a `^[…]^` construct will also match `detect_inline_footnote_ranges`.
400/// Rules that distinguish footnotes from superscripts must check both accessors.
401pub fn detect_subscript_superscript_ranges(content: &str) -> Vec<ByteRange> {
402    let bytes = content.as_bytes();
403    let mut ranges = Vec::new();
404
405    for m in SUBSCRIPT_PATTERN.find_iter(content) {
406        // Reject if preceded or followed by `~` (would be strikethrough).
407        let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
408        let next = bytes.get(m.end()).copied().unwrap_or(0);
409        if prev != b'~' && next != b'~' {
410            ranges.push(ByteRange {
411                start: m.start(),
412                end: m.end(),
413            });
414        }
415    }
416    for m in SUPERSCRIPT_PATTERN.find_iter(content) {
417        // Reject if preceded or followed by `^` (would be a `^^` run).
418        let prev = m.start().checked_sub(1).map_or(0, |i| bytes[i]);
419        let next = bytes.get(m.end()).copied().unwrap_or(0);
420        if prev != b'^' && next != b'^' {
421            ranges.push(ByteRange {
422                start: m.start(),
423                end: m.end(),
424            });
425        }
426    }
427    // Sort because the two regex passes are merged and their results may interleave.
428    ranges.sort_by_key(|r| r.start);
429    ranges
430}
431
432// ============================================================================
433// Inline Code Attribute Support
434// ============================================================================
435//
436// Pandoc `inline_code_attributes` extension: `` `code`{.lang} ``
437//
438// The attribute block must immediately follow the closing backtick of the
439// inline code span. Only the `{...}` part is captured; the backtick span
440// itself is already handled by the standard code-span detector.
441
442/// Pattern for inline code attribute: a backtick-quoted span immediately
443/// followed by `{...}`. We capture only the trailing attribute block.
444static INLINE_CODE_ATTR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`[^`]*`(\{[^}]+\})").unwrap());
445
446/// Detect Pandoc inline code attribute ranges.
447///
448/// Inline code attributes are written as `` `code`{.lang} ``. Returns the
449/// byte ranges of the trailing `{...}` attribute block only (not the
450/// backticked code itself).
451pub fn detect_inline_code_attr_ranges(content: &str) -> Vec<ByteRange> {
452    let mut ranges = Vec::new();
453    for caps in INLINE_CODE_ATTR.captures_iter(content) {
454        let m = caps.get(1).unwrap();
455        ranges.push(ByteRange {
456            start: m.start(),
457            end: m.end(),
458        });
459    }
460    ranges
461}
462
463// ============================================================================
464// Example List Support
465// ============================================================================
466//
467// Pandoc `example_lists` extension:
468// - Line-start marker: `(@)` or `(@label)` followed by whitespace
469// - Inline reference: `(@label)` appearing mid-paragraph (not at line start)
470//
471// Example keys contain letters, digits, underscores, and hyphens.
472// The anonymous form `(@)` is valid as a marker but cannot appear as a reference
473// (references require a label to be named).
474
475/// Pattern for an example-list marker at line start: `(@)` or `(@label)` followed
476/// by whitespace. Captures the `(@...)` portion.
477static EXAMPLE_LIST_MARKER: LazyLock<Regex> =
478    LazyLock::new(|| Regex::new(r"(?m)^[ \t]*(\(@[A-Za-z0-9_-]*\))[ \t]+").unwrap());
479
480/// Pattern for an example reference: `(@label)` anywhere in text. Used together
481/// with the marker pre-pass to filter out line-start markers.
482static EXAMPLE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\(@[A-Za-z0-9_-]+\))").unwrap());
483
484/// Detect Pandoc example-list marker ranges (`(@)` / `(@label)` at line start).
485///
486/// Returns byte ranges covering the `(@...)` portion of each marker. Used by
487/// rules that process list markers to skip Pandoc example markers.
488pub fn detect_example_list_marker_ranges(content: &str) -> Vec<ByteRange> {
489    let mut ranges = Vec::new();
490    for caps in EXAMPLE_LIST_MARKER.captures_iter(content) {
491        let m = caps.get(1).unwrap();
492        ranges.push(ByteRange {
493            start: m.start(),
494            end: m.end(),
495        });
496    }
497    ranges
498}
499
500/// Detect Pandoc example reference ranges (`(@label)` not at line start).
501///
502/// Excludes positions whose start byte appears in `marker_ranges` (those are
503/// line-start markers, not references). The caller must pass the already-computed
504/// result of [`detect_example_list_marker_ranges`] so the marker regex is not
505/// executed a second time.
506pub fn detect_example_reference_ranges(content: &str, marker_ranges: &[ByteRange]) -> Vec<ByteRange> {
507    let mut ranges = Vec::new();
508    let marker_starts: std::collections::HashSet<usize> = marker_ranges.iter().map(|r| r.start).collect();
509    for caps in EXAMPLE_REFERENCE.captures_iter(content) {
510        let m = caps.get(1).unwrap();
511        if !marker_starts.contains(&m.start()) {
512            ranges.push(ByteRange {
513                start: m.start(),
514                end: m.end(),
515            });
516        }
517    }
518    ranges
519}
520
521// ============================================================================
522// Bracketed Span Support
523// ============================================================================
524//
525// Pandoc `bracketed_spans` extension: `[text]{attrs}` where attrs is a
526// non-empty Pandoc attribute block.
527//
528// Distinguished from `[text](url)` (link) and `[text][ref]` (reference link)
529// by requiring `]{` immediately adjacent — the `{` must directly follow `]`
530// with no intervening characters.
531
532/// Pattern for Pandoc bracketed span: `[text]{attrs}` where attrs is a
533/// non-empty Pandoc attribute block. The regex requires `]{` immediately
534/// adjacent (no characters between `]` and `{`), which excludes `[text](url)`
535/// links and `[text][ref]` reference links.
536static BRACKETED_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[[^\]]+\]\{[^}]+\}").unwrap());
537
538/// Detect Pandoc bracketed span ranges (`[text]{attrs}`).
539///
540/// Returns byte ranges covering the full `[...]` + `{...}` span. The detector
541/// is structural only — it does not validate `attrs` content.
542pub fn detect_bracketed_span_ranges(content: &str) -> Vec<ByteRange> {
543    let mut ranges = Vec::new();
544    for m in BRACKETED_SPAN.find_iter(content) {
545        ranges.push(ByteRange {
546            start: m.start(),
547            end: m.end(),
548        });
549    }
550    ranges
551}
552
553// ============================================================================
554// Line Block Support
555// ============================================================================
556//
557// Pandoc `line_blocks` extension: a contiguous run of lines starting with `| `
558// (pipe space). Each line in a line block is rendered as a separate line of
559// verse or address. Continuation lines — indented, non-empty, not starting
560// with `|` — extend the immediately preceding block line.
561//
562// Distinguished from pipe tables: a line whose trimmed form ends with `|`
563// (i.e. `| col1 | col2 |`) is a table row, not a line block entry.
564
565/// Detect Pandoc line blocks (consecutive lines starting with `| `).
566///
567/// A line block is a contiguous run of lines where each line either:
568/// - Starts with `| ` (a single pipe followed by space) and does NOT
569///   end with `|` (which would be a pipe-table row), or
570/// - Is a continuation line (whitespace-indented, non-empty, not starting
571///   with `|`) appearing within an active line-block run.
572///
573/// A blank line ends the run.
574pub fn detect_line_block_ranges(content: &str) -> Vec<ByteRange> {
575    let mut ranges = Vec::new();
576    let mut in_block = false;
577    let mut block_start = 0usize;
578    let mut block_end = 0usize;
579    let mut byte_offset = 0usize;
580
581    for line in content.split_inclusive('\n') {
582        let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
583        let is_line_block_line = trimmed.starts_with("| ") && !trimmed.trim_end().ends_with('|');
584        let is_continuation = in_block
585            && !trimmed.is_empty()
586            && trimmed.starts_with(|c: char| c.is_whitespace())
587            && !trimmed.trim_start().starts_with('|');
588
589        if is_line_block_line || is_continuation {
590            if !in_block {
591                block_start = byte_offset;
592                in_block = true;
593            }
594            block_end = byte_offset + line.len();
595        } else if in_block {
596            ranges.push(ByteRange {
597                start: block_start,
598                end: block_end,
599            });
600            in_block = false;
601        }
602        byte_offset += line.len();
603    }
604    if in_block {
605        ranges.push(ByteRange {
606            start: block_start,
607            end: block_end,
608        });
609    }
610    ranges
611}
612
613// ============================================================================
614// Pipe-Table Caption Support
615// ============================================================================
616//
617// Pandoc `table_captions` extension: a `: caption text` line that appears
618// adjacent to a pipe table, separated by exactly one blank line (either
619// above or below). Without the blank-line adjacency to a pipe table, a
620// `: text` line is a definition-list value and must NOT be matched here.
621//
622// Matching rule:
623//   caption_below: caption at line i, blank at i+1, pipe-table row at i+2
624//   caption_above: pipe-table row at i-2, blank at i-1, caption at i
625
626/// Detect Pandoc pipe-table caption lines (`: caption`) adjacent (above or
627/// below, separated by exactly one blank line) to a pipe table. A `: text`
628/// line not adjacent to a table is treated as a definition-list value and
629/// is not matched here.
630///
631/// Iterates with `split_inclusive('\n')` so byte ranges remain accurate for
632/// content without a trailing newline and for CRLF line endings.
633pub fn detect_pipe_table_caption_ranges(content: &str) -> Vec<ByteRange> {
634    let mut lines: Vec<&str> = Vec::new();
635    let mut line_offsets: Vec<usize> = Vec::new();
636    let mut offset = 0usize;
637    for line in content.split_inclusive('\n') {
638        line_offsets.push(offset);
639        lines.push(line);
640        offset += line.len();
641    }
642    line_offsets.push(offset);
643
644    fn line_body(line: &str) -> &str {
645        line.trim_end_matches('\n').trim_end_matches('\r')
646    }
647    fn is_pipe_table_row(line: &str) -> bool {
648        let t = line_body(line).trim();
649        t.starts_with('|') && t.ends_with('|') && t.len() >= 3
650    }
651    fn is_caption_line(line: &str) -> bool {
652        line_body(line).trim_start().starts_with(": ")
653    }
654    fn is_blank(line: &str) -> bool {
655        line_body(line).trim().is_empty()
656    }
657
658    let mut ranges = Vec::new();
659    for (i, line) in lines.iter().enumerate() {
660        if !is_caption_line(line) {
661            continue;
662        }
663        let table_below = i + 2 < lines.len() && is_blank(lines[i + 1]) && is_pipe_table_row(lines[i + 2]);
664        let table_above = i >= 2 && is_blank(lines[i - 1]) && is_pipe_table_row(lines[i - 2]);
665        if table_below || table_above {
666            ranges.push(ByteRange {
667                start: line_offsets[i],
668                end: line_offsets[i + 1],
669            });
670        }
671    }
672    ranges
673}
674
675// ============================================================================
676// YAML Metadata Block Support
677// ============================================================================
678//
679// Pandoc `yaml_metadata_block` extension: one or more `---`-delimited YAML
680// blocks anywhere in the document. Unlike standard frontmatter (single block
681// at file start), Pandoc allows:
682//   - Multiple blocks per document
683//   - `---` opener
684//   - Either `---` or `...` as the closer
685//   - Opener must be at start-of-file OR immediately after a blank line
686//   - Unterminated openers are skipped
687
688/// Detect Pandoc YAML metadata blocks (`---...---` or `---...`).
689/// Unlike standard frontmatter, these can appear anywhere in the document
690/// and there can be multiple per file.
691pub fn detect_yaml_metadata_block_ranges(content: &str) -> Vec<ByteRange> {
692    let mut lines: Vec<&str> = Vec::new();
693    let mut line_offsets: Vec<usize> = Vec::new();
694    let mut offset = 0usize;
695    for line in content.split_inclusive('\n') {
696        line_offsets.push(offset);
697        lines.push(line);
698        offset += line.len();
699    }
700    line_offsets.push(offset);
701
702    fn line_body(line: &str) -> &str {
703        line.trim_end_matches('\n').trim_end_matches('\r')
704    }
705    fn is_blank(line: &str) -> bool {
706        line_body(line).trim().is_empty()
707    }
708    fn is_opener(line: &str) -> bool {
709        line_body(line).trim_end() == "---"
710    }
711    fn is_closer(line: &str) -> bool {
712        let t = line_body(line).trim_end();
713        t == "---" || t == "..."
714    }
715
716    let mut ranges = Vec::new();
717    let mut i = 0;
718    while i < lines.len() {
719        let preceded_by_blank = i == 0 || is_blank(lines[i - 1]);
720        if preceded_by_blank && is_opener(lines[i]) {
721            let mut j = i + 1;
722            let mut found_closer = false;
723            while j < lines.len() {
724                if is_closer(lines[j]) {
725                    ranges.push(ByteRange {
726                        start: line_offsets[i],
727                        end: line_offsets[j + 1],
728                    });
729                    i = j + 1;
730                    found_closer = true;
731                    break;
732                }
733                j += 1;
734            }
735            if !found_closer {
736                // Unterminated opener — skip and continue scanning.
737                i += 1;
738            }
739        } else {
740            i += 1;
741        }
742    }
743    ranges
744}
745
746// ============================================================================
747// Grid Table Support
748// ============================================================================
749//
750// Pandoc `grid_tables` extension: a contiguous block of lines where the
751// first line is a `+---+---+` border row (`+` corners, `-` or `=` between),
752// followed by alternating content rows (`| ... | ... |`) and border rows
753// (`+---+---+` or `+===+===+`), ending with a closing border row.
754// At least one content row is required for a valid grid table.
755
756/// Pattern for a grid-table border row: `+---+---+` or `+===+===+`.
757static GRID_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\+(?:[-=]+\+)+\s*$").unwrap());
758
759/// Pattern for a grid-table content row: `| ... | ... |`.
760static GRID_CONTENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|.*\|\s*$").unwrap());
761
762/// Detect Pandoc grid tables. A grid table is a contiguous run of lines
763/// where the first line is a `+---+---+` border, followed by alternating
764/// content rows `|...|` and border rows, and ending in a border row.
765/// At least one content row is required.
766///
767/// Iterates with `split_inclusive('\n')` so byte ranges remain accurate for
768/// content without a trailing newline and for CRLF line endings.
769pub fn detect_grid_table_ranges(content: &str) -> Vec<ByteRange> {
770    let mut lines: Vec<&str> = Vec::new();
771    let mut line_offsets: Vec<usize> = Vec::new();
772    let mut offset = 0usize;
773    for line in content.split_inclusive('\n') {
774        line_offsets.push(offset);
775        lines.push(line);
776        offset += line.len();
777    }
778    line_offsets.push(offset);
779
780    fn line_body(line: &str) -> &str {
781        line.trim_end_matches('\n').trim_end_matches('\r')
782    }
783    fn is_border(line: &str) -> bool {
784        GRID_BORDER.is_match(line_body(line))
785    }
786    fn is_content(line: &str) -> bool {
787        GRID_CONTENT.is_match(line_body(line))
788    }
789
790    let mut ranges = Vec::new();
791    let mut i = 0;
792    while i < lines.len() {
793        if is_border(lines[i]) {
794            let start_line = i;
795            let mut j = i + 1;
796            let mut last_border = i;
797            let mut saw_content = false;
798            while j < lines.len() {
799                if is_border(lines[j]) {
800                    last_border = j;
801                    j += 1;
802                } else if is_content(lines[j]) {
803                    saw_content = true;
804                    j += 1;
805                } else {
806                    break;
807                }
808            }
809            // A valid grid table needs at least one content row and a
810            // closing border (last_border > start_line).
811            if saw_content && last_border > start_line {
812                ranges.push(ByteRange {
813                    start: line_offsets[start_line],
814                    end: line_offsets[last_border + 1],
815                });
816                i = last_border + 1;
817                continue;
818            }
819        }
820        i += 1;
821    }
822    ranges
823}
824
825// ============================================================================
826// Multi-line Table Support
827// ============================================================================
828//
829// Pandoc `multiline_tables` extension: a block whose column widths are declared
830// by an underline row of dashes-separated-by-spaces (MULTI_LINE_UNDERLINE), with
831// an optional top-border and a mandatory closing solid-dash row (MULTI_LINE_BORDER).
832// The header line immediately precedes the underline row.
833
834/// Pattern for a multi-line table column-width underline row.
835/// Matches two or more runs of dashes separated by spaces, e.g.:
836/// `----------- ------- --------------- -------------------------`
837static MULTI_LINE_UNDERLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{2,}(?:\s+-{2,})+\s*$").unwrap());
838
839/// Pattern for a multi-line table solid border row (≥10 dashes).
840/// Used as both an optional top border and the mandatory closing border.
841static MULTI_LINE_BORDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^-{10,}\s*$").unwrap());
842
843/// Detect Pandoc multi-line table ranges.
844///
845/// A multi-line table is recognised by an underline row (dashes separated by
846/// spaces, ≥2 columns) immediately following a non-empty header line. The table
847/// extends to the next solid-dash border row (≥10 dashes). An optional solid
848/// border may appear before the header as well.
849///
850/// Iterates with `split_inclusive('\n')` so byte ranges remain accurate for
851/// content without a trailing newline and for CRLF line endings.
852pub fn detect_multi_line_table_ranges(content: &str) -> Vec<ByteRange> {
853    let mut lines: Vec<&str> = Vec::new();
854    let mut line_offsets: Vec<usize> = Vec::new();
855    let mut offset = 0usize;
856    for line in content.split_inclusive('\n') {
857        line_offsets.push(offset);
858        lines.push(line);
859        offset += line.len();
860    }
861    line_offsets.push(offset);
862
863    fn line_body(line: &str) -> &str {
864        line.trim_end_matches('\n').trim_end_matches('\r')
865    }
866    fn is_underline(line: &str) -> bool {
867        MULTI_LINE_UNDERLINE.is_match(line_body(line))
868    }
869    fn is_border(line: &str) -> bool {
870        MULTI_LINE_BORDER.is_match(line_body(line))
871    }
872
873    let mut ranges = Vec::new();
874    let mut i = 0;
875    while i < lines.len() {
876        // Look for an underline row whose previous line is a non-empty header.
877        if i >= 1 && is_underline(lines[i]) && !line_body(lines[i - 1]).is_empty() {
878            // Walk backward from i-1 to find the first line of the header block.
879            // The header may span multiple lines; keep going back while lines are
880            // non-empty and not themselves borders or underlines.
881            let mut header_start = i - 1;
882            while header_start > 0
883                && !line_body(lines[header_start - 1]).is_empty()
884                && !is_border(lines[header_start - 1])
885                && !is_underline(lines[header_start - 1])
886            {
887                header_start -= 1;
888            }
889
890            // Optionally include a solid border that precedes the header block.
891            let start_line = if header_start > 0 && is_border(lines[header_start - 1]) {
892                header_start - 1
893            } else {
894                header_start
895            };
896
897            // Walk forward from the line after the underline to find the closing border.
898            let mut j = i + 1;
899            let mut end_line: Option<usize> = None;
900            while j < lines.len() {
901                if is_border(lines[j]) {
902                    // Closing solid-dash border found.
903                    end_line = Some(j);
904                    break;
905                } else if j > i + 1 && is_underline(lines[j]) {
906                    // Another column-width underline (second header section?):
907                    // the previous line is the last body line.
908                    end_line = Some(j - 1);
909                    break;
910                }
911                j += 1;
912            }
913
914            if let Some(end) = end_line {
915                ranges.push(ByteRange {
916                    start: line_offsets[start_line],
917                    end: line_offsets[end + 1],
918                });
919                i = end + 1;
920                continue;
921            }
922            // No closing border found — skip this candidate and keep walking.
923        }
924        i += 1;
925    }
926    ranges
927}
928
929/// Detect Pandoc inline footnote ranges (`^[note text]`).
930///
931/// Returns byte ranges covering the entire `^[...]` span. Intended for rules that
932/// process bracket-like syntax to skip Pandoc inline footnotes.
933pub fn detect_inline_footnote_ranges(content: &str) -> Vec<ByteRange> {
934    let mut ranges = Vec::new();
935    for caps in INLINE_FOOTNOTE_PATTERN.captures_iter(content) {
936        let m = caps.get(1).unwrap();
937        ranges.push(ByteRange {
938            start: m.start(),
939            end: m.end(),
940        });
941    }
942    ranges
943}
944
945/// Find all citation ranges in content (byte ranges)
946/// Returns ranges for both bracketed `[@key]` and inline `@key` citations.
947///
948/// Markdown link labels are excluded: when `[text]` is immediately followed
949/// by `(` (inline link) or `[` (reference link), Pandoc prefers the link
950/// parse over the citation parse, so any `@key` mentioned inside `text`
951/// is not a citation. The link-label scan covers both bracketed-form
952/// matches and free-floating inline `@key` matches.
953pub fn find_citation_ranges(content: &str) -> Vec<ByteRange> {
954    let mut ranges = Vec::new();
955
956    // Pre-compute Markdown link-label byte ranges (the `[text]` portion of
957    // `[text](url)` or `[text][ref]`).
958    let link_label_ranges: Vec<(usize, usize)> = LINK_LABEL_PATTERN
959        .captures_iter(content)
960        .filter_map(|c| c.get(1).map(|m| (m.start(), m.end())))
961        .collect();
962
963    let in_link_label = |pos: usize| -> bool { link_label_ranges.iter().any(|&(s, e)| pos >= s && pos < e) };
964
965    // Find bracketed citations first (higher priority)
966    for mat in BRACKETED_CITATION_PATTERN.find_iter(content) {
967        if in_link_label(mat.start()) {
968            continue;
969        }
970        ranges.push(ByteRange {
971            start: mat.start(),
972            end: mat.end(),
973        });
974    }
975
976    // Find inline citations (but not inside already-found brackets or link labels)
977    for cap in INLINE_CITATION_PATTERN.captures_iter(content) {
978        if let Some(mat) = cap.get(1) {
979            let start = mat.start();
980            if in_link_label(start) {
981                continue;
982            }
983            // Skip if this is inside a bracketed citation
984            if !ranges.iter().any(|r| start >= r.start && start < r.end) {
985                ranges.push(ByteRange { start, end: mat.end() });
986            }
987        }
988    }
989
990    // Sort by start position
991    ranges.sort_by_key(|r| r.start);
992    ranges
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998
999    #[test]
1000    fn test_div_open_detection() {
1001        // Valid div openings
1002        assert!(is_div_open("::: {.callout-note}"));
1003        assert!(is_div_open("::: {.callout-warning}"));
1004        assert!(is_div_open("::: {#myid .class}"));
1005        assert!(is_div_open("::: bordered"));
1006        assert!(is_div_open("  ::: {.note}")); // Indented
1007        assert!(is_div_open("::: {.callout-tip title=\"My Title\"}"));
1008
1009        // Invalid patterns
1010        assert!(!is_div_open(":::")); // Just closing marker
1011        assert!(!is_div_open(":::  ")); // Just closing with trailing space
1012        assert!(!is_div_open("Regular text"));
1013        assert!(!is_div_open("# Heading"));
1014        assert!(!is_div_open("```python")); // Code fence
1015    }
1016
1017    #[test]
1018    fn test_div_close_detection() {
1019        assert!(is_div_close(":::"));
1020        assert!(is_div_close(":::  "));
1021        assert!(is_div_close("  :::"));
1022        assert!(is_div_close("    :::  "));
1023
1024        assert!(!is_div_close("::: {.note}"));
1025        assert!(!is_div_close("::: class"));
1026        assert!(!is_div_close(":::note"));
1027    }
1028
1029    #[test]
1030    fn test_callout_detection() {
1031        assert!(is_callout_open("::: {.callout-note}"));
1032        assert!(is_callout_open("::: {.callout-warning}"));
1033        assert!(is_callout_open("::: {.callout-tip}"));
1034        assert!(is_callout_open("::: {.callout-important}"));
1035        assert!(is_callout_open("::: {.callout-caution}"));
1036        assert!(is_callout_open("::: {#myid .callout-note}"));
1037        assert!(is_callout_open("::: {.callout-note title=\"Title\"}"));
1038
1039        assert!(!is_callout_open("::: {.note}")); // Not a callout
1040        assert!(!is_callout_open("::: {.bordered}")); // Not a callout
1041        assert!(!is_callout_open("::: callout-note")); // Missing braces
1042    }
1043
1044    #[test]
1045    fn test_div_tracker() {
1046        let mut tracker = DivTracker::new();
1047
1048        // Enter a div
1049        assert!(tracker.process_line("::: {.callout-note}"));
1050        assert!(tracker.is_inside_div());
1051
1052        // Inside content
1053        assert!(tracker.process_line("This is content."));
1054        assert!(tracker.is_inside_div());
1055
1056        // Exit the div
1057        assert!(!tracker.process_line(":::"));
1058        assert!(!tracker.is_inside_div());
1059    }
1060
1061    #[test]
1062    fn test_nested_divs() {
1063        let mut tracker = DivTracker::new();
1064
1065        // Outer div
1066        assert!(tracker.process_line("::: {.outer}"));
1067        assert!(tracker.is_inside_div());
1068
1069        // Inner div
1070        assert!(tracker.process_line("  ::: {.inner}"));
1071        assert!(tracker.is_inside_div());
1072
1073        // Content
1074        assert!(tracker.process_line("    Content"));
1075        assert!(tracker.is_inside_div());
1076
1077        // Close inner
1078        assert!(tracker.process_line("  :::"));
1079        assert!(tracker.is_inside_div());
1080
1081        // Close outer
1082        assert!(!tracker.process_line(":::"));
1083        assert!(!tracker.is_inside_div());
1084    }
1085
1086    #[test]
1087    fn test_detect_div_block_ranges() {
1088        let content = r#"# Heading
1089
1090::: {.callout-note}
1091This is a note.
1092:::
1093
1094Regular text.
1095
1096::: {.bordered}
1097Content here.
1098:::
1099"#;
1100        let ranges = detect_div_block_ranges(content);
1101        assert_eq!(ranges.len(), 2);
1102
1103        // First div
1104        let first_div_content = &content[ranges[0].start..ranges[0].end];
1105        assert!(first_div_content.contains("callout-note"));
1106        assert!(first_div_content.contains("This is a note"));
1107
1108        // Second div
1109        let second_div_content = &content[ranges[1].start..ranges[1].end];
1110        assert!(second_div_content.contains("bordered"));
1111        assert!(second_div_content.contains("Content here"));
1112    }
1113
1114    #[test]
1115    fn test_pandoc_attributes() {
1116        assert!(has_pandoc_attributes("# Heading {#custom-id}"));
1117        assert!(has_pandoc_attributes("# Heading {.unnumbered}"));
1118        assert!(has_pandoc_attributes("![Image](path.png){#fig-1 width=\"50%\"}"));
1119        assert!(has_pandoc_attributes("{#id .class key=\"value\"}"));
1120
1121        assert!(!has_pandoc_attributes("# Heading"));
1122        assert!(!has_pandoc_attributes("Regular text"));
1123        assert!(!has_pandoc_attributes("{}"));
1124    }
1125
1126    #[test]
1127    fn test_div_with_title_attribute() {
1128        let content = r#"::: {.callout-note title="Important Note"}
1129This is the content of the note.
1130It can span multiple lines.
1131:::
1132"#;
1133        let ranges = detect_div_block_ranges(content);
1134        assert_eq!(ranges.len(), 1);
1135        assert!(is_callout_open("::: {.callout-note title=\"Important Note\"}"));
1136    }
1137
1138    #[test]
1139    fn test_unclosed_div() {
1140        let content = r#"::: {.callout-note}
1141This note is never closed.
1142"#;
1143        let ranges = detect_div_block_ranges(content);
1144        assert_eq!(ranges.len(), 1);
1145        // Should include all content to end of document
1146        assert_eq!(ranges[0].end, content.len());
1147    }
1148
1149    #[test]
1150    fn test_heading_inside_callout() {
1151        let content = r#"::: {.callout-warning}
1152## Warning Title
1153
1154Warning content here.
1155:::
1156"#;
1157        let ranges = detect_div_block_ranges(content);
1158        assert_eq!(ranges.len(), 1);
1159
1160        let div_content = &content[ranges[0].start..ranges[0].end];
1161        assert!(div_content.contains("## Warning Title"));
1162    }
1163
1164    // Citation tests
1165    #[test]
1166    fn test_has_citations() {
1167        assert!(has_citations("See @smith2020 for details."));
1168        assert!(has_citations("[@smith2020]"));
1169        assert!(has_citations("Multiple [@a; @b] citations"));
1170        assert!(!has_citations("No citations here"));
1171        // has_citations is just a quick @ check - emails will pass (intended behavior)
1172        assert!(has_citations("Email: user@example.com"));
1173    }
1174
1175    #[test]
1176    fn test_bracketed_citation_detection() {
1177        let content = "See [@smith2020] for more info.";
1178        let ranges = find_citation_ranges(content);
1179        assert_eq!(ranges.len(), 1);
1180        assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020]");
1181    }
1182
1183    #[test]
1184    fn test_inline_citation_detection() {
1185        let content = "As @smith2020 argues, this is true.";
1186        let ranges = find_citation_ranges(content);
1187        assert_eq!(ranges.len(), 1);
1188        assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1189    }
1190
1191    #[test]
1192    fn test_multiple_citations_in_brackets() {
1193        let content = "See [@smith2020; @jones2021] for details.";
1194        let ranges = find_citation_ranges(content);
1195        assert_eq!(ranges.len(), 1);
1196        assert_eq!(&content[ranges[0].start..ranges[0].end], "[@smith2020; @jones2021]");
1197    }
1198
1199    #[test]
1200    fn test_citation_with_prefix() {
1201        let content = "[see @smith2020, p. 10]";
1202        let ranges = find_citation_ranges(content);
1203        assert_eq!(ranges.len(), 1);
1204        assert_eq!(&content[ranges[0].start..ranges[0].end], "[see @smith2020, p. 10]");
1205    }
1206
1207    #[test]
1208    fn test_suppress_author_citation() {
1209        let content = "The theory [-@smith2020] states that...";
1210        let ranges = find_citation_ranges(content);
1211        assert_eq!(ranges.len(), 1);
1212        assert_eq!(&content[ranges[0].start..ranges[0].end], "[-@smith2020]");
1213    }
1214
1215    #[test]
1216    fn test_mixed_citations() {
1217        let content = "@smith2020 argues that [@jones2021] is wrong.";
1218        let ranges = find_citation_ranges(content);
1219        assert_eq!(ranges.len(), 2);
1220        // Inline citation
1221        assert_eq!(&content[ranges[0].start..ranges[0].end], "@smith2020");
1222        // Bracketed citation
1223        assert_eq!(&content[ranges[1].start..ranges[1].end], "[@jones2021]");
1224    }
1225
1226    #[test]
1227    fn test_email_not_confused_with_citation() {
1228        // Email addresses should not match as inline citations when properly filtered
1229        // The has_citations() is just a quick check, but find_citation_ranges uses more strict patterns
1230        let content = "Contact user@example.com for help.";
1231        let ranges = find_citation_ranges(content);
1232        // Email should not be detected as citation (@ is preceded by alphanumeric)
1233        assert!(
1234            ranges.is_empty()
1235                || !ranges.iter().any(|r| {
1236                    let s = &content[r.start..r.end];
1237                    s.contains("example.com")
1238                })
1239        );
1240    }
1241
1242    /// Bracketed link text containing an email (`@` embedded in a word) must
1243    /// NOT be classified as a Pandoc citation. A citation `@key` requires the
1244    /// `@` to sit at a citation boundary — start of bracket, after `-`, after
1245    /// whitespace, or after `;` — never in the middle of a word like an email.
1246    #[test]
1247    fn test_bracketed_link_text_with_email_not_citation() {
1248        let content = "[contact user@example.com](#missing)";
1249        let ranges = find_citation_ranges(content);
1250        assert!(
1251            ranges.is_empty(),
1252            "Bracketed link text with embedded email must not be detected as a Pandoc citation: {ranges:?}"
1253        );
1254    }
1255
1256    /// Same bracketed text with an empty link target — also a link, not a citation.
1257    #[test]
1258    fn test_bracketed_link_text_with_email_empty_href_not_citation() {
1259        let content = "[contact user@example.com]()";
1260        let ranges = find_citation_ranges(content);
1261        assert!(
1262            ranges.is_empty(),
1263            "Bracketed link text with embedded email and empty href must not be a Pandoc citation: {ranges:?}"
1264        );
1265    }
1266
1267    /// A bracketed label whose text mentions a citation key but is *immediately*
1268    /// followed by a link target `(...)` is a Markdown link, not a citation.
1269    /// Pandoc itself prefers the link interpretation for `[text](url)` over a
1270    /// citation parse, even when `text` contains `@key`.
1271    #[test]
1272    fn test_bracketed_text_followed_by_inline_link_not_citation() {
1273        let content = "[see @smith2020](#missing)";
1274        let ranges = find_citation_ranges(content);
1275        assert!(
1276            ranges.is_empty(),
1277            "Bracketed text followed by `(...)` is a link, not a citation: {ranges:?}"
1278        );
1279    }
1280
1281    /// Same disambiguation when the link target is empty: still a link.
1282    #[test]
1283    fn test_bracketed_text_followed_by_empty_inline_link_not_citation() {
1284        let content = "[see @smith2020]()";
1285        let ranges = find_citation_ranges(content);
1286        assert!(
1287            ranges.is_empty(),
1288            "Bracketed text followed by `()` is a link with empty href, not a citation: {ranges:?}"
1289        );
1290    }
1291
1292    /// Reference-style links `[text][ref]` are also links — the bracketed
1293    /// label `[text]` must not be classified as a citation just because it
1294    /// contains `@key`.
1295    #[test]
1296    fn test_bracketed_text_followed_by_reference_link_not_citation() {
1297        let content = "[see @smith2020][ref]";
1298        let ranges = find_citation_ranges(content);
1299        assert!(
1300            ranges.is_empty(),
1301            "Bracketed text followed by `[ref]` is a reference link, not a citation: {ranges:?}"
1302        );
1303    }
1304
1305    /// Standalone bracketed citations remain citations: nothing immediately
1306    /// follows the closing `]`, so the link disambiguation does not apply.
1307    #[test]
1308    fn test_standalone_bracketed_citation_still_detected() {
1309        let content = "See [see @smith2020] for details.";
1310        let ranges = find_citation_ranges(content);
1311        assert!(
1312            ranges.iter().any(|r| &content[r.start..r.end] == "[see @smith2020]"),
1313            "Standalone bracketed citation must still be detected: {ranges:?}"
1314        );
1315    }
1316
1317    /// Citation followed by sentence punctuation remains a citation.
1318    #[test]
1319    fn test_bracketed_citation_followed_by_punctuation_still_detected() {
1320        let content = "Note [@smith2020].";
1321        let ranges = find_citation_ranges(content);
1322        assert!(
1323            ranges.iter().any(|r| &content[r.start..r.end] == "[@smith2020]"),
1324            "Bracketed citation followed by `.` must still be detected: {ranges:?}"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_detect_inline_footnotes() {
1330        let content = "See ^[a quick note] here.\nAnd ^[another one] too.\n";
1331        let ranges = detect_inline_footnote_ranges(content);
1332        assert_eq!(ranges.len(), 2);
1333        // First footnote
1334        let first_start = content.find("^[").unwrap();
1335        let first_end = content[first_start..].find(']').unwrap() + first_start + 1;
1336        assert_eq!(ranges[0].start, first_start);
1337        assert_eq!(ranges[0].end, first_end);
1338        // Second footnote
1339        let second_start = content[first_end..].find("^[").unwrap() + first_end;
1340        let second_end = content[second_start..].find(']').unwrap() + second_start + 1;
1341        assert_eq!(ranges[1].start, second_start);
1342        assert_eq!(ranges[1].end, second_end);
1343    }
1344
1345    #[test]
1346    fn test_inline_footnote_with_brackets_inside() {
1347        // Inline footnotes do not nest; a `]` inside terminates the footnote.
1348        // This documents the chosen behavior. Pandoc itself supports nesting via
1349        // backslash-escapes; rumdl currently treats the first unescaped `]` as
1350        // the terminator.
1351        let content = "Note ^[ref to [other] thing] here.\n";
1352        let ranges = detect_inline_footnote_ranges(content);
1353        assert_eq!(ranges.len(), 1);
1354    }
1355
1356    #[test]
1357    fn test_inline_footnote_does_not_match_image_or_link() {
1358        // `![alt]` is an image, not a footnote.
1359        let content = "An image ![alt](url) and a link [txt](url).\n";
1360        let ranges = detect_inline_footnote_ranges(content);
1361        assert_eq!(ranges.len(), 0);
1362    }
1363
1364    #[test]
1365    fn test_implicit_header_reference_slug() {
1366        // Pandoc lowercases, replaces internal whitespace with `-`, and strips
1367        // punctuation other than `_`, `-`, `.`.
1368        assert_eq!(pandoc_header_slug("My Section"), "my-section");
1369        assert_eq!(pandoc_header_slug("API: v2!"), "api-v2");
1370        assert_eq!(pandoc_header_slug("  Trim Me  "), "trim-me");
1371        assert_eq!(pandoc_header_slug("Multiple   Spaces"), "multiple-spaces");
1372    }
1373
1374    #[test]
1375    fn test_collect_pandoc_header_slugs() {
1376        let content = "# My Section\n\n## Sub-section\n\nbody\n";
1377        let slugs = collect_pandoc_header_slugs(content);
1378        assert!(slugs.contains("my-section"));
1379        assert!(slugs.contains("sub-section"));
1380    }
1381
1382    #[test]
1383    fn test_collect_pandoc_header_slugs_strips_attribute_block() {
1384        let content = "# My Section {#custom-id .red}\n## Plain Section\n";
1385        let slugs = collect_pandoc_header_slugs(content);
1386        assert!(slugs.contains("my-section"));
1387        assert!(slugs.contains("plain-section"));
1388        // Slug must not include the attribute block contents.
1389        assert!(!slugs.iter().any(|s| s.contains("custom-id")));
1390    }
1391
1392    #[test]
1393    fn test_collect_pandoc_header_slugs_preserves_body_braces() {
1394        // `{` in heading body must NOT be mistaken for an attribute block.
1395        let content = "# Some {curly} word in title\n";
1396        let slugs = collect_pandoc_header_slugs(content);
1397        assert!(slugs.contains("some-curly-word-in-title"));
1398    }
1399
1400    #[test]
1401    fn test_collect_pandoc_header_slugs_disambiguates_duplicates() {
1402        // Pandoc's auto_identifiers extension assigns the second heading with the
1403        // same slug `<base>-1`, the third `<base>-2`, etc. Both base and suffixed
1404        // forms must be reachable as link targets.
1405        let content = "# A.\n\nbody\n\n# A.\n";
1406        let slugs = collect_pandoc_header_slugs(content);
1407        assert!(slugs.contains("a."), "first occurrence should expose base slug `a.`");
1408        assert!(
1409            slugs.contains("a.-1"),
1410            "second occurrence should expose `a.-1`: got {slugs:?}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_collect_pandoc_header_slugs_three_duplicates_get_two_suffixes() {
1416        let content = "# Intro\n\n# Intro\n\n# Intro\n";
1417        let slugs = collect_pandoc_header_slugs(content);
1418        assert!(slugs.contains("intro"));
1419        assert!(slugs.contains("intro-1"));
1420        assert!(slugs.contains("intro-2"));
1421        assert!(
1422            !slugs.contains("intro-3"),
1423            "three occurrences must produce only -1 and -2 suffixes, not -3: got {slugs:?}"
1424        );
1425    }
1426
1427    #[test]
1428    fn test_collect_pandoc_header_slugs_unique_headings_get_no_suffix() {
1429        let content = "# Foo\n\n# Bar\n\n# Baz\n";
1430        let slugs = collect_pandoc_header_slugs(content);
1431        assert!(slugs.contains("foo"));
1432        assert!(slugs.contains("bar"));
1433        assert!(slugs.contains("baz"));
1434        // Unique headings must not gain a `-1` suffix.
1435        assert!(!slugs.contains("foo-1"));
1436        assert!(!slugs.contains("bar-1"));
1437        assert!(!slugs.contains("baz-1"));
1438    }
1439
1440    #[test]
1441    fn test_detect_example_list_markers() {
1442        let content = "(@)  First item.\n(@good) Second item.\n(@) Third item.\n";
1443        let ranges = detect_example_list_marker_ranges(content);
1444        assert_eq!(ranges.len(), 3);
1445        assert_eq!(ranges[0].start, 0);
1446        assert_eq!(&content[ranges[0].start..ranges[0].end], "(@)");
1447        let second_start = content.find("(@good)").unwrap();
1448        assert_eq!(ranges[1].start, second_start);
1449        assert_eq!(&content[ranges[1].start..ranges[1].end], "(@good)");
1450    }
1451
1452    #[test]
1453    fn test_detect_example_references() {
1454        // `(@label)` mid-paragraph is a reference, not a list marker.
1455        let content = "As shown in (@good), this works.\n";
1456        let marker_ranges = detect_example_list_marker_ranges(content);
1457        let ranges = detect_example_reference_ranges(content, &marker_ranges);
1458        assert_eq!(ranges.len(), 1);
1459    }
1460
1461    #[test]
1462    fn test_example_marker_must_be_at_line_start() {
1463        let content = "Inline (@) is not a marker.\n";
1464        let ranges = detect_example_list_marker_ranges(content);
1465        assert_eq!(ranges.len(), 0);
1466    }
1467
1468    #[test]
1469    fn test_detect_subscript() {
1470        let content = "H~2~O is water.\n";
1471        let ranges = detect_subscript_superscript_ranges(content);
1472        assert_eq!(ranges.len(), 1);
1473        assert_eq!(&content[ranges[0].start..ranges[0].end], "~2~");
1474    }
1475
1476    #[test]
1477    fn test_detect_superscript() {
1478        let content = "2^10^ is 1024.\n";
1479        let ranges = detect_subscript_superscript_ranges(content);
1480        assert_eq!(ranges.len(), 1);
1481        assert_eq!(&content[ranges[0].start..ranges[0].end], "^10^");
1482    }
1483
1484    #[test]
1485    fn test_subscript_does_not_match_strikethrough() {
1486        // `~~text~~` is GFM strikethrough, not subscript.
1487        let content = "This is ~~struck~~.\n";
1488        let ranges = detect_subscript_superscript_ranges(content);
1489        assert_eq!(ranges.len(), 0);
1490    }
1491
1492    #[test]
1493    fn test_superscript_with_internal_space_is_not_matched() {
1494        // Pandoc requires no whitespace inside `^...^`.
1495        let content = "x^a b^ y\n";
1496        let ranges = detect_subscript_superscript_ranges(content);
1497        assert_eq!(ranges.len(), 0);
1498    }
1499
1500    #[test]
1501    fn test_subscript_at_start_of_input() {
1502        // Position 0: previous-byte path uses checked_sub(1).unwrap_or(0).
1503        let content = "~x~ rest of line\n";
1504        let ranges = detect_subscript_superscript_ranges(content);
1505        assert_eq!(ranges.len(), 1);
1506        assert_eq!(&content[ranges[0].start..ranges[0].end], "~x~");
1507    }
1508
1509    #[test]
1510    fn test_superscript_at_end_of_input_no_newline() {
1511        // EOF: next-byte path uses bytes.get(end).unwrap_or(0).
1512        let content = "text ^x^";
1513        let ranges = detect_subscript_superscript_ranges(content);
1514        assert_eq!(ranges.len(), 1);
1515        assert_eq!(&content[ranges[0].start..ranges[0].end], "^x^");
1516    }
1517
1518    #[test]
1519    fn test_detect_inline_code_attribute() {
1520        // `code`{.python} — the {.python} is a Pandoc attribute on inline code.
1521        let content = "Use `print()`{.python} for output.\n";
1522        let ranges = detect_inline_code_attr_ranges(content);
1523        assert_eq!(ranges.len(), 1);
1524        let r = &ranges[0];
1525        assert_eq!(&content[r.start..r.end], "{.python}");
1526    }
1527
1528    #[test]
1529    fn test_inline_code_attribute_only_after_backtick() {
1530        // A bare `{...}` in prose is not an inline code attribute.
1531        let content = "Use {.example} for the class.\n";
1532        let ranges = detect_inline_code_attr_ranges(content);
1533        assert_eq!(ranges.len(), 0);
1534    }
1535
1536    #[test]
1537    fn test_inline_code_attribute_multiple_on_one_line() {
1538        let content = "Use `a`{.x} and `b`{.y} here.\n";
1539        let ranges = detect_inline_code_attr_ranges(content);
1540        assert_eq!(ranges.len(), 2);
1541        assert_eq!(&content[ranges[0].start..ranges[0].end], "{.x}");
1542        assert_eq!(&content[ranges[1].start..ranges[1].end], "{.y}");
1543    }
1544
1545    #[test]
1546    fn test_inline_code_attribute_compound_attributes() {
1547        // Pandoc supports compound attribute blocks: classes, IDs, and key=value pairs.
1548        let content = "Use `code`{.lang #id key=value} here.\n";
1549        let ranges = detect_inline_code_attr_ranges(content);
1550        assert_eq!(ranges.len(), 1);
1551        assert_eq!(&content[ranges[0].start..ranges[0].end], "{.lang #id key=value}");
1552    }
1553
1554    #[test]
1555    fn test_detect_bracketed_span() {
1556        let content = "This is [some text]{.smallcaps} here.\n";
1557        let ranges = detect_bracketed_span_ranges(content);
1558        assert_eq!(ranges.len(), 1);
1559        let r = &ranges[0];
1560        assert_eq!(&content[r.start..r.end], "[some text]{.smallcaps}");
1561    }
1562
1563    #[test]
1564    fn test_bracketed_span_does_not_match_link() {
1565        // `[text](url)` is a link, not a bracketed span.
1566        let content = "A [link](http://example.com) here.\n";
1567        let ranges = detect_bracketed_span_ranges(content);
1568        assert_eq!(ranges.len(), 0);
1569    }
1570
1571    #[test]
1572    fn test_bracketed_span_does_not_match_reference_link() {
1573        // `[text][ref]` is a reference link.
1574        let content = "A [ref][def] here.\n[def]: http://example.com\n";
1575        let ranges = detect_bracketed_span_ranges(content);
1576        assert_eq!(ranges.len(), 0);
1577    }
1578
1579    #[test]
1580    fn test_bracketed_span_multiple_on_one_line() {
1581        let content = "[one]{.a} and [two]{.b} together.\n";
1582        let ranges = detect_bracketed_span_ranges(content);
1583        assert_eq!(ranges.len(), 2);
1584        assert_eq!(&content[ranges[0].start..ranges[0].end], "[one]{.a}");
1585        assert_eq!(&content[ranges[1].start..ranges[1].end], "[two]{.b}");
1586    }
1587
1588    #[test]
1589    fn test_bracketed_span_rejects_empty_content() {
1590        // Both bracket and brace bodies require at least one character.
1591        let content = "[]{.x} and [x]{} here.\n";
1592        let ranges = detect_bracketed_span_ranges(content);
1593        assert_eq!(ranges.len(), 0);
1594    }
1595
1596    #[test]
1597    fn test_bracketed_span_at_start_of_line() {
1598        let content = "[head]{.intro} starts the line.\n";
1599        let ranges = detect_bracketed_span_ranges(content);
1600        assert_eq!(ranges.len(), 1);
1601        assert_eq!(ranges[0].start, 0);
1602        assert_eq!(&content[ranges[0].start..ranges[0].end], "[head]{.intro}");
1603    }
1604
1605    #[test]
1606    fn test_detect_line_block_single() {
1607        let content = "| The Lord of the Rings\n| by J.R.R. Tolkien\n";
1608        let ranges = detect_line_block_ranges(content);
1609        assert_eq!(ranges.len(), 1);
1610        assert_eq!(ranges[0].start, 0);
1611        assert_eq!(ranges[0].end, content.len());
1612    }
1613
1614    #[test]
1615    fn test_line_block_no_trailing_newline() {
1616        // Single-line block with no terminating newline must be flushed.
1617        let content = "| Only line";
1618        let ranges = detect_line_block_ranges(content);
1619        assert_eq!(ranges.len(), 1);
1620        assert_eq!(ranges[0].start, 0);
1621        assert_eq!(ranges[0].end, content.len());
1622    }
1623
1624    #[test]
1625    fn test_line_block_indented_pipe_is_not_continuation() {
1626        // An indented line whose non-whitespace content begins with `|` is
1627        // not a plain-text continuation; it ends the active block.
1628        let content = "| First\n  | indented\n";
1629        let ranges = detect_line_block_ranges(content);
1630        assert_eq!(ranges.len(), 1);
1631        assert_eq!(ranges[0].end, "| First\n".len());
1632    }
1633
1634    #[test]
1635    fn test_line_block_continuation_with_indent() {
1636        // A line starting with whitespace (and NOT `|`) inside a line block is
1637        // a continuation of the previous line.
1638        let content = "| First line\n  continuation\n| Second\n";
1639        let ranges = detect_line_block_ranges(content);
1640        assert_eq!(ranges.len(), 1);
1641    }
1642
1643    #[test]
1644    fn test_line_block_separated_by_blank() {
1645        let content = "| Block A\n\n| Block B\n";
1646        let ranges = detect_line_block_ranges(content);
1647        assert_eq!(ranges.len(), 2);
1648    }
1649
1650    #[test]
1651    fn test_line_block_does_not_match_pipe_table() {
1652        // A `| col |...| row` line ending with `|` is a pipe-table row, not a line block.
1653        let content = "| col1 | col2 |\n|------|------|\n";
1654        let ranges = detect_line_block_ranges(content);
1655        assert_eq!(ranges.len(), 0);
1656    }
1657
1658    #[test]
1659    fn test_detect_pipe_table_caption_below() {
1660        let content = "\
1661| col1 | col2 |
1662|------|------|
1663| a    | b    |
1664
1665: My caption
1666";
1667        let ranges = detect_pipe_table_caption_ranges(content);
1668        assert_eq!(ranges.len(), 1);
1669        let cap = &content[ranges[0].start..ranges[0].end];
1670        assert!(cap.starts_with(": My caption"));
1671    }
1672
1673    #[test]
1674    fn test_detect_pipe_table_caption_above() {
1675        let content = "\
1676: Caption first
1677
1678| col1 | col2 |
1679|------|------|
1680| a    | b    |
1681";
1682        let ranges = detect_pipe_table_caption_ranges(content);
1683        assert_eq!(ranges.len(), 1);
1684    }
1685
1686    #[test]
1687    fn test_colon_line_without_adjacent_table_is_definition_term() {
1688        // A `: text` line not adjacent to a table is part of a definition list.
1689        let content = "Term\n: definition\n";
1690        let ranges = detect_pipe_table_caption_ranges(content);
1691        assert_eq!(ranges.len(), 0);
1692    }
1693
1694    #[test]
1695    fn test_pipe_table_caption_two_blank_lines_does_not_match() {
1696        // Pandoc requires exactly one blank line between table and caption.
1697        let content = "\
1698| a | b |
1699|---|---|
1700| 1 | 2 |
1701
1702
1703: Caption
1704";
1705        let ranges = detect_pipe_table_caption_ranges(content);
1706        assert_eq!(ranges.len(), 0);
1707    }
1708
1709    #[test]
1710    fn test_pipe_table_caption_no_blank_line_does_not_match() {
1711        // Adjacent without a blank line is not a caption either.
1712        let content = "\
1713| a | b |
1714|---|---|
1715| 1 | 2 |
1716: Caption
1717";
1718        let ranges = detect_pipe_table_caption_ranges(content);
1719        assert_eq!(ranges.len(), 0);
1720    }
1721
1722    #[test]
1723    fn test_pipe_table_caption_no_trailing_newline() {
1724        // Caption is the final line of the document with no newline; the
1725        // computed end must equal the content length, not overshoot.
1726        let content = "\
1727| a | b |
1728|---|---|
1729| 1 | 2 |
1730
1731: Trailing caption";
1732        let ranges = detect_pipe_table_caption_ranges(content);
1733        assert_eq!(ranges.len(), 1);
1734        assert_eq!(ranges[0].end, content.len());
1735        assert_eq!(&content[ranges[0].start..ranges[0].end], ": Trailing caption");
1736    }
1737
1738    #[test]
1739    fn test_pipe_table_caption_handles_crlf() {
1740        // CRLF line endings must produce correct byte offsets too.
1741        let content = "| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\n\r\n: CRLF caption\r\n";
1742        let ranges = detect_pipe_table_caption_ranges(content);
1743        assert_eq!(ranges.len(), 1);
1744        let cap = &content[ranges[0].start..ranges[0].end];
1745        assert!(cap.starts_with(": CRLF caption"));
1746    }
1747
1748    #[test]
1749    fn test_pipe_table_caption_lone_colon_does_not_match() {
1750        // Pandoc requires `: ` (colon-space) for a caption; bare `:` is not.
1751        let content = "\
1752| a | b |
1753|---|---|
1754| 1 | 2 |
1755
1756:
1757";
1758        let ranges = detect_pipe_table_caption_ranges(content);
1759        assert_eq!(ranges.len(), 0);
1760    }
1761
1762    #[test]
1763    fn test_detect_metadata_block_at_start() {
1764        // Standard frontmatter case — should be returned as a metadata range.
1765        let content = "---\ntitle: Doc\n---\n\nBody.\n";
1766        let ranges = detect_yaml_metadata_block_ranges(content);
1767        assert_eq!(ranges.len(), 1);
1768        assert_eq!(ranges[0].start, 0);
1769    }
1770
1771    #[test]
1772    fn test_detect_metadata_block_mid_document() {
1773        // Pandoc allows multiple `---...---` metadata blocks anywhere.
1774        let content = "---\ntitle: Doc\n---\n\n# Heading\n\n---\nauthor: X\n---\n\nBody.\n";
1775        let ranges = detect_yaml_metadata_block_ranges(content);
1776        assert_eq!(ranges.len(), 2);
1777    }
1778
1779    #[test]
1780    fn test_metadata_block_uses_dot_terminator() {
1781        // Pandoc accepts `...` as an alternative terminator.
1782        let content = "---\ntitle: Doc\n...\n\nBody.\n";
1783        let ranges = detect_yaml_metadata_block_ranges(content);
1784        assert_eq!(ranges.len(), 1);
1785    }
1786
1787    #[test]
1788    fn test_metadata_block_unterminated_opener_skipped() {
1789        // An opener with no closer reaching EOF must NOT produce a range.
1790        let content = "---\ntitle: Doc\nbody continues forever\n";
1791        let ranges = detect_yaml_metadata_block_ranges(content);
1792        assert_eq!(ranges.len(), 0);
1793    }
1794
1795    #[test]
1796    fn test_metadata_block_dashes_after_text_are_not_opener() {
1797        // A `---` line not preceded by a blank is a horizontal rule,
1798        // not a metadata opener.
1799        let content = "Some prose paragraph.\n---\nbody: not-metadata\n---\n";
1800        let ranges = detect_yaml_metadata_block_ranges(content);
1801        assert_eq!(ranges.len(), 0);
1802    }
1803
1804    #[test]
1805    fn test_metadata_block_no_trailing_newline() {
1806        // Block at end of file with no trailing newline; end must equal
1807        // content length, not overshoot.
1808        let content = "---\ntitle: Doc\n---";
1809        let ranges = detect_yaml_metadata_block_ranges(content);
1810        assert_eq!(ranges.len(), 1);
1811        assert_eq!(ranges[0].start, 0);
1812        assert_eq!(ranges[0].end, content.len());
1813    }
1814
1815    #[test]
1816    fn test_metadata_block_handles_crlf() {
1817        // CRLF endings must produce correct byte offsets.
1818        let content = "---\r\ntitle: Doc\r\n---\r\n\r\nBody.\r\n";
1819        let ranges = detect_yaml_metadata_block_ranges(content);
1820        assert_eq!(ranges.len(), 1);
1821        let block = &content[ranges[0].start..ranges[0].end];
1822        assert!(block.starts_with("---\r\n"));
1823        assert!(block.ends_with("---\r\n"));
1824    }
1825
1826    #[test]
1827    fn test_collect_pandoc_header_slugs_skips_code_blocks() {
1828        let content = "\
1829# Real Heading
1830
1831```bash
1832# This is a bash comment
1833#!/usr/bin/env bash
1834```
1835
1836# Another Heading
1837";
1838        let slugs = collect_pandoc_header_slugs(content);
1839        assert!(slugs.contains("real-heading"));
1840        assert!(slugs.contains("another-heading"));
1841        assert!(!slugs.contains("this-is-a-bash-comment"));
1842        assert!(!slugs.iter().any(|s| s.contains("usr-bin")));
1843    }
1844
1845    #[test]
1846    fn test_detect_simple_grid_table() {
1847        let content = "\
1848+---------+---------+
1849| col1    | col2    |
1850+=========+=========+
1851| a       | b       |
1852+---------+---------+
1853";
1854        let ranges = detect_grid_table_ranges(content);
1855        assert_eq!(ranges.len(), 1);
1856        assert_eq!(ranges[0].start, 0);
1857        assert_eq!(ranges[0].end, content.len());
1858    }
1859
1860    #[test]
1861    fn test_grid_table_with_surrounding_text() {
1862        let content = "\
1863Before.
1864
1865+---+---+
1866| a | b |
1867+---+---+
1868| 1 | 2 |
1869+---+---+
1870
1871After.
1872";
1873        let ranges = detect_grid_table_ranges(content);
1874        assert_eq!(ranges.len(), 1);
1875        let region = &content[ranges[0].start..ranges[0].end];
1876        assert!(region.contains("+---+---+"));
1877        assert!(!region.contains("Before"));
1878        assert!(!region.contains("After"));
1879    }
1880
1881    #[test]
1882    fn test_lone_plus_dash_line_is_not_a_table() {
1883        let content = "Just a +---+ in prose.\n";
1884        let ranges = detect_grid_table_ranges(content);
1885        assert_eq!(ranges.len(), 0);
1886    }
1887
1888    #[test]
1889    fn test_grid_table_no_trailing_newline() {
1890        // Block at end of file with no trailing newline; end must equal
1891        // content length, not overshoot.
1892        let content = "+---+---+\n| a | b |\n+---+---+\n| 1 | 2 |\n+---+---+";
1893        let ranges = detect_grid_table_ranges(content);
1894        assert_eq!(ranges.len(), 1);
1895        assert_eq!(ranges[0].start, 0);
1896        assert_eq!(ranges[0].end, content.len());
1897    }
1898
1899    #[test]
1900    fn test_grid_table_crlf() {
1901        // CRLF endings must produce correct byte offsets.
1902        let content = "+---+---+\r\n| a | b |\r\n+---+---+\r\n| 1 | 2 |\r\n+---+---+\r\n";
1903        let ranges = detect_grid_table_ranges(content);
1904        assert_eq!(ranges.len(), 1);
1905        assert_eq!(ranges[0].start, 0);
1906        assert_eq!(ranges[0].end, content.len());
1907    }
1908
1909    #[test]
1910    fn test_grid_table_borders_only_no_content_row_rejected() {
1911        // Two border lines with no content row must not form a valid table.
1912        let content = "+---+\n+---+\n";
1913        let ranges = detect_grid_table_ranges(content);
1914        assert_eq!(ranges.len(), 0);
1915    }
1916
1917    // -----------------------------------------------------------------------
1918    // Multi-line table tests
1919    // -----------------------------------------------------------------------
1920
1921    #[test]
1922    fn test_detect_multi_line_table() {
1923        let content = "\
1924-------------------------------------------------------------
1925 Centered   Default           Right Left
1926  Header    Aligned         Aligned Aligned
1927----------- ------- --------------- -------------------------
1928   First    row                12.0 Example of a row that
1929                                    spans multiple lines.
1930
1931  Second    row                 5.0 Here's another one. Note
1932                                    the blank line between
1933                                    rows.
1934-------------------------------------------------------------
1935";
1936        let ranges = detect_multi_line_table_ranges(content);
1937        assert_eq!(ranges.len(), 1);
1938        assert_eq!(ranges[0].start, 0);
1939        assert_eq!(ranges[0].end, content.len());
1940    }
1941
1942    #[test]
1943    fn test_simple_dash_header_underline_only_does_not_match() {
1944        // The dash line has length 8 < 10 so it is not a MULTI_LINE_BORDER,
1945        // and it is not a MULTI_LINE_UNDERLINE (only one dash run — no spaces).
1946        let content = "Some text\n--------\nMore text\n";
1947        let ranges = detect_multi_line_table_ranges(content);
1948        assert_eq!(ranges.len(), 0);
1949    }
1950
1951    #[test]
1952    fn test_multi_line_table_no_trailing_newline() {
1953        // The last line has no trailing newline; end must equal content.len().
1954        let content = "\
1955-------------------------------------------------------------
1956 Centered   Default           Right Left
1957  Header    Aligned         Aligned Aligned
1958----------- ------- --------------- -------------------------
1959   First    row                12.0 Example of a row that
1960                                    spans multiple lines.
1961
1962  Second    row                 5.0 Here's another one. Note
1963                                    the blank line between
1964                                    rows.
1965-------------------------------------------------------------";
1966        let ranges = detect_multi_line_table_ranges(content);
1967        assert_eq!(ranges.len(), 1);
1968        assert_eq!(ranges[0].end, content.len());
1969    }
1970
1971    #[test]
1972    fn test_multi_line_table_crlf() {
1973        // CRLF line endings must produce correct byte offsets.
1974        let content = "\
1975-------------------------------------------------------------\r\n\
1976 Centered   Default           Right Left\r\n\
1977  Header    Aligned         Aligned Aligned\r\n\
1978----------- ------- --------------- -------------------------\r\n\
1979   First    row                12.0 Example of a row that\r\n\
1980                                    spans multiple lines.\r\n\
1981\r\n\
1982  Second    row                 5.0 Here's another one. Note\r\n\
1983                                    the blank line between\r\n\
1984                                    rows.\r\n\
1985-------------------------------------------------------------\r\n";
1986        let ranges = detect_multi_line_table_ranges(content);
1987        assert_eq!(ranges.len(), 1);
1988        assert_eq!(ranges[0].start, 0);
1989        assert_eq!(ranges[0].end, content.len());
1990    }
1991
1992    #[test]
1993    fn test_multi_line_table_unterminated_skipped() {
1994        // Header + underline but no closing border — must return 0 ranges.
1995        let content = "\
1996 Centered   Default
1997  Header    Aligned
1998----------- -------
1999   First    row
2000   Second   row
2001";
2002        let ranges = detect_multi_line_table_ranges(content);
2003        assert_eq!(ranges.len(), 0);
2004    }
2005
2006    #[test]
2007    fn test_multi_line_table_no_top_border() {
2008        // Valid table with no top border: header line immediately followed by
2009        // the column underline, then body rows, then closing border.
2010        let content = "\
2011  Centered   Default           Right Left
2012----------- ------- --------------- -------------------------
2013   First    row                12.0 Example
2014  Second    row                 5.0 Another
2015-------------------------------------------------------------
2016";
2017        let ranges = detect_multi_line_table_ranges(content);
2018        assert_eq!(ranges.len(), 1);
2019        assert_eq!(ranges[0].start, 0);
2020        assert_eq!(ranges[0].end, content.len());
2021    }
2022
2023    #[test]
2024    fn test_is_pandoc_raw_block_lang() {
2025        assert!(is_pandoc_raw_block_lang("{=html}"));
2026        assert!(is_pandoc_raw_block_lang("{=latex}"));
2027        assert!(is_pandoc_raw_block_lang("{=docx}"));
2028        assert!(is_pandoc_raw_block_lang("{=rst}"));
2029        // Hyphens and underscores are part of the allowed character set.
2030        assert!(is_pandoc_raw_block_lang("{=open-document}"));
2031        assert!(is_pandoc_raw_block_lang("{=my_format}"));
2032        // Uppercase is accepted (Pandoc itself is case-sensitive but the
2033        // grammar permits any ASCII alphanumeric).
2034        assert!(is_pandoc_raw_block_lang("{=HTML}"));
2035        // Reject Quarto exec blocks.
2036        assert!(!is_pandoc_raw_block_lang("{r}"));
2037        assert!(!is_pandoc_raw_block_lang("{python}"));
2038        // Reject malformed.
2039        assert!(!is_pandoc_raw_block_lang("{=}"));
2040        assert!(!is_pandoc_raw_block_lang("{=  }"));
2041        assert!(!is_pandoc_raw_block_lang("=html"));
2042        // Reject inner whitespace and special characters.
2043        assert!(!is_pandoc_raw_block_lang("{=html }"));
2044        assert!(!is_pandoc_raw_block_lang("{=ht ml}"));
2045    }
2046
2047    #[test]
2048    fn test_is_pandoc_code_class_attr() {
2049        // Single class declares the language.
2050        assert!(is_pandoc_code_class_attr("{.python}"));
2051        assert!(is_pandoc_code_class_attr("{.haskell}"));
2052        assert!(is_pandoc_code_class_attr("{.rust}"));
2053        // Multiple classes — first class is the language, rest are decoration.
2054        assert!(is_pandoc_code_class_attr("{.haskell .numberLines}"));
2055        // Class plus id.
2056        assert!(is_pandoc_code_class_attr("{#myid .python}"));
2057        // Class plus key=value attributes.
2058        assert!(is_pandoc_code_class_attr("{.python startFrom=\"10\"}"));
2059        // Class anywhere in the attribute list.
2060        assert!(is_pandoc_code_class_attr("{#snippet .python startFrom=\"10\"}"));
2061        // Identifiers with hyphens and underscores are valid.
2062        assert!(is_pandoc_code_class_attr("{.objective-c}"));
2063        assert!(is_pandoc_code_class_attr("{.my_lang}"));
2064
2065        // Reject — no class anywhere.
2066        assert!(!is_pandoc_code_class_attr("{}"));
2067        assert!(!is_pandoc_code_class_attr("{#myid}"));
2068        assert!(!is_pandoc_code_class_attr("{startFrom=\"10\"}"));
2069        // Reject — Pandoc raw block (handled by separate predicate).
2070        assert!(!is_pandoc_code_class_attr("{=html}"));
2071        // Reject — Quarto exec syntax (no leading dot).
2072        assert!(!is_pandoc_code_class_attr("{r}"));
2073        assert!(!is_pandoc_code_class_attr("{python}"));
2074        // Reject — bare dot with no identifier.
2075        assert!(!is_pandoc_code_class_attr("{.}"));
2076        // Reject — missing braces.
2077        assert!(!is_pandoc_code_class_attr(".python"));
2078        assert!(!is_pandoc_code_class_attr("python"));
2079    }
2080}