Skip to main content

rac_engine/
markdown.rs

1//! Markdown -> Product extraction (PORT-CONTRACT.d/03, parity landmine #2).
2//!
3//! Bespoke CommonMark *block-boundary* tokenizer reproducing markdown-it-py
4//! 4.2.0's `"commonmark"` preset for the consumed surface only: `heading_open`
5//! (tag + 0-based start line) and `inline` (raw content + start line), plus
6//! the oracle's section-slicing walk on top (`src/rac/core/markdown.py`).
7//!
8//! The bake-off of PORT-CONTRACT decision 4 resolves to *bespoke*: external
9//! markdown crates are not in the dependency allowlist and the consumed
10//! surface is block boundaries only, so the block rules of markdown-it-py
11//! (`code fence blockquote hr list reference html_block heading lheading
12//! paragraph`) are ported line-by-line from the oracle venv's source.
13//!
14//! Python-semantics helpers (`py_strip`, `py_casefold`, `py_repr_str`,
15//! Unicode `\d`) come from `crate::pycompat` per the layer-1 contract.
16
17use std::collections::{BTreeSet, HashMap};
18use std::io::Read;
19use std::sync::OnceLock;
20
21use crate::pycompat::{is_re_digit, py_casefold, py_is_space, py_repr_str, py_strip};
22
23// ---------------------------------------------------------------------------
24// Limits (src/rac/core/limits.py)
25// ---------------------------------------------------------------------------
26
27pub const DEFAULT_MAX_FILE_BYTES: u128 = 1 << 20; // 1 MiB
28pub const MAX_FIELD_CHARS: usize = 256 << 10; // 262144 code points per section
29pub const MAX_CAPTURED_LINES: usize = 50_000; // non-blank captured lines
30
31/// Unicode Nd run starts (every decimal-digit run begins at digit value 0);
32/// digit value = (cp - run_start) % 10. Generated from unicodedata (CPython
33/// 3.11). Membership itself is checked with `pycompat::is_re_digit`.
34const ND_RUN_STARTS: &[u32] = &[
35    48, 1632, 1776, 1984, 2406, 2534, 2662, 2790, 2918, 3046, 3174, 3302, 3430, 3558, 3664, 3792,
36    3872, 4160, 4240, 6112, 6160, 6470, 6608, 6784, 6800, 6992, 7088, 7232, 7248, 42528, 43216,
37    43264, 43472, 43504, 43600, 44016, 65296, 66720, 68912, 69734, 69872, 69942, 70096, 70384,
38    70736, 70864, 71248, 71360, 71472, 71904, 72016, 72784, 73040, 73120, 92768, 92864, 93008,
39    120782, 123200, 123632, 125264, 130032,
40];
41
42fn nd_digit_value(c: char) -> Option<u32> {
43    if !is_re_digit(c) {
44        return None;
45    }
46    let cp = c as u32;
47    let idx = match ND_RUN_STARTS.binary_search(&cp) {
48        Ok(i) => i,
49        Err(0) => return None,
50        Err(i) => i - 1,
51    };
52    Some((cp - ND_RUN_STARTS[idx]) % 10)
53}
54
55/// Python `int(str)` (decimal): strips the Unicode whitespace set (which,
56/// unlike `str.strip()`, excludes U+001C-U+001F — verified empirically, and
57/// it matches Rust `str::trim`), accepts an optional sign, Unicode
58/// Nd digits, and single underscores strictly between digits.
59///
60/// Python ints are unbounded; magnitudes beyond i128 SATURATE to i128::MAX
61/// (sign applied afterwards). Every consumer only cares about "positive and
62/// at least N" thresholds far below the saturation point, so the clamp is
63/// unobservable.
64pub fn py_parse_int(raw: &str) -> Option<i128> {
65    let s = raw.trim();
66    let mut chars = s.chars().peekable();
67    let mut neg = false;
68    match chars.peek() {
69        Some('+') => {
70            chars.next();
71        }
72        Some('-') => {
73            neg = true;
74            chars.next();
75        }
76        _ => {}
77    }
78    let mut value: i128 = 0;
79    let mut last_was_digit = false;
80    let mut any_digit = false;
81    for c in chars {
82        if c == '_' {
83            if !last_was_digit {
84                return None;
85            }
86            last_was_digit = false;
87            continue;
88        }
89        let d = nd_digit_value(c)?;
90        value = value
91            .saturating_mul(10)
92            .saturating_add(d as i128);
93        last_was_digit = true;
94        any_digit = true;
95    }
96    if !any_digit || !last_was_digit {
97        return None;
98    }
99    Some(if neg { -value } else { value })
100}
101
102/// `max_file_bytes()` computed from a raw env value (None = unset).
103pub fn max_file_bytes_from(raw: Option<&str>) -> u128 {
104    if let Some(raw) = raw {
105        if let Some(v) = py_parse_int(raw) {
106            if v > 0 {
107                return v as u128;
108            }
109        }
110    }
111    DEFAULT_MAX_FILE_BYTES
112}
113
114/// The per-file byte cap, honoring `DECIDED_MAX_FILE_BYTES` (REQ-001).
115pub fn max_file_bytes() -> u128 {
116    match std::env::var("DECIDED_MAX_FILE_BYTES") {
117        Ok(v) => max_file_bytes_from(Some(&v)),
118        Err(_) => DEFAULT_MAX_FILE_BYTES,
119    }
120}
121
122/// True when `text` exceeds `cap` UTF-8 bytes. (Equivalent to the oracle's
123/// chars-fast-path formulation: both reduce to `utf8_len > cap`.)
124fn exceeds_byte_cap(text: &str, cap: u128) -> bool {
125    text.len() as u128 > cap
126}
127
128// ---------------------------------------------------------------------------
129// Data model (mirrors decided.core.models for the markdown-owned fields)
130// ---------------------------------------------------------------------------
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Issue {
134    pub severity: &'static str,
135    pub code: &'static str,
136    pub message: String,
137    pub line: Option<i64>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Requirement {
142    pub id: String,
143    pub text: String,
144    pub line: i64,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct MalformedRequirement {
149    pub raw: String,
150    pub line: i64,
151    pub bad_id: Option<String>,
152    pub empty_text: bool,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Default)]
156pub struct SearchSection {
157    pub heading: String,
158    pub lines: Vec<String>,
159}
160
161/// `split_frontmatter` result (decided.core.frontmatter.FrontmatterSplit).
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct FrontmatterSplit {
164    pub raw: Option<String>,
165    pub body: String,
166    pub line_offset: usize,
167    pub unterminated: bool,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Default)]
171pub struct Product {
172    pub title: Option<String>,
173    pub extra_title_lines: Vec<i64>,
174    pub problem: Option<String>,
175    pub requirements: Vec<Requirement>,
176    pub malformed_requirements: Vec<MalformedRequirement>,
177    pub success_metrics: Vec<String>,
178    pub risks: Vec<String>,
179    /// Insertion-ordered `{normalized heading -> joined body}` map.
180    pub sections: Vec<(String, String)>,
181    pub search_sections: Vec<SearchSection>,
182    pub has_problem_section: bool,
183    pub has_requirements_section: bool,
184    pub has_metrics_section: bool,
185    pub has_risks_section: bool,
186    pub source_path: String,
187    /// Raw frontmatter text for the frontmatter module (WS2) to parse into
188    /// metadata; None when the document has no (terminated) frontmatter block.
189    pub frontmatter_raw: Option<String>,
190    /// Only the `malformed-frontmatter` unterminated-opener issue lives here;
191    /// `parse_frontmatter` issues are the frontmatter module's concern.
192    pub metadata_issues: Vec<Issue>,
193    pub parse_issues: Vec<Issue>,
194}
195
196/// Split a leading `---` frontmatter block (decided.core.frontmatter).
197pub fn split_frontmatter(text: &str) -> FrontmatterSplit {
198    let lines: Vec<&str> = text.split('\n').collect();
199    if lines.is_empty() || py_strip(lines[0]) != "---" {
200        return FrontmatterSplit {
201            raw: None,
202            body: text.to_string(),
203            line_offset: 0,
204            unterminated: false,
205        };
206    }
207    for i in 1..lines.len() {
208        let s = py_strip(lines[i]);
209        if s == "---" || s == "..." {
210            return FrontmatterSplit {
211                raw: Some(lines[1..i].join("\n")),
212                body: lines[i + 1..].join("\n"),
213                line_offset: i + 1,
214                unterminated: false,
215            };
216        }
217    }
218    FrontmatterSplit {
219        raw: None,
220        body: text.to_string(),
221        line_offset: 0,
222        unterminated: true,
223    }
224}
225
226// ---------------------------------------------------------------------------
227// Block tokenizer: state
228// ---------------------------------------------------------------------------
229
230#[derive(Debug, Clone)]
231pub struct Token {
232    pub typ: &'static str,
233    pub tag: &'static str,
234    pub map: Option<(usize, usize)>,
235    pub content: String,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239enum Parent {
240    Root,
241    Paragraph,
242    Blockquote,
243    List,
244    Reference,
245}
246
247const MAX_NESTING: i32 = 20; // commonmark preset options.maxNesting
248
249#[inline]
250fn is_str_space(c: char) -> bool {
251    c == ' ' || c == '\t'
252}
253
254struct State<'s> {
255    src: &'s [char],
256    b_marks: Vec<usize>,
257    e_marks: Vec<usize>,
258    t_shift: Vec<usize>,
259    s_count: Vec<i32>,
260    bs_count: Vec<i32>,
261    blk_indent: i32,
262    line: usize,
263    line_max: usize,
264    tight: bool,
265    list_indent: i32,
266    parent_type: Parent,
267    level: i32,
268    tokens: Vec<Token>,
269}
270
271impl<'s> State<'s> {
272    fn new(src: &'s [char]) -> Self {
273        let mut st = State {
274            src,
275            b_marks: Vec::new(),
276            e_marks: Vec::new(),
277            t_shift: Vec::new(),
278            s_count: Vec::new(),
279            bs_count: Vec::new(),
280            blk_indent: 0,
281            line: 0,
282            line_max: 0,
283            tight: false,
284            list_indent: -1,
285            parent_type: Parent::Root,
286            level: 0,
287            tokens: Vec::new(),
288        };
289        let length = src.len();
290        let mut indent_found = false;
291        let mut start = 0usize;
292        let mut indent = 0usize;
293        let mut offset = 0i32;
294        for (pos, &ch) in src.iter().enumerate() {
295            if !indent_found {
296                if is_str_space(ch) {
297                    indent += 1;
298                    if ch == '\t' {
299                        offset += 4 - offset % 4;
300                    } else {
301                        offset += 1;
302                    }
303                    continue;
304                } else {
305                    indent_found = true;
306                }
307            }
308            if ch == '\n' || pos == length - 1 {
309                let p = if ch != '\n' { pos + 1 } else { pos };
310                st.b_marks.push(start);
311                st.e_marks.push(p);
312                st.t_shift.push(indent);
313                st.s_count.push(offset);
314                st.bs_count.push(0);
315                indent_found = false;
316                indent = 0;
317                offset = 0;
318                start = p + 1;
319            }
320        }
321        // fake entry to simplify bounds checks
322        st.b_marks.push(length);
323        st.e_marks.push(length);
324        st.t_shift.push(0);
325        st.s_count.push(0);
326        st.bs_count.push(0);
327        st.line_max = st.b_marks.len() - 1;
328        st
329    }
330
331    fn push(&mut self, typ: &'static str, tag: &'static str, nesting: i32) -> usize {
332        if nesting < 0 {
333            self.level -= 1;
334        }
335        if nesting > 0 {
336            self.level += 1;
337        }
338        self.tokens.push(Token {
339            typ,
340            tag,
341            map: None,
342            content: String::new(),
343        });
344        self.tokens.len() - 1
345    }
346
347    fn is_empty(&self, line: usize) -> bool {
348        self.b_marks[line] + self.t_shift[line] >= self.e_marks[line]
349    }
350
351    fn skip_empty_lines(&self, mut from: usize) -> usize {
352        while from < self.line_max {
353            if self.b_marks[from] + self.t_shift[from] < self.e_marks[from] {
354                break;
355            }
356            from += 1;
357        }
358        from
359    }
360
361    fn skip_spaces(&self, mut pos: usize) -> usize {
362        while let Some(&c) = self.src.get(pos) {
363            if !is_str_space(c) {
364                break;
365            }
366            pos += 1;
367        }
368        pos
369    }
370
371    fn skip_spaces_back(&self, mut pos: usize, minimum: usize) -> usize {
372        if pos <= minimum {
373            return pos;
374        }
375        while pos > minimum {
376            pos -= 1;
377            if !is_str_space(self.src[pos]) {
378                return pos + 1;
379            }
380        }
381        pos
382    }
383
384    fn skip_chars_str(&self, mut pos: usize, ch: char) -> usize {
385        while let Some(&c) = self.src.get(pos) {
386            if c != ch {
387                break;
388            }
389            pos += 1;
390        }
391        pos
392    }
393
394    fn skip_chars_str_back(&self, mut pos: usize, ch: char, minimum: usize) -> usize {
395        if pos <= minimum {
396            return pos;
397        }
398        while pos > minimum {
399            pos -= 1;
400            if ch != self.src[pos] {
401                return pos + 1;
402            }
403        }
404        pos
405    }
406
407    fn get_lines(&self, begin: usize, end: usize, indent: i32, keep_last_lf: bool) -> String {
408        if begin >= end {
409            return String::new();
410        }
411        let mut out = String::new();
412        for line in begin..end {
413            let mut line_indent: i32 = 0;
414            let line_start = self.b_marks[line];
415            let mut first = line_start;
416            let last = if line + 1 < end || keep_last_lf {
417                self.e_marks[line] + 1
418            } else {
419                self.e_marks[line]
420            };
421            while first < last && line_indent < indent {
422                match self.src.get(first) {
423                    None => break, // Python would IndexError here; defensively stop
424                    Some(&ch) => {
425                        if is_str_space(ch) {
426                            if ch == '\t' {
427                                line_indent += 4 - (line_indent + self.bs_count[line]) % 4;
428                            } else {
429                                line_indent += 1;
430                            }
431                        } else if first - line_start < self.t_shift[line] {
432                            line_indent += 1;
433                        } else {
434                            break;
435                        }
436                    }
437                }
438                first += 1;
439            }
440            if line_indent > indent {
441                for _ in 0..(line_indent - indent) {
442                    out.push(' ');
443                }
444            }
445            let last_c = last.min(self.src.len());
446            if first < last_c {
447                out.extend(self.src[first..last_c].iter());
448            }
449        }
450        out
451    }
452
453    fn is_code_block(&self, line: usize) -> bool {
454        self.s_count[line] - self.blk_indent >= 4
455    }
456}
457
458type Rule = fn(&mut State, usize, usize, bool) -> bool;
459
460// Terminator chains for the commonmark preset:
461// getRules("paragraph"|"reference"|"blockquote") = fence, blockquote, hr,
462// list, html_block, heading; getRules("list") = fence, blockquote, hr.
463const TERM_PARAGRAPH: &[Rule] = &[
464    rule_fence,
465    rule_blockquote,
466    rule_hr,
467    rule_list,
468    rule_html_block,
469    rule_heading,
470];
471const TERM_LIST: &[Rule] = &[rule_fence, rule_blockquote, rule_hr];
472const RULES_ROOT: &[Rule] = &[
473    rule_code,
474    rule_fence,
475    rule_blockquote,
476    rule_hr,
477    rule_list,
478    rule_reference,
479    rule_html_block,
480    rule_heading,
481    rule_lheading,
482    rule_paragraph,
483];
484
485fn tokenize(state: &mut State, start_line: usize, end_line: usize) {
486    let mut has_empty_lines = false;
487    let mut line = start_line;
488    while line < end_line {
489        line = state.skip_empty_lines(line);
490        state.line = line;
491        if line >= end_line {
492            break;
493        }
494        if state.s_count[line] < state.blk_indent {
495            break;
496        }
497        if state.level >= MAX_NESTING {
498            state.line = end_line;
499            break;
500        }
501        for rule in RULES_ROOT {
502            if rule(state, line, end_line, false) {
503                break;
504            }
505        }
506        state.tight = !has_empty_lines;
507        line = state.line;
508        if line >= 1 && (line - 1) < end_line && state.is_empty(line - 1) {
509            has_empty_lines = true;
510        }
511        if line < end_line && state.is_empty(line) {
512            has_empty_lines = true;
513            line += 1;
514            state.line = line;
515        }
516    }
517}
518
519// ---------------------------------------------------------------------------
520// Block rules
521// ---------------------------------------------------------------------------
522
523fn rule_code(state: &mut State, start_line: usize, end_line: usize, _silent: bool) -> bool {
524    if !state.is_code_block(start_line) {
525        return false;
526    }
527    let mut last = start_line + 1;
528    let mut next_line = start_line + 1;
529    while next_line < end_line {
530        if state.is_empty(next_line) {
531            next_line += 1;
532            continue;
533        }
534        if state.is_code_block(next_line) {
535            next_line += 1;
536            last = next_line;
537            continue;
538        }
539        break;
540    }
541    state.line = last;
542    let mut content = state.get_lines(start_line, last, 4 + state.blk_indent, false);
543    content.push('\n');
544    let i = state.push("code_block", "code", 0);
545    state.tokens[i].content = content;
546    state.tokens[i].map = Some((start_line, state.line));
547    true
548}
549
550fn rule_fence(state: &mut State, start_line: usize, end_line: usize, silent: bool) -> bool {
551    let mut have_end_marker = false;
552    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
553    let mut maximum = state.e_marks[start_line];
554    if state.is_code_block(start_line) {
555        return false;
556    }
557    if pos + 3 > maximum {
558        return false;
559    }
560    let marker = state.src[pos];
561    if marker != '~' && marker != '`' {
562        return false;
563    }
564    let mem = pos;
565    pos = state.skip_chars_str(pos, marker);
566    let length = pos - mem;
567    if length < 3 {
568        return false;
569    }
570    let params: String = state.src[pos..maximum].iter().collect();
571    if marker == '`' && params.contains('`') {
572        return false;
573    }
574    if silent {
575        return true;
576    }
577    let mut next_line = start_line;
578    loop {
579        next_line += 1;
580        if next_line >= end_line {
581            break;
582        }
583        pos = state.b_marks[next_line] + state.t_shift[next_line];
584        let mem2 = pos;
585        maximum = state.e_marks[next_line];
586        if pos < maximum && state.s_count[next_line] < state.blk_indent {
587            break;
588        }
589        match state.src.get(pos) {
590            Some(&c) if c == marker => {}
591            Some(_) => continue,
592            None => break,
593        }
594        if state.is_code_block(next_line) {
595            continue;
596        }
597        pos = state.skip_chars_str(pos, marker);
598        if pos - mem2 < length {
599            continue;
600        }
601        pos = state.skip_spaces(pos);
602        if pos < maximum {
603            continue;
604        }
605        have_end_marker = true;
606        break;
607    }
608    let indent = state.s_count[start_line];
609    state.line = next_line + if have_end_marker { 1 } else { 0 };
610    let i = state.push("fence", "code", 0);
611    state.tokens[i].content = state.get_lines(start_line + 1, next_line, indent, true);
612    state.tokens[i].map = Some((start_line, state.line));
613    true
614}
615
616fn rule_hr(state: &mut State, start_line: usize, _end_line: usize, silent: bool) -> bool {
617    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
618    let maximum = state.e_marks[start_line];
619    if state.is_code_block(start_line) {
620        return false;
621    }
622    let marker = match state.src.get(pos) {
623        Some(&c) => c,
624        None => return false,
625    };
626    pos += 1;
627    if marker != '*' && marker != '-' && marker != '_' {
628        return false;
629    }
630    let mut cnt = 1;
631    while pos < maximum {
632        let ch = state.src[pos];
633        pos += 1;
634        if ch != marker && !is_str_space(ch) {
635            return false;
636        }
637        if ch == marker {
638            cnt += 1;
639        }
640    }
641    if cnt < 3 {
642        return false;
643    }
644    if silent {
645        return true;
646    }
647    state.line = start_line + 1;
648    let i = state.push("hr", "hr", 0);
649    state.tokens[i].map = Some((start_line, state.line));
650    true
651}
652
653const H_TAGS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"];
654
655fn rule_heading(state: &mut State, start_line: usize, _end_line: usize, silent: bool) -> bool {
656    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
657    let mut maximum = state.e_marks[start_line];
658    if state.is_code_block(start_line) {
659        return false;
660    }
661    let mut ch = state.src.get(pos).copied();
662    if ch != Some('#') || pos >= maximum {
663        return false;
664    }
665    let mut level = 1usize;
666    pos += 1;
667    ch = state.src.get(pos).copied();
668    while ch == Some('#') && pos < maximum && level <= 6 {
669        level += 1;
670        pos += 1;
671        ch = state.src.get(pos).copied();
672    }
673    if level > 6 || (pos < maximum && !ch.is_some_and(is_str_space)) {
674        return false;
675    }
676    if silent {
677        return true;
678    }
679    maximum = state.skip_spaces_back(maximum, pos);
680    let tmp = state.skip_chars_str_back(maximum, '#', pos);
681    if tmp > pos && is_str_space(state.src[tmp - 1]) {
682        maximum = tmp;
683    }
684    state.line = start_line + 1;
685    let tag = H_TAGS[level - 1];
686    let i = state.push("heading_open", tag, 1);
687    state.tokens[i].map = Some((start_line, state.line));
688    let content: String = if maximum > pos {
689        state.src[pos..maximum].iter().collect()
690    } else {
691        String::new()
692    };
693    let i2 = state.push("inline", "", 0);
694    state.tokens[i2].content = py_strip(&content).to_string();
695    state.tokens[i2].map = Some((start_line, state.line));
696    state.push("heading_close", tag, -1);
697    true
698}
699
700fn rule_lheading(state: &mut State, start_line: usize, end_line: usize, _silent: bool) -> bool {
701    let mut level: Option<usize> = None;
702    let mut next_line = start_line + 1;
703    if state.is_code_block(start_line) {
704        return false;
705    }
706    let old_parent = state.parent_type;
707    state.parent_type = Parent::Paragraph;
708    while next_line < end_line && !state.is_empty(next_line) {
709        if state.s_count[next_line] - state.blk_indent > 3 {
710            next_line += 1;
711            continue;
712        }
713        if state.s_count[next_line] >= state.blk_indent {
714            let mut pos = state.b_marks[next_line] + state.t_shift[next_line];
715            let maximum = state.e_marks[next_line];
716            if pos < maximum {
717                let m = state.src[pos];
718                if m == '-' || m == '=' {
719                    pos = state.skip_chars_str(pos, m);
720                    pos = state.skip_spaces(pos);
721                    if pos >= maximum {
722                        level = Some(if m == '=' { 1 } else { 2 });
723                        break;
724                    }
725                }
726            }
727        }
728        if state.s_count[next_line] < 0 {
729            next_line += 1;
730            continue;
731        }
732        let mut terminate = false;
733        for rule in TERM_PARAGRAPH {
734            if rule(state, next_line, end_line, true) {
735                terminate = true;
736                break;
737            }
738        }
739        if terminate {
740            break;
741        }
742        next_line += 1;
743    }
744    let lv = match level {
745        Some(lv) => lv,
746        // NOTE: the oracle does NOT restore parentType on this failure path
747        // (unobservable: every terminator loop re-sets it first).
748        None => return false,
749    };
750    let content =
751        py_strip(&state.get_lines(start_line, next_line, state.blk_indent, false)).to_string();
752    state.line = next_line + 1;
753    let tag = H_TAGS[lv - 1];
754    let i = state.push("heading_open", tag, 1);
755    state.tokens[i].map = Some((start_line, state.line));
756    let i2 = state.push("inline", "", 0);
757    state.tokens[i2].content = content;
758    state.tokens[i2].map = Some((start_line, state.line - 1));
759    state.push("heading_close", tag, -1);
760    state.parent_type = old_parent;
761    true
762}
763
764fn rule_paragraph(state: &mut State, start_line: usize, _end_line: usize, _silent: bool) -> bool {
765    let mut next_line = start_line + 1;
766    let end_line = state.line_max; // paragraph ignores the passed endLine
767    let old_parent = state.parent_type;
768    state.parent_type = Parent::Paragraph;
769    while next_line < end_line {
770        if state.is_empty(next_line) {
771            break;
772        }
773        if state.s_count[next_line] - state.blk_indent > 3 {
774            next_line += 1;
775            continue;
776        }
777        if state.s_count[next_line] < 0 {
778            next_line += 1;
779            continue;
780        }
781        let mut terminate = false;
782        for rule in TERM_PARAGRAPH {
783            if rule(state, next_line, end_line, true) {
784                terminate = true;
785                break;
786            }
787        }
788        if terminate {
789            break;
790        }
791        next_line += 1;
792    }
793    let content =
794        py_strip(&state.get_lines(start_line, next_line, state.blk_indent, false)).to_string();
795    state.line = next_line;
796    let i = state.push("paragraph_open", "p", 1);
797    state.tokens[i].map = Some((start_line, state.line));
798    let i2 = state.push("inline", "", 0);
799    state.tokens[i2].content = content;
800    state.tokens[i2].map = Some((start_line, state.line));
801    state.push("paragraph_close", "p", -1);
802    state.parent_type = old_parent;
803    true
804}
805
806fn rule_blockquote(state: &mut State, start_line: usize, end_line: usize, silent: bool) -> bool {
807    let old_line_max = state.line_max;
808    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
809    let mut max = state.e_marks[start_line];
810    if state.is_code_block(start_line) {
811        return false;
812    }
813    if state.src.get(pos) != Some(&'>') {
814        return false;
815    }
816    pos += 1;
817    if silent {
818        return true;
819    }
820    let mut initial: i32 = state.s_count[start_line] + 1;
821    let mut offset: i32 = initial;
822    let second = state.src.get(pos).copied();
823    let mut adjust_tab = false;
824    let space_after_marker;
825    if second == Some(' ') {
826        pos += 1;
827        initial += 1;
828        offset += 1;
829        adjust_tab = false;
830        space_after_marker = true;
831    } else if second == Some('\t') {
832        space_after_marker = true;
833        if (state.bs_count[start_line] + offset) % 4 == 3 {
834            pos += 1;
835            initial += 1;
836            offset += 1;
837            adjust_tab = false;
838        } else {
839            adjust_tab = true;
840        }
841    } else {
842        space_after_marker = false;
843    }
844    let mut old_b_marks = vec![state.b_marks[start_line]];
845    state.b_marks[start_line] = pos;
846    while pos < max {
847        let ch = state.src[pos];
848        if is_str_space(ch) {
849            if ch == '\t' {
850                offset +=
851                    4 - (offset + state.bs_count[start_line] + if adjust_tab { 1 } else { 0 }) % 4;
852            } else {
853                offset += 1;
854            }
855        } else {
856            break;
857        }
858        pos += 1;
859    }
860    let mut old_bs_count = vec![state.bs_count[start_line]];
861    state.bs_count[start_line] =
862        state.s_count[start_line] + 1 + if space_after_marker { 1 } else { 0 };
863    let mut last_line_empty = pos >= max;
864    let mut old_s_count = vec![state.s_count[start_line]];
865    state.s_count[start_line] = offset - initial;
866    let mut old_t_shift = vec![state.t_shift[start_line]];
867    state.t_shift[start_line] = pos - state.b_marks[start_line];
868    let old_parent = state.parent_type;
869    state.parent_type = Parent::Blockquote;
870
871    let mut next_line = start_line + 1;
872    while next_line < end_line {
873        let is_outdented = state.s_count[next_line] < state.blk_indent;
874        pos = state.b_marks[next_line] + state.t_shift[next_line];
875        max = state.e_marks[next_line];
876        if pos >= max {
877            break;
878        }
879        let evaluates_true = state.src[pos] == '>' && !is_outdented;
880        pos += 1;
881        if evaluates_true {
882            let mut initial2: i32 = state.s_count[next_line] + 1;
883            let mut offset2: i32 = initial2;
884            let next_char = state.src.get(pos).copied();
885            let mut adjust_tab2 = false;
886            let space_after_marker2;
887            if next_char == Some(' ') {
888                pos += 1;
889                initial2 += 1;
890                offset2 += 1;
891                adjust_tab2 = false;
892                space_after_marker2 = true;
893            } else if next_char == Some('\t') {
894                space_after_marker2 = true;
895                if (state.bs_count[next_line] + offset2) % 4 == 3 {
896                    pos += 1;
897                    initial2 += 1;
898                    offset2 += 1;
899                    adjust_tab2 = false;
900                } else {
901                    adjust_tab2 = true;
902                }
903            } else {
904                space_after_marker2 = false;
905            }
906            old_b_marks.push(state.b_marks[next_line]);
907            state.b_marks[next_line] = pos;
908            while pos < max {
909                let ch = state.src[pos];
910                if is_str_space(ch) {
911                    if ch == '\t' {
912                        offset2 += 4
913                            - (offset2
914                                + state.bs_count[next_line]
915                                + if adjust_tab2 { 1 } else { 0 })
916                                % 4;
917                    } else {
918                        offset2 += 1;
919                    }
920                } else {
921                    break;
922                }
923                pos += 1;
924            }
925            last_line_empty = pos >= max;
926            old_bs_count.push(state.bs_count[next_line]);
927            state.bs_count[next_line] =
928                state.s_count[next_line] + 1 + if space_after_marker2 { 1 } else { 0 };
929            old_s_count.push(state.s_count[next_line]);
930            state.s_count[next_line] = offset2 - initial2;
931            old_t_shift.push(state.t_shift[next_line]);
932            state.t_shift[next_line] = pos - state.b_marks[next_line];
933            next_line += 1;
934            continue;
935        }
936        if last_line_empty {
937            break;
938        }
939        let mut terminate = false;
940        for rule in TERM_PARAGRAPH {
941            if rule(state, next_line, end_line, true) {
942                terminate = true;
943                break;
944            }
945        }
946        if terminate {
947            // hard termination mode for paragraphs
948            state.line_max = next_line;
949            if state.blk_indent != 0 {
950                old_b_marks.push(state.b_marks[next_line]);
951                old_bs_count.push(state.bs_count[next_line]);
952                old_t_shift.push(state.t_shift[next_line]);
953                old_s_count.push(state.s_count[next_line]);
954                state.s_count[next_line] -= state.blk_indent;
955            }
956            break;
957        }
958        old_b_marks.push(state.b_marks[next_line]);
959        old_bs_count.push(state.bs_count[next_line]);
960        old_t_shift.push(state.t_shift[next_line]);
961        old_s_count.push(state.s_count[next_line]);
962        // negative indentation: paragraph continuation
963        state.s_count[next_line] = -1;
964        next_line += 1;
965    }
966    let old_indent = state.blk_indent;
967    state.blk_indent = 0;
968
969    let open_idx = state.push("blockquote_open", "blockquote", 1);
970    state.tokens[open_idx].map = Some((start_line, 0));
971    tokenize(state, start_line, next_line);
972    state.push("blockquote_close", "blockquote", -1);
973
974    state.line_max = old_line_max;
975    state.parent_type = old_parent;
976    let end = state.line;
977    state.tokens[open_idx].map = Some((start_line, end));
978
979    for (i, &ts) in old_t_shift.iter().enumerate() {
980        state.b_marks[i + start_line] = old_b_marks[i];
981        state.t_shift[i + start_line] = ts;
982        state.s_count[i + start_line] = old_s_count[i];
983        state.bs_count[i + start_line] = old_bs_count[i];
984    }
985    state.blk_indent = old_indent;
986    true
987}
988
989fn skip_bullet_list_marker(state: &State, start_line: usize) -> Option<usize> {
990    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
991    let maximum = state.e_marks[start_line];
992    let marker = *state.src.get(pos)?;
993    pos += 1;
994    if marker != '*' && marker != '-' && marker != '+' {
995        return None;
996    }
997    if pos < maximum {
998        let ch = state.src[pos];
999        if !is_str_space(ch) {
1000            return None;
1001        }
1002    }
1003    Some(pos)
1004}
1005
1006fn skip_ordered_list_marker(state: &State, start_line: usize) -> Option<usize> {
1007    let start = state.b_marks[start_line] + state.t_shift[start_line];
1008    let mut pos = start;
1009    let maximum = state.e_marks[start_line];
1010    if pos + 1 >= maximum {
1011        return None;
1012    }
1013    let ch = state.src[pos];
1014    pos += 1;
1015    if !ch.is_ascii_digit() {
1016        return None;
1017    }
1018    loop {
1019        if pos >= maximum {
1020            return None;
1021        }
1022        let ch = state.src[pos];
1023        pos += 1;
1024        if ch.is_ascii_digit() {
1025            if pos - start >= 10 {
1026                return None;
1027            }
1028            continue;
1029        }
1030        if ch == ')' || ch == '.' {
1031            break;
1032        }
1033        return None;
1034    }
1035    if pos < maximum {
1036        let ch = state.src[pos];
1037        if !is_str_space(ch) {
1038            return None;
1039        }
1040    }
1041    Some(pos)
1042}
1043
1044fn rule_list(state: &mut State, start_line: usize, end_line: usize, silent: bool) -> bool {
1045    let mut is_terminating_paragraph = false;
1046    if state.is_code_block(start_line) {
1047        return false;
1048    }
1049    if state.list_indent >= 0
1050        && state.s_count[start_line] - state.list_indent >= 4
1051        && state.s_count[start_line] < state.blk_indent
1052    {
1053        return false;
1054    }
1055    if silent
1056        && state.parent_type == Parent::Paragraph
1057        && state.s_count[start_line] >= state.blk_indent
1058    {
1059        is_terminating_paragraph = true;
1060    }
1061    let is_ordered;
1062    let mut pos_after_marker;
1063    if let Some(p) = skip_ordered_list_marker(state, start_line) {
1064        is_ordered = true;
1065        pos_after_marker = p;
1066        let start = state.b_marks[start_line] + state.t_shift[start_line];
1067        let digits: String = state.src[start..pos_after_marker - 1].iter().collect();
1068        let marker_value: i64 = digits.parse().unwrap_or(0);
1069        if is_terminating_paragraph && marker_value != 1 {
1070            return false;
1071        }
1072    } else if let Some(p) = skip_bullet_list_marker(state, start_line) {
1073        is_ordered = false;
1074        pos_after_marker = p;
1075    } else {
1076        return false;
1077    }
1078    if is_terminating_paragraph && state.skip_spaces(pos_after_marker) >= state.e_marks[start_line]
1079    {
1080        return false;
1081    }
1082    let marker_char = state.src[pos_after_marker - 1];
1083    if silent {
1084        return true;
1085    }
1086
1087    let open_idx = state.push(
1088        if is_ordered {
1089            "ordered_list_open"
1090        } else {
1091            "bullet_list_open"
1092        },
1093        if is_ordered { "ol" } else { "ul" },
1094        1,
1095    );
1096    let list_start_line = start_line;
1097    state.tokens[open_idx].map = Some((list_start_line, 0));
1098
1099    let mut start_line = start_line;
1100    let mut next_line = start_line;
1101    let old_parent = state.parent_type;
1102    state.parent_type = Parent::List;
1103
1104    while next_line < end_line {
1105        let mut pos = pos_after_marker;
1106        let maximum = state.e_marks[next_line];
1107        let initial: i32 = state.s_count[next_line] + pos_after_marker as i32
1108            - (state.b_marks[start_line] as i32 + state.t_shift[start_line] as i32);
1109        let mut offset = initial;
1110        while pos < maximum {
1111            let ch = state.src[pos];
1112            if ch == '\t' {
1113                offset += 4 - (offset + state.bs_count[next_line]) % 4;
1114            } else if ch == ' ' {
1115                offset += 1;
1116            } else {
1117                break;
1118            }
1119            pos += 1;
1120        }
1121        let content_start = pos;
1122        let mut indent_after_marker: i32 = if content_start >= maximum {
1123            1
1124        } else {
1125            offset - initial
1126        };
1127        if indent_after_marker > 4 {
1128            indent_after_marker = 1;
1129        }
1130        let indent = initial + indent_after_marker;
1131
1132        let item_idx = state.push("list_item_open", "li", 1);
1133        state.tokens[item_idx].map = Some((start_line, 0));
1134
1135        let old_tight = state.tight;
1136        let old_t_shift = state.t_shift[start_line];
1137        let old_s_count = state.s_count[start_line];
1138        let old_list_indent = state.list_indent;
1139        state.list_indent = state.blk_indent;
1140        state.blk_indent = indent;
1141        state.tight = true;
1142        state.t_shift[start_line] = content_start - state.b_marks[start_line];
1143        state.s_count[start_line] = offset;
1144
1145        if content_start >= maximum && state.is_empty(start_line + 1) {
1146            // empty list item workaround
1147            state.line = (state.line + 2).min(end_line);
1148        } else {
1149            tokenize(state, start_line, end_line);
1150        }
1151
1152        // (the oracle tracks tight/prevEmptyEnd here; only used for the
1153        // hidden-paragraph marking, which the consumed surface never sees)
1154
1155        state.blk_indent = state.list_indent;
1156        state.list_indent = old_list_indent;
1157        state.t_shift[start_line] = old_t_shift;
1158        state.s_count[start_line] = old_s_count;
1159        state.tight = old_tight;
1160
1161        state.push("list_item_close", "li", -1);
1162
1163        next_line = state.line;
1164        start_line = state.line;
1165        if let Some(m) = state.tokens[item_idx].map {
1166            state.tokens[item_idx].map = Some((m.0, next_line));
1167        }
1168        if next_line >= end_line {
1169            break;
1170        }
1171        if state.s_count[next_line] < state.blk_indent {
1172            break;
1173        }
1174        if state.is_code_block(start_line) {
1175            break;
1176        }
1177        let mut terminate = false;
1178        for rule in TERM_LIST {
1179            if rule(state, next_line, end_line, true) {
1180                terminate = true;
1181                break;
1182            }
1183        }
1184        if terminate {
1185            break;
1186        }
1187        if is_ordered {
1188            match skip_ordered_list_marker(state, next_line) {
1189                Some(p) => pos_after_marker = p,
1190                None => break,
1191            }
1192        } else {
1193            match skip_bullet_list_marker(state, next_line) {
1194                Some(p) => pos_after_marker = p,
1195                None => break,
1196            }
1197        }
1198        if marker_char != state.src[pos_after_marker - 1] {
1199            break;
1200        }
1201    }
1202
1203    state.push(
1204        if is_ordered {
1205            "ordered_list_close"
1206        } else {
1207            "bullet_list_close"
1208        },
1209        if is_ordered { "ol" } else { "ul" },
1210        -1,
1211    );
1212    state.tokens[open_idx].map = Some((list_start_line, next_line));
1213    state.line = next_line;
1214    state.parent_type = old_parent;
1215    true
1216}
1217
1218// --- reference rule + link helpers -----------------------------------------
1219
1220struct DestResult {
1221    ok: bool,
1222    pos: usize,
1223    str_: String,
1224}
1225
1226fn parse_link_destination(s: &[char], mut pos: usize, maximum: usize) -> DestResult {
1227    let start = pos;
1228    let mut result = DestResult {
1229        ok: false,
1230        pos: 0,
1231        str_: String::new(),
1232    };
1233    if s.get(pos) == Some(&'<') {
1234        pos += 1;
1235        while pos < maximum {
1236            let code = s[pos];
1237            if code == '\n' || code == '<' {
1238                return result;
1239            }
1240            if code == '>' {
1241                result.pos = pos + 1;
1242                let inner: String = s[start + 1..pos].iter().collect();
1243                result.str_ = unescape_all(&inner);
1244                result.ok = true;
1245                return result;
1246            }
1247            if code == '\\' && pos + 1 < maximum {
1248                pos += 2;
1249                continue;
1250            }
1251            pos += 1;
1252        }
1253        return result;
1254    }
1255    let mut level: i32 = 0;
1256    while pos < maximum {
1257        let code = s[pos];
1258        if code == ' ' {
1259            break;
1260        }
1261        if (code as u32) < 0x20 || code == '\u{7f}' {
1262            break;
1263        }
1264        if code == '\\' && pos + 1 < maximum {
1265            if s[pos + 1] == ' ' {
1266                break;
1267            }
1268            pos += 2;
1269            continue;
1270        }
1271        if code == '(' {
1272            level += 1;
1273            if level > 32 {
1274                return result;
1275            }
1276        }
1277        if code == ')' {
1278            if level == 0 {
1279                break;
1280            }
1281            level -= 1;
1282        }
1283        pos += 1;
1284    }
1285    if start == pos {
1286        return result;
1287    }
1288    if level != 0 {
1289        return result;
1290    }
1291    let inner: String = s[start..pos].iter().collect();
1292    result.str_ = unescape_all(&inner);
1293    result.pos = pos;
1294    result.ok = true;
1295    result
1296}
1297
1298struct TitleResult {
1299    ok: bool,
1300    can_continue: bool,
1301    pos: usize,
1302    str_: String,
1303    marker: char,
1304}
1305
1306fn parse_link_title(
1307    s: &[char],
1308    start: usize,
1309    maximum: usize,
1310    prev_state: Option<TitleResult>,
1311) -> TitleResult {
1312    let mut pos = start;
1313    let mut start = start;
1314    let mut state = TitleResult {
1315        ok: false,
1316        can_continue: false,
1317        pos: 0,
1318        str_: String::new(),
1319        marker: '\0',
1320    };
1321    if let Some(prev) = prev_state {
1322        state.str_ = prev.str_;
1323        state.marker = prev.marker;
1324    } else {
1325        if pos >= maximum {
1326            return state;
1327        }
1328        let marker = s[pos];
1329        if marker != '"' && marker != '\'' && marker != '(' {
1330            return state;
1331        }
1332        start += 1;
1333        pos += 1;
1334        state.marker = if marker == '(' { ')' } else { marker };
1335    }
1336    while pos < maximum {
1337        let code = s[pos];
1338        if code == state.marker {
1339            state.pos = pos + 1;
1340            let inner: String = s[start..pos].iter().collect();
1341            state.str_.push_str(&unescape_all(&inner));
1342            state.ok = true;
1343            return state;
1344        } else if code == '(' && state.marker == ')' {
1345            return state;
1346        } else if code == '\\' && pos + 1 < maximum {
1347            pos += 1;
1348        }
1349        pos += 1;
1350    }
1351    state.can_continue = true;
1352    let inner: String = s[start..pos.min(s.len())].iter().collect();
1353    state.str_.push_str(&unescape_all(&inner));
1354    state
1355}
1356
1357const ENTITIES_JSON: &str = include_str!("../assets/spec/markdown-entities.json");
1358
1359fn entities() -> &'static HashMap<String, String> {
1360    static MAP: OnceLock<HashMap<String, String>> = OnceLock::new();
1361    MAP.get_or_init(|| serde_json::from_str(ENTITIES_JSON).expect("entities json parses"))
1362}
1363
1364fn is_valid_entity_code(c: u32) -> bool {
1365    if (0xD800..=0xDFFF).contains(&c) {
1366        return false;
1367    }
1368    if (0xFDD0..=0xFDEF).contains(&c) {
1369        return false;
1370    }
1371    if (c & 0xFFFF) == 0xFFFF || (c & 0xFFFF) == 0xFFFE {
1372        return false;
1373    }
1374    if c <= 0x08 {
1375        return false;
1376    }
1377    if c == 0x0B {
1378        return false;
1379    }
1380    if (0x0E..=0x1F).contains(&c) {
1381        return false;
1382    }
1383    if (0x7F..=0x9F).contains(&c) {
1384        return false;
1385    }
1386    c <= 0x10FFFF
1387}
1388
1389const MD_ESCAPABLE: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
1390
1391/// markdown-it `unescapeAll`: backslash escapes plus HTML entity references.
1392/// Entity names that do not resolve are left verbatim (the oracle's
1393/// `replaceEntityPattern` returns the original match), so only resolvable
1394/// (ASCII) names need handling.
1395fn unescape_all(s: &str) -> String {
1396    if !s.contains('\\') && !s.contains('&') {
1397        return s.to_string();
1398    }
1399    let chars: Vec<char> = s.chars().collect();
1400    let mut out = String::with_capacity(s.len());
1401    let mut i = 0usize;
1402    while i < chars.len() {
1403        let c = chars[i];
1404        if c == '\\' && i + 1 < chars.len() {
1405            let n = chars[i + 1];
1406            if n.is_ascii() && MD_ESCAPABLE.contains(n) {
1407                out.push(n);
1408                i += 2;
1409                continue;
1410            }
1411            out.push(c);
1412            i += 1;
1413            continue;
1414        }
1415        if c == '&' {
1416            // &([a-z#][a-z0-9]{1,31}); (IGNORECASE): head, maximal alnum run
1417            // (1..=31), then ';'.
1418            if let Some(&head) = chars.get(i + 1) {
1419                if head.is_ascii_alphabetic() || head == '#' {
1420                    let mut j = i + 2;
1421                    while j < chars.len() && chars[j].is_ascii_alphanumeric() {
1422                        j += 1;
1423                    }
1424                    let run = j - (i + 2);
1425                    if (1..=31).contains(&run) && chars.get(j) == Some(&';') {
1426                        let name: String = chars[i + 1..j].iter().collect();
1427                        if let Some(rep) = resolve_entity(&name) {
1428                            out.push_str(&rep);
1429                            i = j + 1;
1430                            continue;
1431                        }
1432                        // unresolved: keep the original text verbatim
1433                        out.extend(chars[i..=j].iter());
1434                        i = j + 1;
1435                        continue;
1436                    }
1437                }
1438            }
1439            out.push(c);
1440            i += 1;
1441            continue;
1442        }
1443        out.push(c);
1444        i += 1;
1445    }
1446    out
1447}
1448
1449fn resolve_entity(name: &str) -> Option<String> {
1450    if let Some(v) = entities().get(name) {
1451        return Some(v.clone());
1452    }
1453    let rest = name.strip_prefix('#')?;
1454    let code: Option<u32> = if let Some(hex) = rest.strip_prefix(['x', 'X']) {
1455        if (1..=8).contains(&hex.len()) && hex.chars().all(|c| c.is_ascii_hexdigit()) {
1456            u32::from_str_radix(hex, 16).ok()
1457        } else {
1458            None
1459        }
1460    } else if (1..=8).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_digit()) {
1461        rest.parse::<u32>().ok()
1462    } else {
1463        None
1464    };
1465    let code = code?;
1466    if is_valid_entity_code(code) {
1467        char::from_u32(code).map(|c| c.to_string())
1468    } else {
1469        None
1470    }
1471}
1472
1473/// markdown-it `validateLink` over the (identity-)normalized destination.
1474fn validate_link(url: &str) -> bool {
1475    let stripped = py_strip(url);
1476    let lower: String = stripped.chars().flat_map(|c| c.to_lowercase()).collect();
1477    if lower.starts_with("javascript:")
1478        || lower.starts_with("vbscript:")
1479        || lower.starts_with("file:")
1480    {
1481        return false;
1482    }
1483    if lower.starts_with("data:") {
1484        return [
1485            "data:image/gif;",
1486            "data:image/png;",
1487            "data:image/jpeg;",
1488            "data:image/webp;",
1489        ]
1490        .iter()
1491        .any(|p| lower.starts_with(p));
1492    }
1493    true
1494}
1495
1496fn get_next_line(state: &mut State, next_line: usize) -> Option<Vec<char>> {
1497    let end_line = state.line_max;
1498    if next_line >= end_line || state.is_empty(next_line) {
1499        return None;
1500    }
1501    let mut is_continuation = false;
1502    if state.is_code_block(next_line) {
1503        is_continuation = true;
1504    }
1505    if state.s_count[next_line] < 0 {
1506        is_continuation = true;
1507    }
1508    if !is_continuation {
1509        let old_parent = state.parent_type;
1510        state.parent_type = Parent::Reference;
1511        let mut terminate = false;
1512        for rule in TERM_PARAGRAPH {
1513            if rule(state, next_line, end_line, true) {
1514                terminate = true;
1515                break;
1516            }
1517        }
1518        state.parent_type = old_parent;
1519        if terminate {
1520            return None;
1521        }
1522    }
1523    let pos = state.b_marks[next_line] + state.t_shift[next_line];
1524    let maximum = state.e_marks[next_line];
1525    Some(state.src[pos..(maximum + 1).min(state.src.len())].to_vec())
1526}
1527
1528fn rule_reference(state: &mut State, start_line: usize, _end_line: usize, silent: bool) -> bool {
1529    let pos0 = state.b_marks[start_line] + state.t_shift[start_line];
1530    let maximum0 = state.e_marks[start_line];
1531    let mut next_line = start_line + 1;
1532    if state.is_code_block(start_line) {
1533        return false;
1534    }
1535    if state.src.get(pos0) != Some(&'[') {
1536        return false;
1537    }
1538    let mut string: Vec<char> = state.src[pos0..(maximum0 + 1).min(state.src.len())].to_vec();
1539    let mut maximum = string.len();
1540
1541    let mut label_end: Option<usize> = None;
1542    let mut pos = 1usize;
1543    while pos < maximum {
1544        let ch = string[pos];
1545        if ch == '[' {
1546            return false;
1547        } else if ch == ']' {
1548            label_end = Some(pos);
1549            break;
1550        } else if ch == '\n' {
1551            if let Some(cont) = get_next_line(state, next_line) {
1552                string.extend(cont);
1553                maximum = string.len();
1554                next_line += 1;
1555            }
1556        } else if ch == '\\' {
1557            pos += 1;
1558            if pos < maximum && string[pos] == '\n' {
1559                if let Some(cont) = get_next_line(state, next_line) {
1560                    string.extend(cont);
1561                    maximum = string.len();
1562                    next_line += 1;
1563                }
1564            }
1565        }
1566        pos += 1;
1567    }
1568    let label_end = match label_end {
1569        Some(le) => le,
1570        None => return false,
1571    };
1572    if string.get(label_end + 1) != Some(&':') {
1573        return false;
1574    }
1575
1576    // skip optional whitespace after the colon
1577    pos = label_end + 2;
1578    while pos < maximum {
1579        let ch = string[pos];
1580        if ch == '\n' {
1581            if let Some(cont) = get_next_line(state, next_line) {
1582                string.extend(cont);
1583                maximum = string.len();
1584                next_line += 1;
1585            }
1586        } else if is_str_space(ch) {
1587        } else {
1588            break;
1589        }
1590        pos += 1;
1591    }
1592
1593    let dest_res = parse_link_destination(&string, pos, maximum);
1594    if !dest_res.ok {
1595        return false;
1596    }
1597    // normalizeLink is approximated as identity for the validate step (its
1598    // percent-encoding cannot change the BAD_PROTO prefix decision).
1599    if !validate_link(&dest_res.str_) {
1600        return false;
1601    }
1602    pos = dest_res.pos;
1603
1604    let dest_end_pos = pos;
1605    let dest_end_line_no = next_line;
1606
1607    let start_pos = pos;
1608    while pos < maximum {
1609        let ch = string[pos];
1610        if ch == '\n' {
1611            if let Some(cont) = get_next_line(state, next_line) {
1612                string.extend(cont);
1613                maximum = string.len();
1614                next_line += 1;
1615            }
1616        } else if is_str_space(ch) {
1617        } else {
1618            break;
1619        }
1620        pos += 1;
1621    }
1622
1623    let mut title_res = parse_link_title(&string, pos, maximum, None);
1624    while title_res.can_continue {
1625        match get_next_line(state, next_line) {
1626            None => break,
1627            Some(cont) => {
1628                string.extend(cont);
1629                pos = maximum;
1630                maximum = string.len();
1631                next_line += 1;
1632                title_res = parse_link_title(&string, pos, maximum, Some(title_res));
1633            }
1634        }
1635    }
1636
1637    let mut title;
1638    if pos < maximum && start_pos != pos && title_res.ok {
1639        title = title_res.str_;
1640        pos = title_res.pos;
1641    } else {
1642        title = String::new();
1643        pos = dest_end_pos;
1644        next_line = dest_end_line_no;
1645    }
1646
1647    while pos < maximum {
1648        if !is_str_space(string[pos]) {
1649            break;
1650        }
1651        pos += 1;
1652    }
1653
1654    if pos < maximum && string[pos] != '\n' && !title.is_empty() {
1655        title = String::new();
1656        pos = dest_end_pos;
1657        next_line = dest_end_line_no;
1658        while pos < maximum {
1659            if !is_str_space(string[pos]) {
1660                break;
1661            }
1662            pos += 1;
1663        }
1664    }
1665    let _ = title;
1666
1667    if pos < maximum && string[pos] != '\n' {
1668        return false;
1669    }
1670
1671    let label: String = string[1..label_end].iter().collect();
1672    // normalizeReference(label) is empty iff the stripped label is empty
1673    if py_strip(&label).is_empty() {
1674        return false;
1675    }
1676
1677    if silent {
1678        return true;
1679    }
1680
1681    state.line = next_line;
1682    true
1683}
1684
1685// --- html_block rule --------------------------------------------------------
1686
1687const HTML_BLOCK_NAMES: &[&str] = &[
1688    "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center",
1689    "col", "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset",
1690    "figcaption", "figure", "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5",
1691    "h6", "head", "header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu",
1692    "menuitem", "nav", "noframes", "ol", "optgroup", "option", "p", "param", "search", "section",
1693    "summary", "table", "tbody", "td", "tfoot", "th", "thead", "title", "tr", "track", "ul",
1694];
1695
1696/// Case-insensitive char match against an ASCII-lowercase pattern char,
1697/// covering Python re.IGNORECASE's simple case folding (incl. U+017F and
1698/// U+212A folding to ASCII).
1699#[inline]
1700fn ci_eq(c: char, lower: char) -> bool {
1701    c.eq_ignore_ascii_case(&lower)
1702        || (lower == 's' && c == '\u{17f}')
1703        || (lower == 'k' && c == '\u{212a}')
1704}
1705
1706fn starts_with_ci(hay: &[char], pos: usize, needle: &str) -> bool {
1707    needle
1708        .chars()
1709        .enumerate()
1710        .all(|(i, l)| hay.get(pos + i).is_some_and(|&c| ci_eq(c, l)))
1711}
1712
1713fn contains_ci(hay: &[char], needle: &str) -> bool {
1714    if needle.is_empty() {
1715        return true;
1716    }
1717    (0..hay.len()).any(|i| starts_with_ci(hay, i, needle))
1718}
1719
1720fn contains_exact(hay: &[char], needle: &str) -> bool {
1721    let n: Vec<char> = needle.chars().collect();
1722    if n.is_empty() {
1723        return true;
1724    }
1725    if hay.len() < n.len() {
1726        return false;
1727    }
1728    (0..=hay.len() - n.len()).any(|i| hay[i..i + n.len()] == n[..])
1729}
1730
1731#[inline]
1732fn unquoted_value_char(c: char) -> bool {
1733    (c as u32) > 0x20 && !matches!(c, '"' | '\'' | '=' | '<' | '>' | '`')
1734}
1735
1736/// `^(open_tag|close_tag)\s*$` (HTML_OPEN_CLOSE_TAG_STR, no IGNORECASE).
1737fn match_open_close_tag_line(line: &[char]) -> bool {
1738    let n = line.len();
1739    if line.first() != Some(&'<') {
1740        return false;
1741    }
1742    let mut p = 1usize;
1743    let closing = line.get(1) == Some(&'/');
1744    if closing {
1745        p = 2;
1746    }
1747    if !line.get(p).is_some_and(|c| c.is_ascii_alphabetic()) {
1748        return false;
1749    }
1750    p += 1;
1751    while line
1752        .get(p)
1753        .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '-')
1754    {
1755        p += 1;
1756    }
1757    if closing {
1758        while line.get(p).is_some_and(|&c| py_is_space(c)) {
1759            p += 1;
1760        }
1761        if line.get(p) != Some(&'>') {
1762            return false;
1763        }
1764        p += 1;
1765    } else {
1766        // (attribute)*
1767        loop {
1768            let save = p;
1769            let mut q = p;
1770            while line.get(q).is_some_and(|&c| py_is_space(c)) {
1771                q += 1;
1772            }
1773            if q == p {
1774                break;
1775            }
1776            if !line
1777                .get(q)
1778                .is_some_and(|c| c.is_ascii_alphabetic() || *c == '_' || *c == ':')
1779            {
1780                p = save;
1781                break;
1782            }
1783            q += 1;
1784            while line
1785                .get(q)
1786                .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(*c, ':' | '.' | '_' | '-'))
1787            {
1788                q += 1;
1789            }
1790            // optional \s*=\s*attr_value
1791            let save2 = q;
1792            let mut r = q;
1793            while line.get(r).is_some_and(|&c| py_is_space(c)) {
1794                r += 1;
1795            }
1796            if line.get(r) == Some(&'=') {
1797                r += 1;
1798                while line.get(r).is_some_and(|&c| py_is_space(c)) {
1799                    r += 1;
1800                }
1801                match line.get(r) {
1802                    Some(&'\'') => {
1803                        r += 1;
1804                        while line.get(r).is_some_and(|&c| c != '\'') {
1805                            r += 1;
1806                        }
1807                        if line.get(r) == Some(&'\'') {
1808                            q = r + 1;
1809                        } else {
1810                            q = save2;
1811                        }
1812                    }
1813                    Some(&'"') => {
1814                        r += 1;
1815                        while line.get(r).is_some_and(|&c| c != '"') {
1816                            r += 1;
1817                        }
1818                        if line.get(r) == Some(&'"') {
1819                            q = r + 1;
1820                        } else {
1821                            q = save2;
1822                        }
1823                    }
1824                    Some(&c) if unquoted_value_char(c) => {
1825                        r += 1;
1826                        while line.get(r).is_some_and(|&cc| unquoted_value_char(cc)) {
1827                            r += 1;
1828                        }
1829                        q = r;
1830                    }
1831                    _ => {
1832                        q = save2;
1833                    }
1834                }
1835            } else {
1836                q = save2;
1837            }
1838            p = q;
1839        }
1840        while line.get(p).is_some_and(|&c| py_is_space(c)) {
1841            p += 1;
1842        }
1843        if line.get(p) == Some(&'/') {
1844            p += 1;
1845        }
1846        if line.get(p) != Some(&'>') {
1847            return false;
1848        }
1849        p += 1;
1850    }
1851    while p < n {
1852        if !py_is_space(line[p]) {
1853            return false;
1854        }
1855        p += 1;
1856    }
1857    true
1858}
1859
1860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1861enum HtmlSeq {
1862    Raw,     // script|pre|style|textarea ... matching close tag
1863    Comment, // <!-- ... -->
1864    Pi,      // <? ... ?>
1865    Decl,    // <![A-Z] ... >
1866    Cdata,   // <![CDATA[ ... ]]>
1867    Block,   // </?blockname(\s|/?>|$) ... blank line
1868    AnyTag,  // full open/close tag alone on the line ... blank line (no terminate)
1869}
1870
1871fn starts_with_exact(hay: &[char], pos: usize, needle: &str) -> bool {
1872    needle
1873        .chars()
1874        .enumerate()
1875        .all(|(i, l)| hay.get(pos + i) == Some(&l))
1876}
1877
1878fn html_seq_open(line: &[char]) -> Option<(HtmlSeq, bool)> {
1879    if line.first() != Some(&'<') {
1880        return None;
1881    }
1882    // 1. <(script|pre|style|textarea)(?=(\s|>|$)) IGNORECASE
1883    for name in ["script", "pre", "style", "textarea"] {
1884        if starts_with_ci(line, 1, name) {
1885            let after = 1 + name.len();
1886            match line.get(after) {
1887                None => return Some((HtmlSeq::Raw, true)),
1888                Some(&c) if py_is_space(c) || c == '>' => return Some((HtmlSeq::Raw, true)),
1889                _ => {}
1890            }
1891        }
1892    }
1893    // 2. <!--
1894    if line.len() >= 4 && line[1] == '!' && line[2] == '-' && line[3] == '-' {
1895        return Some((HtmlSeq::Comment, true));
1896    }
1897    // 3. <?
1898    if line.get(1) == Some(&'?') {
1899        return Some((HtmlSeq::Pi, true));
1900    }
1901    // 4. <![A-Z] — note <![CDATA[ also matches this earlier pattern, so the
1902    // decl close condition (`>`) wins for CDATA too, exactly like the oracle
1903    // (Python checks the HTML_SEQUENCES list in order and takes the first).
1904    if line.get(1) == Some(&'!') && line.get(2).is_some_and(|c| c.is_ascii_uppercase()) {
1905        return Some((HtmlSeq::Decl, true));
1906    }
1907    // 5. <![CDATA[ (exact case; unreachable after 4, kept for fidelity)
1908    if starts_with_exact(line, 0, "<![CDATA[") {
1909        return Some((HtmlSeq::Cdata, true));
1910    }
1911    // 6. </?(block names)(?=(\s|/?>|$)) IGNORECASE
1912    {
1913        let mut p = 1usize;
1914        if line.get(1) == Some(&'/') {
1915            p = 2;
1916        }
1917        for name in HTML_BLOCK_NAMES {
1918            if starts_with_ci(line, p, name) {
1919                let after = p + name.len();
1920                let ok = match line.get(after) {
1921                    None => true,
1922                    Some(&c) if py_is_space(c) || c == '>' => true,
1923                    Some(&'/') => line.get(after + 1) == Some(&'>'),
1924                    _ => false,
1925                };
1926                if ok {
1927                    return Some((HtmlSeq::Block, true));
1928                }
1929            }
1930        }
1931    }
1932    // 7. full open/close tag to end of line (cannot terminate a paragraph)
1933    if match_open_close_tag_line(line) {
1934        return Some((HtmlSeq::AnyTag, false));
1935    }
1936    None
1937}
1938
1939fn html_seq_close(seq: HtmlSeq, line: &[char]) -> bool {
1940    match seq {
1941        HtmlSeq::Raw => ["</script>", "</pre>", "</style>", "</textarea>"]
1942            .iter()
1943            .any(|c| contains_ci(line, c)),
1944        HtmlSeq::Comment => contains_exact(line, "-->"),
1945        HtmlSeq::Pi => contains_exact(line, "?>"),
1946        HtmlSeq::Decl => contains_exact(line, ">"),
1947        HtmlSeq::Cdata => contains_exact(line, "]]>"),
1948        HtmlSeq::Block | HtmlSeq::AnyTag => line.is_empty(), // ^$
1949    }
1950}
1951
1952fn rule_html_block(state: &mut State, start_line: usize, end_line: usize, silent: bool) -> bool {
1953    let mut pos = state.b_marks[start_line] + state.t_shift[start_line];
1954    let mut maximum = state.e_marks[start_line];
1955    if state.is_code_block(start_line) {
1956        return false;
1957    }
1958    if state.src.get(pos) != Some(&'<') {
1959        return false;
1960    }
1961    let mut line_text: Vec<char> = state.src[pos..maximum].to_vec();
1962    let (seq, terminator) = match html_seq_open(&line_text) {
1963        Some(x) => x,
1964        None => return false,
1965    };
1966    if silent {
1967        return terminator;
1968    }
1969    let mut next_line = start_line + 1;
1970    if !html_seq_close(seq, &line_text) {
1971        while next_line < end_line {
1972            if state.s_count[next_line] < state.blk_indent {
1973                break;
1974            }
1975            pos = state.b_marks[next_line] + state.t_shift[next_line];
1976            maximum = state.e_marks[next_line];
1977            line_text = state.src[pos.min(maximum)..maximum].to_vec();
1978            if html_seq_close(seq, &line_text) {
1979                if !line_text.is_empty() {
1980                    next_line += 1;
1981                }
1982                break;
1983            }
1984            next_line += 1;
1985        }
1986    }
1987    state.line = next_line;
1988    let i = state.push("html_block", "", 0);
1989    state.tokens[i].map = Some((start_line, next_line));
1990    state.tokens[i].content = state.get_lines(start_line, next_line, state.blk_indent, true);
1991    true
1992}
1993
1994// ---------------------------------------------------------------------------
1995// Tokenizer driver
1996// ---------------------------------------------------------------------------
1997
1998/// rules_core normalize: `\r\n?|\n` -> `\n`, then NUL -> U+FFFD.
1999fn normalize_src(s: &str) -> String {
2000    let mut out = String::with_capacity(s.len());
2001    let mut it = s.chars().peekable();
2002    while let Some(c) = it.next() {
2003        match c {
2004            '\r' => {
2005                if it.peek() == Some(&'\n') {
2006                    it.next();
2007                }
2008                out.push('\n');
2009            }
2010            '\0' => out.push('\u{fffd}'),
2011            c => out.push(c),
2012        }
2013    }
2014    out
2015}
2016
2017/// Tokenize a (frontmatter-stripped) Markdown body into the flat block-level
2018/// token stream, exactly as markdown-it-py 4.2.0 "commonmark" emits it.
2019pub fn tokenize_blocks(body: &str) -> Vec<Token> {
2020    let normalized = normalize_src(body);
2021    if normalized.is_empty() {
2022        return Vec::new();
2023    }
2024    let src: Vec<char> = normalized.chars().collect();
2025    let mut state = State::new(&src);
2026    let line_max = state.line_max;
2027    tokenize(&mut state, 0, line_max);
2028    state.tokens
2029}
2030
2031/// One consumed-surface event: a heading (tag + raw inline text) or a body
2032/// inline (raw content), each with the block's 0-based start line.
2033#[derive(Debug, Clone, PartialEq, Eq)]
2034pub struct BlockEvent {
2035    pub heading: bool,
2036    pub tag: &'static str,
2037    pub line: i64, // map[0]; -1 when the token had no map (never in practice)
2038    pub content: String,
2039}
2040
2041/// The exact token surface the oracle walk consumes (contract section 1).
2042pub fn consumed_events(body: &str) -> Vec<BlockEvent> {
2043    let tokens = tokenize_blocks(body);
2044    let mut events = Vec::new();
2045    for i in 0..tokens.len() {
2046        let t = &tokens[i];
2047        if t.typ == "heading_open" {
2048            let content = if i + 1 < tokens.len() {
2049                tokens[i + 1].content.clone()
2050            } else {
2051                String::new()
2052            };
2053            events.push(BlockEvent {
2054                heading: true,
2055                tag: t.tag,
2056                line: t.map.map_or(-1, |m| m.0 as i64),
2057                content,
2058            });
2059        } else if t.typ == "inline" && !(i > 0 && tokens[i - 1].typ == "heading_open") {
2060            events.push(BlockEvent {
2061                heading: false,
2062                tag: "",
2063                line: t.map.map_or(-1, |m| m.0 as i64),
2064                content: t.content.clone(),
2065            });
2066        }
2067    }
2068    events
2069}
2070
2071// ---------------------------------------------------------------------------
2072// The walk (decided.core.markdown._WalkState)
2073// ---------------------------------------------------------------------------
2074
2075#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2076enum Sect {
2077    None,
2078    Other,
2079    Problem,
2080    Requirements,
2081    SuccessMetrics,
2082    Risks,
2083}
2084
2085struct Walk {
2086    offset: i64,
2087    title: Option<String>,
2088    extra_title_lines: Vec<i64>,
2089    section: Sect,
2090    current_h2: Option<String>,
2091    search_sections: Vec<SearchSection>,
2092    current_search: Option<usize>,
2093    problem_lines: Vec<String>,
2094    requirement_lines: Vec<(String, i64)>,
2095    metric_lines: Vec<String>,
2096    risk_lines: Vec<String>,
2097    section_order: Vec<String>,
2098    section_bodies: HashMap<String, Vec<String>>,
2099    section_chars: HashMap<String, usize>,
2100    captured_lines: usize,
2101    truncated_fields: BTreeSet<String>,
2102    body_truncated: bool,
2103    has_problem: bool,
2104    has_requirements: bool,
2105    has_metrics: bool,
2106    has_risks: bool,
2107}
2108
2109impl Walk {
2110    fn new(offset: i64) -> Self {
2111        Walk {
2112            offset,
2113            title: None,
2114            extra_title_lines: Vec::new(),
2115            section: Sect::None,
2116            current_h2: None,
2117            search_sections: Vec::new(),
2118            current_search: None,
2119            problem_lines: Vec::new(),
2120            requirement_lines: Vec::new(),
2121            metric_lines: Vec::new(),
2122            risk_lines: Vec::new(),
2123            section_order: Vec::new(),
2124            section_bodies: HashMap::new(),
2125            section_chars: HashMap::new(),
2126            captured_lines: 0,
2127            truncated_fields: BTreeSet::new(),
2128            body_truncated: false,
2129            has_problem: false,
2130            has_requirements: false,
2131            has_metrics: false,
2132            has_risks: false,
2133        }
2134    }
2135
2136    fn open_heading(&mut self, tag: &str, map: Option<(usize, usize)>, heading_text: &str) {
2137        if tag == "h1" {
2138            if self.title.is_none() {
2139                self.title = Some(py_strip(heading_text).to_string());
2140            } else {
2141                self.extra_title_lines
2142                    .push(map.map_or(0, |m| m.0 as i64 + 1 + self.offset));
2143            }
2144            self.section = Sect::None;
2145            self.current_h2 = None;
2146            self.current_search = None;
2147        } else if tag == "h2" {
2148            let normalized = py_casefold(py_strip(heading_text));
2149            self.current_h2 = Some(normalized.clone());
2150            if !self.section_bodies.contains_key(&normalized) {
2151                self.section_bodies.insert(normalized.clone(), Vec::new());
2152                self.section_order.push(normalized.clone());
2153            }
2154            self.search_sections.push(SearchSection {
2155                heading: py_strip(heading_text).to_string(),
2156                lines: Vec::new(),
2157            });
2158            self.current_search = Some(self.search_sections.len() - 1);
2159            self.section = match normalized.as_str() {
2160                "problem" => {
2161                    self.has_problem = true;
2162                    Sect::Problem
2163                }
2164                "requirements" => {
2165                    self.has_requirements = true;
2166                    Sect::Requirements
2167                }
2168                "success metrics" => {
2169                    self.has_metrics = true;
2170                    Sect::SuccessMetrics
2171                }
2172                "risks" => {
2173                    self.has_risks = true;
2174                    Sect::Risks
2175                }
2176                _ => Sect::None,
2177            };
2178        } else {
2179            self.section = Sect::Other;
2180        }
2181    }
2182
2183    fn capture_inline(&mut self, content: &str, map: Option<(usize, usize)>) {
2184        if self.body_truncated {
2185            return;
2186        }
2187        if let Some(h2) = self.current_h2.clone() {
2188            self.capture_generic_body(content, &h2);
2189        }
2190        if self.body_truncated || matches!(self.section, Sect::None | Sect::Other) {
2191            return;
2192        }
2193        self.capture_field(content, map);
2194    }
2195
2196    fn capture_generic_body(&mut self, content: &str, heading: &str) {
2197        for raw in content.split('\n') {
2198            let stripped = py_strip(raw);
2199            if stripped.is_empty() {
2200                continue;
2201            }
2202            if self.captured_lines >= MAX_CAPTURED_LINES {
2203                self.body_truncated = true;
2204                break;
2205            }
2206            let n_chars = stripped.chars().count();
2207            let used = self.section_chars.get(heading).copied().unwrap_or(0);
2208            if used + n_chars > MAX_FIELD_CHARS {
2209                self.truncated_fields.insert(heading.to_string());
2210                continue;
2211            }
2212            if !self.section_bodies.contains_key(heading) {
2213                self.section_bodies.insert(heading.to_string(), Vec::new());
2214                self.section_order.push(heading.to_string());
2215            }
2216            self.section_bodies
2217                .get_mut(heading)
2218                .expect("just ensured")
2219                .push(stripped.to_string());
2220            self.section_chars
2221                .insert(heading.to_string(), used + n_chars + 1);
2222            self.captured_lines += 1;
2223            if let Some(cs) = self.current_search {
2224                self.search_sections[cs].lines.push(stripped.to_string());
2225            }
2226        }
2227    }
2228
2229    fn capture_field(&mut self, content: &str, map: Option<(usize, usize)>) {
2230        let start_line = map.map_or(0, |m| m.0 as i64 + self.offset);
2231        let mut lines: Vec<(String, i64)> = Vec::new();
2232        for (offset, raw) in content.split('\n').enumerate() {
2233            let stripped = py_strip(raw);
2234            if !stripped.is_empty() {
2235                lines.push((stripped.to_string(), start_line + offset as i64 + 1));
2236            }
2237        }
2238        match self.section {
2239            Sect::Problem => self
2240                .problem_lines
2241                .extend(lines.into_iter().map(|(t, _)| t)),
2242            Sect::Requirements => self.requirement_lines.extend(lines),
2243            Sect::SuccessMetrics => self.metric_lines.extend(lines.into_iter().map(|(t, _)| t)),
2244            Sect::Risks => self.risk_lines.extend(lines.into_iter().map(|(t, _)| t)),
2245            _ => {}
2246        }
2247    }
2248}
2249
2250fn classify_requirement_line(text: &str, line: i64) -> Result<Requirement, MalformedRequirement> {
2251    // _BRACKET_RE = ^\[(?P<id>[^\]]*)\]\s*(?P<text>.*)$
2252    let malformed_no_id = || MalformedRequirement {
2253        raw: text.to_string(),
2254        line,
2255        bad_id: None,
2256        empty_text: false,
2257    };
2258    if !text.starts_with('[') {
2259        return Err(malformed_no_id());
2260    }
2261    let close = match text.find(']') {
2262        Some(i) => i,
2263        None => return Err(malformed_no_id()),
2264    };
2265    let id_group = &text[1..close];
2266    let mut rest = &text[close + 1..];
2267    // \s* — Python re \s == str.isspace set (verified empirically)
2268    rest = rest.trim_start_matches(py_is_space);
2269    let req_id = py_strip(id_group);
2270    let desc = py_strip(rest);
2271    let canonical = req_id
2272        .strip_prefix("REQ-")
2273        .is_some_and(|d| !d.is_empty() && d.chars().all(is_re_digit));
2274    if !canonical {
2275        return Err(MalformedRequirement {
2276            raw: text.to_string(),
2277            line,
2278            bad_id: Some(req_id.to_string()),
2279            empty_text: false,
2280        });
2281    }
2282    if desc.is_empty() {
2283        return Err(MalformedRequirement {
2284            raw: text.to_string(),
2285            line,
2286            bad_id: Some(req_id.to_string()),
2287            empty_text: true,
2288        });
2289    }
2290    Ok(Requirement {
2291        id: req_id.to_string(),
2292        text: desc.to_string(),
2293        line,
2294    })
2295}
2296
2297fn budget_issues(walk: &Walk) -> Vec<Issue> {
2298    let mut issues = Vec::new();
2299    for heading in &walk.truncated_fields {
2300        issues.push(Issue {
2301            severity: "warning",
2302            code: "field-truncated",
2303            message: format!(
2304                "section {} exceeds the {}-char field cap and was truncated",
2305                py_repr_str(heading),
2306                MAX_FIELD_CHARS
2307            ),
2308            line: None,
2309        });
2310    }
2311    if walk.body_truncated {
2312        issues.push(Issue {
2313            severity: "warning",
2314            code: "body-truncated",
2315            message: format!(
2316                "document body exceeds the {}-line capture cap and was truncated",
2317                MAX_CAPTURED_LINES
2318            ),
2319            line: None,
2320        });
2321    }
2322    issues
2323}
2324
2325fn degraded_product(source_path: &str, issues: Vec<Issue>) -> Product {
2326    Product {
2327        source_path: source_path.to_string(),
2328        parse_issues: issues,
2329        ..Default::default()
2330    }
2331}
2332
2333fn oversize_issue(cap: u128, kind: &str) -> Issue {
2334    Issue {
2335        severity: "error",
2336        code: "artifact-oversize",
2337        message: format!(
2338            "artifact exceeds the {cap}-byte {kind} cap (set DECIDED_MAX_FILE_BYTES to raise it)"
2339        ),
2340        line: Some(1),
2341    }
2342}
2343
2344// ---------------------------------------------------------------------------
2345// parse / parse_file envelopes
2346// ---------------------------------------------------------------------------
2347
2348/// Parse Markdown `text` into a `Product` (decided.core.markdown.parse), with the
2349/// byte cap taken from the environment.
2350pub fn parse(text: &str, source_path: &str) -> Product {
2351    parse_with_cap(text, source_path, max_file_bytes())
2352}
2353
2354/// `parse` with an explicit byte cap (testing seam; the oracle reads the env).
2355pub fn parse_with_cap(text: &str, source_path: &str, cap: u128) -> Product {
2356    if exceeds_byte_cap(text, cap) {
2357        return degraded_product(source_path, vec![oversize_issue(cap, "parse")]);
2358    }
2359    let split = split_frontmatter(text);
2360    let mut metadata_issues = Vec::new();
2361    if split.raw.is_none() && split.unterminated {
2362        metadata_issues.push(Issue {
2363            severity: "error",
2364            code: "malformed-frontmatter",
2365            message: "frontmatter block opened with --- on line 1 but never closed".to_string(),
2366            line: Some(1),
2367        });
2368    }
2369
2370    let tokens = tokenize_blocks(&split.body);
2371    let mut walk = Walk::new(split.line_offset as i64);
2372    for i in 0..tokens.len() {
2373        let t = &tokens[i];
2374        if t.typ == "heading_open" {
2375            let heading_text = if i + 1 < tokens.len() {
2376                tokens[i + 1].content.clone()
2377            } else {
2378                String::new()
2379            };
2380            walk.open_heading(t.tag, t.map, &heading_text);
2381        } else if t.typ == "inline" && !(i > 0 && tokens[i - 1].typ == "heading_open") {
2382            walk.capture_inline(&t.content, t.map);
2383        }
2384    }
2385
2386    let mut requirements = Vec::new();
2387    let mut malformed = Vec::new();
2388    for (line_text, line_no) in &walk.requirement_lines {
2389        match classify_requirement_line(line_text, *line_no) {
2390            Ok(r) => requirements.push(r),
2391            Err(m) => malformed.push(m),
2392        }
2393    }
2394    let problem = if walk.has_problem {
2395        Some(py_strip(&walk.problem_lines.join("\n")).to_string())
2396    } else {
2397        None
2398    };
2399    let sections: Vec<(String, String)> = walk
2400        .section_order
2401        .iter()
2402        .map(|h| (h.clone(), walk.section_bodies[h].join("\n")))
2403        .collect();
2404    let parse_issues = budget_issues(&walk);
2405
2406    Product {
2407        title: walk.title,
2408        extra_title_lines: walk.extra_title_lines,
2409        problem,
2410        requirements,
2411        malformed_requirements: malformed,
2412        success_metrics: walk.metric_lines,
2413        risks: walk.risk_lines,
2414        sections,
2415        search_sections: walk.search_sections,
2416        has_problem_section: walk.has_problem,
2417        has_requirements_section: walk.has_requirements,
2418        has_metrics_section: walk.has_metrics,
2419        has_risks_section: walk.has_risks,
2420        source_path: source_path.to_string(),
2421        frontmatter_raw: split.raw,
2422        metadata_issues,
2423        parse_issues,
2424    }
2425}
2426
2427/// Linux `strerror(errno)` text, recovered from std's io::Error display
2428/// (`"<strerror> (os error <n>)"`).
2429fn strerror(errno: i32) -> String {
2430    let s = std::io::Error::from_raw_os_error(errno).to_string();
2431    match s.rfind(" (os error ") {
2432        Some(i) => s[..i].to_string(),
2433        None => s,
2434    }
2435}
2436
2437/// Python `str(OSError)` with a filename: `[Errno N] <strerror>: '<path>'`.
2438fn os_error_message(err: &std::io::Error, path: &str) -> String {
2439    match err.raw_os_error() {
2440        Some(n) => format!("[Errno {}] {}: {}", n, strerror(n), py_repr_str(path)),
2441        None => err.to_string(),
2442    }
2443}
2444
2445fn unreadable_issue(err: &std::io::Error, path: &str) -> Issue {
2446    Issue {
2447        severity: "error",
2448        code: "unreadable-artifact",
2449        message: format!("cannot read artifact: {}", os_error_message(err, path)),
2450        line: Some(1),
2451    }
2452}
2453
2454/// Read `path` and parse it (decided.core.markdown.parse_file).
2455pub fn parse_file(path: &str) -> Product {
2456    parse_file_with_cap(path, max_file_bytes())
2457}
2458
2459/// `parse_file` with an explicit byte cap (testing seam).
2460pub fn parse_file_with_cap(path: &str, cap: u128) -> Product {
2461    let size = match std::fs::metadata(path) {
2462        Ok(m) => m.len(),
2463        Err(e) => return degraded_product(path, vec![unreadable_issue(&e, path)]),
2464    };
2465    if size as u128 > cap {
2466        return degraded_product(path, vec![oversize_issue(cap, "file")]);
2467    }
2468    let file = match std::fs::File::open(path) {
2469        Ok(f) => f,
2470        Err(e) => return degraded_product(path, vec![unreadable_issue(&e, path)]),
2471    };
2472    let mut data = Vec::new();
2473    let take_n: u64 = cap.saturating_add(1).min(u64::MAX as u128) as u64;
2474    if let Err(e) = file.take(take_n).read_to_end(&mut data) {
2475        return degraded_product(path, vec![unreadable_issue(&e, path)]);
2476    }
2477    if data.len() as u128 > cap {
2478        return degraded_product(path, vec![oversize_issue(cap, "file")]);
2479    }
2480    match String::from_utf8(data) {
2481        Ok(text) => parse_with_cap(&text, path, cap),
2482        Err(e) => {
2483            let text = String::from_utf8_lossy(e.as_bytes()).into_owned();
2484            let mut product = parse_with_cap(&text, path, cap);
2485            product.parse_issues.push(Issue {
2486                severity: "warning",
2487                code: "non-utf8-content",
2488                message: "artifact is not valid UTF-8; decoded lossily".to_string(),
2489                line: Some(1),
2490            });
2491            product
2492        }
2493    }
2494}