//! DMS parser — full spec v0.14.

// Re-exported so downstream code can build meta tables without its own
// `indexmap` dependency.
pub use indexmap::IndexMap;

#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    Bool(bool),
    Integer(i64),
    Float(f64),
    String(String),
    OffsetDateTime(String),
    LocalDateTime(String),
    LocalDate(String),
    LocalTime(String),
    Table(IndexMap<String, Value>),
    List(Vec<Value>),
}

#[derive(Debug, Clone)]
pub struct ParseError {
    pub line: usize,
    pub column: usize,
    pub message: String,
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}:{}: {}", self.line, self.column, self.message)
    }
}

impl std::error::Error for ParseError {}

/// A parsed DMS document — body value plus optional front matter table.
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
    /// Front matter table (user metadata only; reserved `_dms_*` keys are
    /// consumed by the parser and do NOT appear here). `None` if the
    /// source had no `+++` block.
    pub meta: Option<IndexMap<String, Value>>,
    /// The body — the rest of the document, parsed per the spec.
    pub body: Value,
    /// Comments captured during parsing, in source order, each tagged with
    /// its attachment (path + position). Empty when the source had no
    /// comments.
    ///
    /// Path-prefix convention: body-attached comments use bare paths
    /// (rooted at the body value); front-matter-attached comments use a
    /// path that begins with the special segment
    /// `BreadcrumbSegment::Key("__fm__")` and continues with the path
    /// inside the front-matter table. (We chose a single flat list with
    /// a sentinel prefix — rather than a separate `meta_comments` field —
    /// for simpler downstream iteration: a re-emitter that walks the
    /// list once handles both contexts uniformly.)
    pub comments: Vec<AttachedComment>,
    /// Sparse `(path, OriginalLiteral)` entries — only nodes whose
    /// surface form differs from the default (decimal-no-underscores
    /// for ints, basic-quoted for strings) get an entry. Consulted by
    /// `to_dms` to re-emit values in their parsed form. The
    /// conformance JSON encoder ignores this field. Same `__fm__`
    /// path-prefix convention as `comments`.
    pub original_forms: Vec<(Vec<BreadcrumbSegment>, OriginalLiteral)>,
}

/// Whether a comment was a `#`/`//` line comment or a `### ###` / `/* */`
/// block comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommentKind {
    Line,
    Block,
}

/// How a comment attaches to its target node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommentPosition {
    /// Comment(s) on lines preceding a sibling node (no blank line gap).
    Leading,
    /// Comment on the same line as a value, after the value.
    Trailing,
    /// Comment that did not attach to a sibling — separated by a blank
    /// line, or left over at block close. Attaches to the enclosing
    /// container.
    Floating,
}

/// One captured comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Comment {
    /// Raw text of the comment, **including delimiters**:
    /// `# foo` / `// foo` / `/* foo */` / `### LABEL\n...\nLABEL`.
    pub content: String,
    pub kind: CommentKind,
}

/// One step in a comment-attachment breadcrumb path.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BreadcrumbSegment {
    Key(String),
    Index(usize),
}

/// A comment plus its attachment metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachedComment {
    pub comment: Comment,
    pub position: CommentPosition,
    /// Path to the node the comment is attached to. Empty `[]` means
    /// "the document root" (or the front-matter table root, given the
    /// `__fm__` prefix convention).
    pub path: Vec<BreadcrumbSegment>,
}

/// Per-node literal-form record, captured during parsing so that
/// `to_dms` can re-emit the value in the same source form (integer
/// base, string flavor, heredoc modifiers). Keyed off the node's path
/// in `Document::original_forms`. Spec §to_dms.
#[derive(Debug, Clone, PartialEq)]
pub enum OriginalLiteral {
    /// An integer literal, stored as its exact source lexeme:
    /// `"0x1F40"`, `"0o755"`, `"0b1010_0110"`, `"1_000_000"`,
    /// `"+42"`, `"-7"`, etc. Re-emitted verbatim.
    Integer { lit: String },
    /// A string value, stored with its original surface form (basic /
    /// literal / heredoc-basic / heredoc-literal, plus heredoc label
    /// and modifier list).
    String { form: StringForm },
}

#[derive(Debug, Clone, PartialEq)]
pub enum StringForm {
    /// `"..."`
    Basic,
    /// `'...'`
    Literal,
    /// `"""[LABEL]...LABEL` or `'''[LABEL]...LABEL` (or unlabeled
    /// `"""..."""` / `'''...'''`).
    Heredoc {
        flavor: HeredocFlavor,
        /// `None` means unlabeled (terminator is `"""` / `'''`).
        label: Option<String>,
        /// Modifier calls in source order: `_trim(...)`,
        /// `_fold_paragraphs()`, etc. Re-emitted on the opener line.
        modifiers: Vec<HeredocModifierCall>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeredocFlavor {
    BasicTriple,
    LiteralTriple,
}

#[derive(Debug, Clone, PartialEq)]
pub struct HeredocModifierCall {
    pub name: String,
    /// Pre-evaluated args (Strings, Integers, etc.). Re-rendered
    /// using the same default-form emit rules as ordinary values
    /// (integers as decimal, strings as basic-quoted).
    pub args: Vec<Value>,
}

/// Backward-compatible entry: returns only the body. Front matter, if
/// present, is parsed and validated (illegal declarations error), then
/// discarded.
pub fn parse(src: &str) -> Result<Value, ParseError> {
    parse_document(src).map(|d| d.body)
}

/// Parse a full DMS document, returning body + front-matter metadata
/// + attached comments.
pub fn parse_document(src: &str) -> Result<Document, ParseError> {
    let mut p = Parser::new(src);
    let meta = p.parse_front_matter()?;
    let body = p.parse_body()?;
    Ok(Document {
        meta,
        body,
        comments: p.comments,
        original_forms: p.original_forms,
    })
}

// ---------- helpers ----------

fn is_bare_key_char(c: char) -> bool {
    c == '_'
        || c == '-'
        || c.is_ascii_alphanumeric()
        || (!c.is_ascii() && (c.is_alphabetic() || c.is_numeric()))
}

fn is_label_start(c: char) -> bool {
    c == '_' || c.is_ascii_alphabetic()
}

fn is_label_cont(c: char) -> bool {
    c == '_' || c.is_ascii_alphanumeric()
}

fn looks_like_date_prefix(s: &str) -> bool {
    let b = s.as_bytes();
    b.len() >= 10
        && b[0].is_ascii_digit()
        && b[1].is_ascii_digit()
        && b[2].is_ascii_digit()
        && b[3].is_ascii_digit()
        && b[4] == b'-'
        && b[5].is_ascii_digit()
        && b[6].is_ascii_digit()
        && b[7] == b'-'
        && b[8].is_ascii_digit()
        && b[9].is_ascii_digit()
}

fn looks_like_time_prefix(s: &str) -> bool {
    let b = s.as_bytes();
    b.len() >= 8
        && b[0].is_ascii_digit()
        && b[1].is_ascii_digit()
        && b[2] == b':'
        && b[3].is_ascii_digit()
        && b[4].is_ascii_digit()
        && b[5] == b':'
        && b[6].is_ascii_digit()
        && b[7].is_ascii_digit()
}

// ---------- parser ----------

struct Parser<'a> {
    src: &'a str,
    pos: usize,
    line: usize,
    line_start: usize,

    // ---- comment-attachment state ----
    /// All comments that have been finalized and attached so far, in
    /// source order.
    comments: Vec<AttachedComment>,
    /// Comments collected from comment-only lines that have not yet been
    /// attached. They become `Leading` on the next sibling node, or
    /// `Floating` on the enclosing container if a blank line appears
    /// before any sibling does, or if the block closes first.
    pending_leading: Vec<Comment>,
    /// The breadcrumb path to the *currently-being-parsed* container.
    /// Block parsers push the relevant segment (Key or Index) before
    /// recursing into a child node and pop after, so that whichever node
    /// is "current" at the time a comment is captured/flushed has the
    /// right path to attach to.
    path: Vec<BreadcrumbSegment>,
    /// Original-literal records captured during parsing. Sparse: only
    /// non-default forms get entries (e.g. an unsigned decimal integer
    /// without underscores has no entry; `0xFF` does). Consulted by
    /// `to_dms` for round-trip emission.
    original_forms: Vec<(Vec<BreadcrumbSegment>, OriginalLiteral)>,
    /// When false, integer/string lexeme recording is suppressed. Used
    /// while parsing heredoc-modifier args (which are values used at
    /// parse time but otherwise discarded — they shouldn't appear in
    /// the host node's `original_forms` slot).
    record_forms: bool,
}

impl<'a> Parser<'a> {
    fn new(src: &'a str) -> Self {
        let src = src.strip_prefix('\u{feff}').unwrap_or(src);
        Self {
            src,
            pos: 0,
            line: 1,
            line_start: 0,
            comments: Vec::new(),
            pending_leading: Vec::new(),
            path: Vec::new(),
            original_forms: Vec::new(),
            record_forms: true,
        }
    }

    /// Append an `OriginalLiteral` record at the current path. Skipped
    /// when `record_forms` is false (e.g. inside heredoc modifier args)
    /// or when the form is the implicit default.
    fn record_form(&mut self, lit: OriginalLiteral) {
        if !self.record_forms {
            return;
        }
        self.original_forms.push((self.path.clone(), lit));
    }

    // ----- position / chars -----

    fn col(&self) -> usize {
        // 1-based column in bytes
        self.pos - self.line_start + 1
    }

    fn err(&self, msg: impl Into<String>) -> ParseError {
        ParseError { line: self.line, column: self.col(), message: msg.into() }
    }

    fn err_at(&self, line: usize, line_start: usize, pos: usize, msg: impl Into<String>) -> ParseError {
        ParseError { line, column: pos.saturating_sub(line_start) + 1, message: msg.into() }
    }

    fn peek(&self) -> Option<char> {
        self.src[self.pos..].chars().next()
    }

    fn rest(&self) -> &str {
        &self.src[self.pos..]
    }

    fn bump(&mut self) -> Option<char> {
        let c = self.peek()?;
        self.pos += c.len_utf8();
        Some(c)
    }

    fn eof(&self) -> bool {
        self.pos >= self.src.len()
    }

    fn advance_line(&mut self) {
        self.line += 1;
        self.line_start = self.pos;
    }

    // ----- whitespace and line terminators -----

    fn skip_inline_ws(&mut self) {
        while matches!(self.peek(), Some(' ') | Some('\t')) {
            self.bump();
        }
    }

    /// Consume a line terminator (LF or CRLF). Returns true if consumed.
    /// Bare CR is NOT a line terminator and is left for the caller to error on.
    fn consume_eol(&mut self) -> bool {
        if self.peek() == Some('\n') {
            self.bump();
            self.advance_line();
            true
        } else if self.rest().starts_with("\r\n") {
            self.pos += 2;
            self.advance_line();
            true
        } else {
            false
        }
    }

    /// Skip blank lines, full-line comments (line + block) at the current
    /// position. Stops at the first content character at column 1+ that is
    /// not trivia.
    ///
    /// Captures full-line comments into `self.pending_leading` (they
    /// become `Leading` on the next sibling, or `Floating` on the
    /// enclosing container if a blank line intervenes). The blank-line
    /// rule: any blank line encountered in this loop while there are
    /// pending comments flushes them as `Floating` on the current path.
    fn skip_trivia(&mut self) -> Result<(), ParseError> {
        loop {
            // skip indent on a line that is entirely trivia: leading ws is
            // okay only if the line is blank or comment.
            let line_start_pos = self.pos;
            self.skip_inline_ws();
            match self.peek() {
                Some('\n') | Some('\r') => {
                    if self.peek() == Some('\r') && !self.rest().starts_with("\r\n") {
                        return Err(self.err("bare CR is not a valid line terminator"));
                    }
                    // Blank line. If we have pending leading comments,
                    // they're separated from any future sibling — flush
                    // them as Floating on the enclosing container.
                    self.flush_pending_as_floating();
                    self.consume_eol();
                }
                Some('#') => {
                    if self.rest().starts_with("###") {
                        let raw = self.read_hash_block_comment()?;
                        self.pending_leading.push(Comment {
                            content: raw,
                            kind: CommentKind::Block,
                        });
                    } else {
                        let raw = self.read_line_comment_to_eol();
                        self.consume_eol();
                        self.pending_leading.push(Comment {
                            content: raw,
                            kind: CommentKind::Line,
                        });
                    }
                }
                Some('/') if self.rest().starts_with("//") => {
                    let raw = self.read_line_comment_to_eol();
                    self.consume_eol();
                    self.pending_leading.push(Comment {
                        content: raw,
                        kind: CommentKind::Line,
                    });
                }
                Some('/') if self.rest().starts_with("/*") => {
                    let raw = self.read_c_block_comment()?;
                    self.pending_leading.push(Comment {
                        content: raw,
                        kind: CommentKind::Block,
                    });
                    // after a c-style block, may be more on this line; loop
                }
                Some(_) => {
                    // not trivia — rewind to line start (preserve indent for caller)
                    self.pos = line_start_pos;
                    return Ok(());
                }
                None => return Ok(()),
            }
        }
    }

    /// Flush any `pending_leading` comments as `Floating` on the current
    /// path (i.e. attached to the enclosing container). Called when a
    /// blank line appears between pending comments and the next sibling,
    /// and when a block closes with leftover pending comments.
    fn flush_pending_as_floating(&mut self) {
        if self.pending_leading.is_empty() {
            return;
        }
        let drained: Vec<Comment> = self.pending_leading.drain(..).collect();
        for c in drained {
            self.comments.push(AttachedComment {
                comment: c,
                position: CommentPosition::Floating,
                path: self.path.clone(),
            });
        }
    }

    /// Attach all `pending_leading` comments as `Leading` on the path
    /// formed by `self.path` + `last_segment`. Called by sibling-entry
    /// points right after pushing the new sibling's breadcrumb.
    fn flush_pending_as_leading_on_current(&mut self) {
        if self.pending_leading.is_empty() {
            return;
        }
        let drained: Vec<Comment> = self.pending_leading.drain(..).collect();
        for c in drained {
            self.comments.push(AttachedComment {
                comment: c,
                position: CommentPosition::Leading,
                path: self.path.clone(),
            });
        }
    }

    fn read_line_comment_to_eol(&mut self) -> String {
        let start = self.pos;
        while let Some(c) = self.peek() {
            if c == '\n' || c == '\r' {
                break;
            }
            self.bump();
        }
        self.src[start..self.pos].to_string()
    }

    /// `/* ... */` with nesting. Caller has not yet consumed `/*`. Returns
    /// the raw comment text (including delimiters).
    fn read_c_block_comment(&mut self) -> Result<String, ParseError> {
        let start_line = self.line;
        let start_lstart = self.line_start;
        let start_pos = self.pos;
        // consume opening /*
        self.pos += 2;
        let mut depth = 1usize;
        while depth > 0 {
            match self.peek() {
                None => {
                    return Err(self.err_at(
                        start_line,
                        start_lstart,
                        start_pos,
                        "unterminated /* block comment",
                    ));
                }
                Some('/') if self.rest().starts_with("/*") => {
                    self.pos += 2;
                    depth += 1;
                }
                Some('*') if self.rest().starts_with("*/") => {
                    self.pos += 2;
                    depth -= 1;
                }
                Some('\n') => {
                    self.bump();
                    self.advance_line();
                }
                Some('\r') if self.rest().starts_with("\r\n") => {
                    self.pos += 2;
                    self.advance_line();
                }
                Some(_) => {
                    self.bump();
                }
            }
        }
        Ok(self.src[start_pos..self.pos].to_string())
    }

    /// `### ... ###` or `###LABEL ... LABEL`. Opener and terminator each on
    /// their own line. Caller has not yet consumed `###`. Returns the
    /// raw comment text including delimiters and the terminator line
    /// (but **not** the trailing EOL of the terminator).
    fn read_hash_block_comment(&mut self) -> Result<String, ParseError> {
        let start_line = self.line;
        let start_lstart = self.line_start;
        let start_pos = self.pos;
        // consume ###
        self.pos += 3;
        // optional label, no whitespace between ### and label
        let label_start = self.pos;
        while let Some(c) = self.peek() {
            if !(c == '_' || c.is_ascii_alphanumeric()) {
                break;
            }
            self.bump();
        }
        let label_str: String = self.src[label_start..self.pos].to_string();
        if !label_str.is_empty() {
            // must start with letter or _
            if !label_str.chars().next().unwrap().is_ascii_alphabetic()
                && !label_str.starts_with('_')
            {
                return Err(self.err_at(
                    start_line,
                    start_lstart,
                    start_pos,
                    "block comment label must start with a letter or underscore",
                ));
            }
        }
        let terminator: String = if label_str.is_empty() {
            "###".to_string()
        } else {
            label_str.clone()
        };
        // require rest of opener line to be only whitespace, then EOL
        self.skip_inline_ws();
        if !(self.consume_eol() || self.eof()) {
            return Err(self.err(
                "block comment opener must be on its own line",
            ));
        }
        // consume body lines until we find a line whose trimmed content
        // equals `terminator`
        loop {
            if self.eof() {
                return Err(self.err_at(
                    start_line,
                    start_lstart,
                    start_pos,
                    "unterminated ### block comment",
                ));
            }
            // capture this line
            let line_begin = self.pos;
            while let Some(c) = self.peek() {
                if c == '\n' || c == '\r' {
                    break;
                }
                self.bump();
            }
            let line_text = &self.src[line_begin..self.pos];
            // The terminator line is part of the captured comment text;
            // capture the comment body BEFORE consuming the EOL so the
            // returned content does not include the terminator's EOL.
            let line_end = self.pos;
            let _ = self.consume_eol();
            if line_text.trim() == terminator {
                return Ok(self.src[start_pos..line_end].to_string());
            }
        }
    }

    // ----- document entry -----

    /// Parse optional front matter block. Leaves the parser positioned at
    /// the start of the body (which may be empty). Returns the user
    /// metadata table (Some) if a block was present, else None. Reserved
    /// `_dms_*` keys are consumed and validated here, not surfaced.
    fn parse_front_matter(&mut self) -> Result<Option<IndexMap<String, Value>>, ParseError> {
        // Front matter must be the first *significant* line. Skip
        // leading trivia; if the next non-trivia line is `+++`, it's
        // front matter. Otherwise rewind so the body parser sees the
        // same position it would have seen.
        let save_pos = self.pos;
        let save_line = self.line;
        let save_lstart = self.line_start;
        let save_pending = self.pending_leading.len();
        let save_comments = self.comments.len();
        self.skip_trivia()?;
        // Check for +++ on its own line
        if !self.rest().starts_with("+++") {
            self.pos = save_pos;
            self.line = save_line;
            self.line_start = save_lstart;
            // Speculative skip_trivia may have captured comments — undo
            // so the body parser re-captures them with the correct
            // path/context.
            self.pending_leading.truncate(save_pending);
            self.comments.truncate(save_comments);
            return Ok(None);
        }
        // The char after +++ must be end-of-line (optionally with inline ws)
        let after = &self.rest()[3..];
        let first_non_ws = after.bytes().find(|&b| b != b' ' && b != b'\t');
        let looks_like_opener = match first_non_ws {
            None => true, // EOF right after +++
            Some(b'\n') => true,
            Some(b'\r') => true,
            _ => false,
        };
        if !looks_like_opener {
            // Not a front-matter opener — let the body parser deal with
            // the `+`-prefixed line.
            self.pos = save_pos;
            self.line = save_line;
            self.line_start = save_lstart;
            self.pending_leading.truncate(save_pending);
            self.comments.truncate(save_comments);
            return Ok(None);
        }
        let opener_line = self.line;
        let opener_lstart = self.line_start;
        let opener_pos = self.pos;
        // consume `+++`, then trailing whitespace, then EOL
        self.pos += 3;
        self.skip_inline_ws();
        if !(self.consume_eol() || self.eof()) {
            return Err(self.err(
                "front matter opener must be on its own line",
            ));
        }
        // Collect lines into a scratch string until we hit the closer `+++`
        let mut inner = String::new();
        loop {
            if self.eof() {
                return Err(self.err_at(
                    opener_line, opener_lstart, opener_pos,
                    "unterminated front matter: missing closing '+++'",
                ));
            }
            // Read one line (without EOL)
            let line_begin = self.pos;
            while let Some(c) = self.peek() {
                if c == '\n' || c == '\r' { break; }
                self.bump();
            }
            let line_text = &self.src[line_begin..self.pos];
            // Is this line the closing `+++`?
            if line_text.trim() == "+++" {
                let _ = self.consume_eol();
                break;
            }
            // Otherwise, preserve this line in `inner` (including trailing EOL)
            inner.push_str(line_text);
            if self.consume_eol() {
                inner.push('\n');
            }
        }
        // Parse `inner` as a standalone DMS table block at indent 0
        let mut sub = Parser::new(&inner);
        let table = sub.parse_body_as_table()?;
        // Now process reserved keys
        let mut meta = IndexMap::new();
        for (k, v) in table {
            if k.strip_prefix('_').is_some() {
                match k.as_str() {
                    "_dms_tier" => {
                        let Value::Integer(n) = v else {
                            return Err(self.err_at(
                                opener_line, opener_lstart, opener_pos,
                                "_dms_tier must be a non-negative integer",
                            ));
                        };
                        if n < 0 {
                            return Err(self.err_at(
                                opener_line, opener_lstart, opener_pos,
                                "_dms_tier must be non-negative",
                            ));
                        }
                        if n >= 1 {
                            return Err(self.err_at(
                                opener_line, opener_lstart, opener_pos,
                                format!(
                                    "_dms_tier: {n} is not supported (no tier ≥ 1 is defined in this version of DMS)"
                                ),
                            ));
                        }
                        // _dms_tier: 0 — accept and discard.
                    }
                    other => {
                        return Err(self.err_at(
                            opener_line, opener_lstart, opener_pos,
                            format!("unknown reserved key: {other}"),
                        ));
                    }
                }
            } else {
                meta.insert(k, v);
            }
        }
        // Hoist comments from the sub-parser, prefixing each path with
        // `Key("__fm__")` so callers can distinguish front-matter
        // comments from body comments. Comments attached to reserved
        // (consumed) `_dms_*` keys are dropped — those nodes do not
        // appear in the final document, so by the spec's
        // delete-the-node-drop-its-comment rule they go too.
        for ac in sub.comments {
            if let Some(BreadcrumbSegment::Key(k0)) = ac.path.first() {
                if k0.starts_with('_') {
                    continue;
                }
            }
            let mut new_path = Vec::with_capacity(ac.path.len() + 1);
            new_path.push(BreadcrumbSegment::Key("__fm__".to_string()));
            new_path.extend(ac.path);
            self.comments.push(AttachedComment {
                comment: ac.comment,
                position: ac.position,
                path: new_path,
            });
        }
        // Same hoist for original_forms: `__fm__` prefix, drop entries
        // attached to reserved (consumed) `_dms_*` keys.
        for (path, lit) in sub.original_forms {
            if let Some(BreadcrumbSegment::Key(k0)) = path.first() {
                if k0.starts_with('_') {
                    continue;
                }
            }
            let mut new_path = Vec::with_capacity(path.len() + 1);
            new_path.push(BreadcrumbSegment::Key("__fm__".to_string()));
            new_path.extend(path);
            self.original_forms.push((new_path, lit));
        }
        Ok(Some(meta))
    }

    /// Parse the inner content of a front matter block as a table.
    fn parse_body_as_table(&mut self) -> Result<IndexMap<String, Value>, ParseError> {
        self.skip_trivia()?;
        if self.eof() {
            // Empty FM body with comment-only content: flush any
            // pending-leading comments as floating on the (empty)
            // table root, so the outer hoist can re-attach them under
            // the `__fm__` prefix.
            self.flush_pending_as_floating();
            return Ok(IndexMap::new());
        }
        if matches!(self.peek(), Some(' ') | Some('\t')) {
            return Err(self.err("unexpected indentation inside front matter"));
        }
        if self.peek() == Some('+') && self.peek_after_plus_is_space_or_eol() {
            return Err(self.err("front matter block cannot have a list root"));
        }
        if !self.line_starts_kvpair() {
            return Err(self.err("front matter block must be a table"));
        }
        let t = self.parse_table_block(0)?;
        self.skip_trivia()?;
        if !self.eof() {
            return Err(self.err("trailing content inside front matter"));
        }
        // Any final pending-leading comments become floating on FM root.
        self.flush_pending_as_floating();
        Ok(t)
    }

    /// Parse a complete document body.
    fn parse_body(&mut self) -> Result<Value, ParseError> {
        self.skip_trivia()?;
        if self.eof() {
            // Empty / comment-only body: pending comments float on root.
            self.flush_pending_as_floating();
            return Ok(Value::Table(IndexMap::new()));
        }
        // dispatch on the first significant char at column 1 (no indent allowed at root)
        if matches!(self.peek(), Some(' ') | Some('\t')) {
            return Err(self.err("unexpected indentation at document root"));
        }
        // Decide root type
        let result = if self.peek() == Some('+') && self.peek_after_plus_is_space_or_eol() {
            // list root
            let v = self.parse_list_block(0)?;
            self.skip_trivia()?;
            if !self.eof() {
                return Err(self.err("trailing content after list root"));
            }
            Value::List(v)
        } else if self.line_starts_kvpair() {
            let v = self.parse_table_block(0)?;
            self.skip_trivia()?;
            if !self.eof() {
                return Err(self.err("trailing content after table root"));
            }
            Value::Table(v)
        } else {
            // scalar root
            let v = self.parse_inline_value_or_heredoc()?;
            self.consume_after_value(true)?;
            self.skip_trivia()?;
            if !self.eof() {
                return Err(self.err("scalar root cannot be followed by more content"));
            }
            v
        };
        // Any post-body trivia comments float on root.
        self.flush_pending_as_floating();
        Ok(result)
    }

    fn peek_after_plus_is_space_or_eol(&self) -> bool {
        let bytes = self.src.as_bytes();
        let next = bytes.get(self.pos + 1).copied();
        matches!(next, Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | None)
    }

    /// Heuristic: scan from current position to determine if the line is a
    /// kvpair (presence of `:` followed by ws/EOL after a key-shaped prefix)
    /// vs a scalar. We don't commit yet; we just classify.
    fn line_starts_kvpair(&self) -> bool {
        let mut p = self.pos;
        let bytes = self.src.as_bytes();
        // Try basic-quoted key
        if bytes.get(p).copied() == Some(b'"') {
            // skip until matching unescaped "
            p += 1;
            while p < bytes.len() {
                match bytes[p] {
                    b'\\' => p += 2,
                    b'"' => { p += 1; break; }
                    b'\n' | b'\r' => return false,
                    _ => p += 1,
                }
            }
        } else if bytes.get(p).copied() == Some(b'\'') {
            p += 1;
            while p < bytes.len() {
                match bytes[p] {
                    b'\'' => { p += 1; break; }
                    b'\n' | b'\r' => return false,
                    _ => p += 1,
                }
            }
        } else {
            // bare key chars
            let s = &self.src[p..];
            let mut chars = s.char_indices();
            let mut last_end = 0;
            let mut any = false;
            while let Some((i, c)) = chars.next() {
                if is_bare_key_char(c) {
                    last_end = i + c.len_utf8();
                    any = true;
                } else {
                    break;
                }
            }
            if !any {
                return false;
            }
            p += last_end;
        }
        // now expect ':'
        if bytes.get(p).copied() != Some(b':') {
            return false;
        }
        // followed by ws, EOL, or EOF — we consider it a kvpair
        match bytes.get(p + 1).copied() {
            Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | None => true,
            _ => false,
        }
    }

    // ----- block parsers -----

    /// Parses a table block: a sequence of kvpairs at exactly `indent`
    /// (column = indent+1). Stops at a line whose indent < indent+1 or EOF.
    fn parse_table_block(&mut self, indent: usize) -> Result<IndexMap<String, Value>, ParseError> {
        let mut table: IndexMap<String, Value> = IndexMap::new();
        loop {
            self.skip_trivia()?;
            if self.eof() {
                break;
            }
            // measure this line's indent
            let line_indent = self.measure_line_indent();
            if line_indent < indent {
                break;
            }
            if line_indent != indent {
                return Err(self.err_at(
                    self.line,
                    self.line_start,
                    self.line_start + indent,
                    format!(
                        "inconsistent indent: expected {indent} spaces, got {line_indent}"
                    ),
                ));
            }
            // consume the indent
            self.pos = self.line_start + indent;
            let (key, value) = self.parse_kvpair(indent)?;
            if table.contains_key(&key) {
                return Err(self.err(format!("duplicate key: {key}")));
            }
            table.insert(key, value);
        }
        // Block closing: any leftover pending leading comments become
        // floating on the enclosing container (the table itself).
        self.flush_pending_as_floating();
        Ok(table)
    }

    /// Parses a list block: sibling `+ item` lines at exactly `indent`.
    fn parse_list_block(&mut self, indent: usize) -> Result<Vec<Value>, ParseError> {
        let mut items: Vec<Value> = Vec::new();
        loop {
            self.skip_trivia()?;
            if self.eof() {
                break;
            }
            let line_indent = self.measure_line_indent();
            if line_indent < indent {
                break;
            }
            if line_indent != indent {
                return Err(self.err_at(
                    self.line,
                    self.line_start,
                    self.line_start + indent,
                    format!(
                        "inconsistent indent: expected {indent} spaces, got {line_indent}"
                    ),
                ));
            }
            self.pos = self.line_start + indent;
            if self.peek() != Some('+') {
                // not a list item — caller may treat as end of list
                break;
            }
            // We've committed to a new list item. Push its index, attach
            // pending leading comments to it, then parse the value.
            let idx = items.len();
            self.path.push(BreadcrumbSegment::Index(idx));
            self.flush_pending_as_leading_on_current();
            // consume '+'
            self.bump();
            // require whitespace OR end-of-line
            let item_result: Result<Value, ParseError> = match self.peek() {
                Some(' ') | Some('\t') => {
                    self.bump();
                    self.skip_inline_ws();
                    self.parse_list_item_value(indent)
                }
                Some('\n') | Some('\r') | None => {
                    // empty `+` opens a nested block on next lines
                    self.consume_eol();
                    self.skip_trivia()?;
                    if self.eof() {
                        Err(self.err("expected indented block after empty '+' marker"))
                    } else {
                        let inner_indent = self.measure_line_indent();
                        if inner_indent <= indent {
                            Err(self.err("expected indented block after empty '+' marker"))
                        } else {
                            self.parse_block_value(inner_indent)
                        }
                    }
                }
                _ => Err(self.err("expected space after '+'")),
            };
            self.path.pop();
            let v = item_result?;
            items.push(v);
        }
        // Block close: leftover pending become floating on the list itself.
        self.flush_pending_as_floating();
        Ok(items)
    }

    fn measure_line_indent(&self) -> usize {
        let b = self.src.as_bytes();
        let mut i = self.line_start;
        let mut n = 0usize;
        while i < b.len() && b[i] == b' ' {
            n += 1;
            i += 1;
        }
        n
    }

    fn parse_block_value(&mut self, indent: usize) -> Result<Value, ParseError> {
        // Decide: list or table
        // Consume the indent first
        self.pos = self.line_start + indent;
        if self.peek() == Some('+') && self.peek_after_plus_is_space_or_eol() {
            Ok(Value::List(self.parse_list_block(indent)?))
        } else {
            Ok(Value::Table(self.parse_table_block(indent)?))
        }
    }

    fn parse_list_item_value(&mut self, list_indent: usize) -> Result<Value, ParseError> {
        // The value sits after `+ `. It can be:
        //   - inline scalar (number/string/...) ending at EOL
        //   - the start of a kvpair (key:value), in which case the item is
        //     a table; subsequent keys of that table sit at the column of
        //     the first key (not the `+`).
        //   - heredoc opener
        if self.line_starts_kvpair() {
            // remember key column
            let key_col = self.col() - 1; // 0-based column of first key
            let (k, v) = self.parse_kvpair(key_col)?;
            // additional keys at key_col are siblings of this table
            let mut t = IndexMap::new();
            t.insert(k, v);
            // continue reading sibling keys at key_col
            loop {
                self.skip_trivia()?;
                if self.eof() {
                    break;
                }
                let li = self.measure_line_indent();
                if li < key_col {
                    break;
                }
                if li != key_col {
                    return Err(self.err_at(
                        self.line,
                        self.line_start,
                        self.line_start + key_col,
                        "list-item table sibling key must align with first key",
                    ));
                }
                self.pos = self.line_start + key_col;
                // ensure not a `+` at key_col (would indicate next list item misaligned)
                if self.peek() == Some('+') {
                    return Err(self.err(
                        "'+' marker at sibling-key column is ambiguous",
                    ));
                }
                if !self.line_starts_kvpair() {
                    break;
                }
                let (k, v) = self.parse_kvpair(key_col)?;
                if t.contains_key(&k) {
                    return Err(self.err(format!("duplicate key: {k}")));
                }
                t.insert(k, v);
            }
            // End of this inline-table-in-list-item: any pending leading
            // comments belong to the enclosing list item itself (Floating).
            self.flush_pending_as_floating();
            Ok(Value::Table(t))
        } else {
            // inline scalar (or heredoc); same end-of-line discipline
            let v = self.parse_inline_value_or_heredoc()?;
            self.consume_after_value(false)?;
            // For a heredoc the parse already consumed up through the
            // terminator line; consume_after_value handled the EOL after
            // any inline scalar.
            // Lists do not support a child block when the item's value is a
            // simple scalar; we don't try to attach one here.
            let _ = list_indent;
            Ok(v)
        }
    }

    // ----- kvpair parsing -----

    fn parse_kvpair(&mut self, parent_indent: usize) -> Result<(String, Value), ParseError> {
        let key = self.parse_key()?;
        if self.peek() != Some(':') {
            return Err(self.err("expected ':' after key"));
        }
        // We've now committed: this is a kvpair with key `key`. Push the
        // breadcrumb so any pending leading comments attach to this node,
        // and so trailing comments captured by `consume_after_value` (and
        // any floating comments inside the value's child block) get the
        // right path.
        self.path.push(BreadcrumbSegment::Key(key.clone()));
        self.flush_pending_as_leading_on_current();
        let result = self.parse_kvpair_after_key(parent_indent);
        self.path.pop();
        result.map(|v| (key, v))
    }

    fn parse_kvpair_after_key(&mut self, parent_indent: usize) -> Result<Value, ParseError> {
        // caller has consumed the key but not the ':'
        self.bump(); // consume ':'
        match self.peek() {
            Some(' ') | Some('\t') => {
                self.bump();
                self.skip_inline_ws();
                // After the `: `, the value is on the same line.
                if matches!(self.peek(), Some('\n') | Some('\r') | None) {
                    return Err(self.err(
                        "expected value or heredoc opener after ':'; for a child block, omit whitespace after the colon",
                    ));
                }
                let v = self.parse_inline_value_or_heredoc()?;
                self.consume_after_value(false)?;
                Ok(v)
            }
            Some('\n') | Some('\r') | None => {
                // child block
                self.consume_eol();
                self.skip_trivia()?;
                if self.eof() {
                    return Err(self.err("expected indented child block"));
                }
                let child_indent = self.measure_line_indent();
                if child_indent <= parent_indent {
                    return Err(self.err("expected indented child block"));
                }
                let v = self.parse_block_value(child_indent)?;
                Ok(v)
            }
            _ => Err(self.err("expected whitespace after ':'")),
        }
    }

    // ----- key parsing -----

    fn parse_key(&mut self) -> Result<String, ParseError> {
        match self.peek() {
            Some('"') => self.parse_basic_string_key(),
            Some('\'') => self.parse_literal_string_key(),
            Some(_) => self.parse_bare_key(),
            None => Err(self.err("expected key")),
        }
    }

    fn parse_bare_key(&mut self) -> Result<String, ParseError> {
        let start = self.pos;
        while let Some(c) = self.peek() {
            if is_bare_key_char(c) {
                self.bump();
            } else {
                break;
            }
        }
        if self.pos == start {
            return Err(self.err("expected key"));
        }
        Ok(self.src[start..self.pos].to_string())
    }

    fn parse_basic_string_key(&mut self) -> Result<String, ParseError> {
        // disallow """ as key opener (heredoc-ish)
        if self.rest().starts_with("\"\"\"") {
            return Err(self.err("triple-quoted strings are not allowed as keys"));
        }
        // Suppress original-form recording: the key is not a value
        // and must not end up as an OriginalLiteral entry on the
        // parent container's path.
        let saved = self.record_forms;
        self.record_forms = false;
        let r = self.parse_basic_string_value();
        self.record_forms = saved;
        r
    }

    fn parse_literal_string_key(&mut self) -> Result<String, ParseError> {
        if self.rest().starts_with("'''") {
            return Err(self.err("triple-quoted strings are not allowed as keys"));
        }
        let saved = self.record_forms;
        self.record_forms = false;
        let r = self.parse_literal_string_value();
        self.record_forms = saved;
        r
    }

    // ----- value parsing -----

    fn parse_inline_value_or_heredoc(&mut self) -> Result<Value, ParseError> {
        // allow inline /* ... */ block comments preceding the value;
        // these mid-line decoration comments don't fit cleanly into the
        // leading/trailing/floating model, so we still discard them
        // (matches the pre-comment-AST behavior for this rare position).
        loop {
            match self.peek() {
                Some('/') if self.rest().starts_with("/*") => {
                    let _ = self.read_c_block_comment()?;
                    self.skip_inline_ws();
                }
                _ => break,
            }
        }
        match self.peek() {
            Some('"') => {
                if self.rest().starts_with("\"\"\"") {
                    self.parse_heredoc_basic().map(Value::String)
                } else {
                    let r = self.parse_basic_string_value().map(Value::String);
                    // Basic is the emitter's default for strings, so
                    // we don't need to record it as an OriginalLiteral
                    // entry — `to_dms` falls back to Basic when no
                    // entry is found. Still walking through the parse
                    // path here for symmetry / future extension.
                    r
                }
            }
            Some('\'') => {
                if self.rest().starts_with("'''") {
                    self.parse_heredoc_literal().map(Value::String)
                } else {
                    let r = self.parse_literal_string_value().map(Value::String)?;
                    self.record_form(OriginalLiteral::String { form: StringForm::Literal });
                    Ok(r)
                }
            }
            Some('[') => Ok(Value::List(self.parse_flow_array()?)),
            Some('{') => Ok(Value::Table(self.parse_flow_table()?)),
            Some('t') | Some('f') => self.parse_bool_or_keyword_value(),
            Some('i') => self.parse_inf_value(false),
            Some('n') => self.parse_nan_value(false),
            Some(c) if c == '+' || c == '-' || c.is_ascii_digit() => {
                self.parse_number_or_datetime()
            }
            Some(c) => Err(self.err(format!("unexpected character '{c}' in value"))),
            None => Err(self.err("expected value")),
        }
    }

    fn parse_bool_or_keyword_value(&mut self) -> Result<Value, ParseError> {
        let rest = self.rest();
        if let Some(after) = rest.strip_prefix("true") {
            if Self::is_value_terminator(after.chars().next()) {
                self.pos += 4;
                return Ok(Value::Bool(true));
            }
        }
        if let Some(after) = rest.strip_prefix("false") {
            if Self::is_value_terminator(after.chars().next()) {
                self.pos += 5;
                return Ok(Value::Bool(false));
            }
        }
        Err(self.err("expected value"))
    }

    fn parse_inf_value(&mut self, signed: bool) -> Result<Value, ParseError> {
        let after = if signed { 4 } else { 3 };
        let want: &'static str = if signed {
            // peek to see sign
            match self.peek() {
                Some('+') => "+inf",
                Some('-') => "-inf",
                _ => "inf",
            }
        } else {
            "inf"
        };
        let neg = want.starts_with('-');
        let rest_owned = self.rest().to_string();
        if let Some(rem) = rest_owned.strip_prefix(want) {
            if Self::is_value_terminator(rem.chars().next()) {
                self.pos += after;
                let v = if neg { f64::NEG_INFINITY } else { f64::INFINITY };
                return Ok(Value::Float(v));
            }
        }
        Err(self.err("expected 'inf'"))
    }

    fn parse_nan_value(&mut self, signed: bool) -> Result<Value, ParseError> {
        let after = if signed { 4 } else { 3 };
        let want: &'static str = if signed {
            match self.peek() { Some('+') => "+nan", Some('-') => "-nan", _ => "nan" }
        } else {
            "nan"
        };
        let rest_owned = self.rest().to_string();
        if let Some(rem) = rest_owned.strip_prefix(want) {
            if Self::is_value_terminator(rem.chars().next()) {
                self.pos += after;
                return Ok(Value::Float(f64::NAN));
            }
        }
        Err(self.err("expected 'nan'"))
    }

    fn is_value_terminator(c: Option<char>) -> bool {
        match c {
            None => true,
            Some(c) => matches!(c, ' ' | '\t' | '\n' | '\r' | '#' | '/' | ',' | ']' | '}'),
        }
    }

    // ----- numbers + datetime -----

    fn parse_number_or_datetime(&mut self) -> Result<Value, ParseError> {
        let rest = self.rest();
        let starts_with_sign = matches!(self.peek(), Some('+') | Some('-'));
        if !starts_with_sign && looks_like_date_prefix(rest) {
            return self.parse_datetime_value();
        }
        if !starts_with_sign && looks_like_time_prefix(rest) {
            return self.parse_local_time_value();
        }
        // Check inf/nan with sign
        if starts_with_sign {
            let after_sign = &rest[1..];
            if after_sign.starts_with("inf") {
                let signed_rest = &rest[..4];
                if Self::is_value_terminator(rest[4..].chars().next()) {
                    let neg = signed_rest.starts_with('-');
                    self.pos += 4;
                    return Ok(Value::Float(if neg { f64::NEG_INFINITY } else { f64::INFINITY }));
                }
            }
            // signed nan rejected per design — keep `nan` bare only
        }
        // determine if this is a float by scanning the token
        let tok = self.scan_number_token();
        let s = &self.src[self.pos..self.pos + tok.len];
        if tok.is_float {
            let f = parse_float(s).ok_or_else(|| self.err(format!("invalid float: {s}")))?;
            self.pos += tok.len;
            Ok(Value::Float(f))
        } else {
            let lit = s.to_string();
            let n = parse_integer(s).map_err(|e| self.err(e))?;
            self.pos += tok.len;
            // Record original lexeme only when it differs from the
            // canonical "decimal, no underscores, no '+' sign" form
            // used by the default emitter (i.e. `n.to_string()`).
            // Anything else — hex/oct/bin, underscore separators,
            // explicit `+` — gets recorded.
            if lit != n.to_string() {
                self.record_form(OriginalLiteral::Integer { lit });
            }
            Ok(Value::Integer(n))
        }
    }

    fn scan_number_token(&self) -> NumTok {
        let s = self.rest();
        let bytes = s.as_bytes();
        let mut i = 0usize;
        if matches!(bytes.get(0), Some(b'+') | Some(b'-')) {
            i += 1;
        }
        // detect base prefix
        let is_prefixed = bytes.get(i) == Some(&b'0')
            && matches!(bytes.get(i + 1).copied(), Some(b'x') | Some(b'o') | Some(b'b'));
        if is_prefixed {
            i += 2;
            // mantissa digits + underscores + possible '.'
            let mut saw_dot = false;
            let mut saw_p = false;
            while let Some(&b) = bytes.get(i) {
                if b == b'_' || is_base_digit(b, bytes[i - if saw_dot { 1 } else { 0 } - 1]) {
                    i += 1;
                } else if b == b'.' && !saw_dot && !saw_p {
                    saw_dot = true;
                    i += 1;
                } else if b == b'p' && !saw_p {
                    saw_p = true;
                    i += 1;
                    if matches!(bytes.get(i), Some(b'+') | Some(b'-')) {
                        i += 1;
                    }
                } else if saw_p && b.is_ascii_digit() {
                    i += 1;
                } else {
                    break;
                }
            }
            return NumTok { len: i, is_float: saw_dot || saw_p };
        }
        // decimal
        let mut saw_dot = false;
        let mut saw_e = false;
        while let Some(&b) = bytes.get(i) {
            if b.is_ascii_digit() || b == b'_' {
                i += 1;
            } else if b == b'.' && !saw_dot && !saw_e {
                saw_dot = true;
                i += 1;
            } else if (b == b'e' || b == b'E') && !saw_e {
                saw_e = true;
                i += 1;
                if matches!(bytes.get(i), Some(b'+') | Some(b'-')) {
                    i += 1;
                }
            } else {
                break;
            }
        }
        NumTok { len: i, is_float: saw_dot || saw_e }
    }

    fn parse_datetime_value(&mut self) -> Result<Value, ParseError> {
        let rest: String = self.rest().to_string();
        let date = rest[..10].to_string();
        validate_date(&date).map_err(|e| self.err(e))?;
        let rest2 = &rest[10..];
        if !rest2.starts_with('T') && !rest2.starts_with('t') && !rest2.starts_with(' ') {
            if !Self::is_value_terminator(rest2.chars().next()) {
                return Err(self.err("invalid character after date"));
            }
            self.pos += 10;
            return Ok(Value::LocalDate(date));
        }
        if rest2.starts_with(' ') {
            let after_ws = rest2.trim_start_matches(|c: char| c == ' ' || c == '\t');
            if matches!(after_ws.chars().next(), Some(c) if c.is_ascii_digit()) {
                return Err(self.err("date and time must be separated by 'T' (space not permitted)"));
            }
            self.pos += 10;
            return Ok(Value::LocalDate(date));
        }
        let after_t = &rest2[1..];
        if !looks_like_time_prefix(after_t) {
            return Err(self.err("expected HH:MM:SS after 'T'"));
        }
        let time_str = &after_t[..8];
        validate_time(time_str).map_err(|e| self.err(e))?;
        let mut consumed = 10 + 1 + 8;
        let after_time = &rest[consumed..];
        let mut frac_len = 0usize;
        if after_time.starts_with('.') {
            let bytes = after_time.as_bytes();
            let mut k = 1usize;
            while k < bytes.len() && bytes[k].is_ascii_digit() {
                k += 1;
            }
            let digits = k - 1;
            if digits == 0 {
                return Err(self.err("expected fractional digits after '.'"));
            }
            if digits > 9 {
                return Err(self.err("fractional seconds limited to 9 digits (nanosecond precision)"));
            }
            frac_len = k;
        }
        consumed += frac_len;
        let after_frac = &rest[consumed..];
        if after_frac.starts_with('Z') || after_frac.starts_with('z') {
            consumed += 1;
            let s = rest[..consumed].to_string();
            self.pos += consumed;
            return Ok(Value::OffsetDateTime(s));
        }
        if after_frac.starts_with('+') || after_frac.starts_with('-') {
            let bytes = after_frac.as_bytes();
            if bytes.len() < 6
                || !bytes[1].is_ascii_digit()
                || !bytes[2].is_ascii_digit()
                || bytes[3] != b':'
                || !bytes[4].is_ascii_digit()
                || !bytes[5].is_ascii_digit()
            {
                return Err(self.err("invalid offset; expected ±HH:MM"));
            }
            let oh = (bytes[1] - b'0') * 10 + (bytes[2] - b'0');
            let om = (bytes[4] - b'0') * 10 + (bytes[5] - b'0');
            if oh > 23 || om > 59 {
                return Err(self.err("offset out of range"));
            }
            consumed += 6;
            let s = rest[..consumed].to_string();
            self.pos += consumed;
            return Ok(Value::OffsetDateTime(s));
        }
        // no offset → local-datetime
        if !Self::is_value_terminator(after_frac.chars().next()) {
            return Err(self.err("invalid character after datetime"));
        }
        let s = rest[..consumed].to_string();
        self.pos += consumed;
        Ok(Value::LocalDateTime(s))
    }

    fn parse_local_time_value(&mut self) -> Result<Value, ParseError> {
        let rest = self.rest();
        let time_str = &rest[..8];
        validate_time(time_str).map_err(|e| self.err(e))?;
        let mut consumed = 8usize;
        let after = &rest[consumed..];
        if after.starts_with('.') {
            let bytes = after.as_bytes();
            let mut k = 1usize;
            while k < bytes.len() && bytes[k].is_ascii_digit() {
                k += 1;
            }
            let digits = k - 1;
            if digits == 0 {
                return Err(self.err("expected fractional digits after '.'"));
            }
            if digits > 9 {
                return Err(self.err("fractional seconds limited to 9 digits"));
            }
            consumed += k;
        }
        let after2 = &rest[consumed..];
        if !Self::is_value_terminator(after2.chars().next()) {
            return Err(self.err("invalid character after time"));
        }
        let s = rest[..consumed].to_string();
        self.pos += consumed;
        Ok(Value::LocalTime(s))
    }

    // ----- strings -----

    fn parse_basic_string_value(&mut self) -> Result<String, ParseError> {
        let start_line = self.line;
        let start_lstart = self.line_start;
        let start_pos = self.pos;
        self.bump(); // consume opening "
        let mut out = String::new();
        loop {
            match self.peek() {
                None => return Err(self.err_at(start_line, start_lstart, start_pos, "unterminated string")),
                Some('\n') | Some('\r') => return Err(self.err("strings cannot span lines")),
                Some('"') => {
                    self.bump();
                    return Ok(out);
                }
                Some('\\') => {
                    self.bump();
                    let esc_pos = self.pos;
                    match self.bump() {
                        Some('"') => out.push('"'),
                        Some('\\') => out.push('\\'),
                        Some('n') => out.push('\n'),
                        Some('t') => out.push('\t'),
                        Some('r') => out.push('\r'),
                        Some('b') => out.push('\u{0008}'),
                        Some('f') => out.push('\u{000c}'),
                        Some('u') => {
                            let cp = self.read_hex_codepoint(4, esc_pos)?;
                            out.push(cp);
                        }
                        Some('U') => {
                            let cp = self.read_hex_codepoint(8, esc_pos)?;
                            out.push(cp);
                        }
                        Some(c) => return Err(self.err(format!("invalid escape '\\{c}'"))),
                        None => return Err(self.err("unterminated escape")),
                    }
                }
                Some(c) => {
                    self.bump();
                    out.push(c);
                }
            }
        }
    }

    fn parse_literal_string_value(&mut self) -> Result<String, ParseError> {
        let start_line = self.line;
        let start_lstart = self.line_start;
        let start_pos = self.pos;
        self.bump(); // consume '
        let mut out = String::new();
        loop {
            match self.peek() {
                None => return Err(self.err_at(start_line, start_lstart, start_pos, "unterminated string")),
                Some('\n') | Some('\r') => return Err(self.err("strings cannot span lines")),
                Some('\'') => {
                    self.bump();
                    return Ok(out);
                }
                Some(c) => {
                    self.bump();
                    out.push(c);
                }
            }
        }
    }

    fn read_hex_codepoint(&mut self, n: usize, _esc_pos: usize) -> Result<char, ParseError> {
        let s = self.rest();
        if s.len() < n {
            return Err(self.err(format!("expected {n} hex digits in unicode escape")));
        }
        let hex = &s[..n];
        if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
            return Err(self.err(format!("invalid hex in unicode escape: {hex}")));
        }
        let v = u32::from_str_radix(hex, 16)
            .map_err(|_| self.err("invalid unicode escape"))?;
        self.pos += n;
        char::from_u32(v).ok_or_else(|| self.err("unicode escape is not a scalar value"))
    }

    // ----- heredocs -----

    fn parse_heredoc_basic(&mut self) -> Result<String, ParseError> {
        // consume opening """
        self.pos += 3;
        let label = self.parse_heredoc_label();
        let modifiers = self.parse_heredoc_modifiers()?;
        // rest of opener line must be only whitespace, then EOL
        self.skip_inline_ws();
        if !(self.consume_eol() || self.eof()) {
            return Err(self.err("heredoc opener must be followed by end of line"));
        }
        let label_opt = if label.is_empty() { None } else { Some(label.clone()) };
        let terminator = if label.is_empty() { "\"\"\"".to_string() } else { label };
        let body = self.collect_heredoc_body(&terminator, true)?;
        let stripped = strip_indent_and_continuations(&body, true)?;
        let processed = apply_modifiers(stripped, &modifiers).map_err(|e| self.err(e))?;
        let calls: Vec<HeredocModifierCall> = modifiers
            .into_iter()
            .map(|m| HeredocModifierCall { name: m.name, args: m.args })
            .collect();
        self.record_form(OriginalLiteral::String {
            form: StringForm::Heredoc {
                flavor: HeredocFlavor::BasicTriple,
                label: label_opt,
                modifiers: calls,
            },
        });
        Ok(processed)
    }

    fn parse_heredoc_literal(&mut self) -> Result<String, ParseError> {
        self.pos += 3;
        let label = self.parse_heredoc_label();
        let modifiers = self.parse_heredoc_modifiers()?;
        self.skip_inline_ws();
        if !(self.consume_eol() || self.eof()) {
            return Err(self.err("heredoc opener must be followed by end of line"));
        }
        let label_opt = if label.is_empty() { None } else { Some(label.clone()) };
        let terminator = if label.is_empty() { "'''".to_string() } else { label };
        let body = self.collect_heredoc_body(&terminator, false)?;
        let stripped = strip_indent_and_continuations(&body, false)?;
        let processed = apply_modifiers(stripped, &modifiers).map_err(|e| self.err(e))?;
        let calls: Vec<HeredocModifierCall> = modifiers
            .into_iter()
            .map(|m| HeredocModifierCall { name: m.name, args: m.args })
            .collect();
        self.record_form(OriginalLiteral::String {
            form: StringForm::Heredoc {
                flavor: HeredocFlavor::LiteralTriple,
                label: label_opt,
                modifiers: calls,
            },
        });
        Ok(processed)
    }

    fn parse_heredoc_label(&mut self) -> String {
        let start = self.pos;
        if let Some(c) = self.peek() {
            if !is_label_start(c) {
                return String::new();
            }
        } else {
            return String::new();
        }
        while let Some(c) = self.peek() {
            if is_label_cont(c) {
                self.bump();
            } else {
                break;
            }
        }
        self.src[start..self.pos].to_string()
    }

    fn parse_heredoc_modifiers(&mut self) -> Result<Vec<HMod>, ParseError> {
        let mut mods = Vec::new();
        loop {
            // require whitespace before each modifier
            let ws_start = self.pos;
            self.skip_inline_ws();
            let had_ws = self.pos > ws_start;
            match self.peek() {
                Some(c) if is_label_start(c) => {
                    if !had_ws {
                        return Err(self.err("modifier must be preceded by whitespace"));
                    }
                    let m = self.parse_one_modifier()?;
                    mods.push(m);
                }
                _ => {
                    self.pos = ws_start;
                    return Ok(mods);
                }
            }
        }
    }

    fn parse_one_modifier(&mut self) -> Result<HMod, ParseError> {
        let name_start = self.pos;
        while let Some(c) = self.peek() {
            if is_label_cont(c) {
                self.bump();
            } else {
                break;
            }
        }
        let name = self.src[name_start..self.pos].to_string();
        if self.peek() != Some('(') {
            return Err(self.err("modifiers require parentheses"));
        }
        self.bump();
        // Suppress original-form recording while parsing modifier args:
        // these values are consumed at parse time (passed into the modifier
        // call) and otherwise discarded — they must not pollute the host
        // heredoc node's `original_forms` slot. e.g. `_trim("\n", ">")`
        // should leave the heredoc's String form intact, not push entries
        // for the basic-string `","` or literal-string `">"` arguments.
        let prev_record = self.record_forms;
        self.record_forms = false;
        // parse args: comma-separated inline values
        let mut args = Vec::new();
        let result: Result<(), ParseError> = (|| {
            loop {
                self.skip_inline_ws();
                if self.peek() == Some(')') {
                    self.bump();
                    return Ok(());
                }
                let v = self.parse_inline_value_or_heredoc()?;
                args.push(v);
                self.skip_inline_ws();
                match self.peek() {
                    Some(',') => { self.bump(); }
                    Some(')') => { self.bump(); return Ok(()); }
                    _ => return Err(self.err("expected ',' or ')' in modifier args")),
                }
            }
        })();
        self.record_forms = prev_record;
        result?;
        Ok(HMod { name, args })
    }

    fn collect_heredoc_body(&mut self, terminator: &str, _allow_continuation: bool) -> Result<HBody, ParseError> {
        let mut lines: Vec<HLine> = Vec::new();
        let opener_line = self.line;
        let opener_lstart = self.line_start;
        let opener_pos = self.pos;
        loop {
            if self.eof() {
                return Err(self.err_at(opener_line, opener_lstart, opener_pos, "unterminated heredoc"));
            }
            let line_begin = self.pos;
            // read until eol
            while let Some(c) = self.peek() {
                if c == '\n' || c == '\r' {
                    break;
                }
                self.bump();
            }
            let line_text = &self.src[line_begin..self.pos];
            let raw = line_text.to_string();
            let this_line = self.line;
            let this_line_start = self.line_start;
            if raw.trim() == terminator {
                // Leave the terminator's trailing EOL for the caller's
                // consume_after_value() to consume; otherwise we'd skip
                // past it and the next line's content gets misread.
                let strip_depth = raw.bytes().take_while(|&b| b == b' ').count();
                return Ok(HBody { lines, strip_depth });
            }
            // not the terminator — consume this line's EOL and continue
            let _consumed_eol = self.consume_eol();
            lines.push(HLine { text: raw, line: this_line, line_start: this_line_start });
        }
    }

    // ----- flow forms -----

    fn parse_flow_array(&mut self) -> Result<Vec<Value>, ParseError> {
        self.bump(); // consume [
        let mut items = Vec::new();
        loop {
            self.skip_flow_ws()?;
            if self.peek() == Some(']') {
                self.bump();
                return Ok(items);
            }
            // Push the current index onto the path so any integer/string
            // OriginalLiteral records inside the flow value get the right
            // breadcrumb. The path is popped after the value parse.
            let idx = items.len();
            self.path.push(BreadcrumbSegment::Index(idx));
            let parsed = self.parse_inline_value_or_heredoc_in_flow();
            self.path.pop();
            let v = parsed?;
            items.push(v);
            self.skip_flow_ws()?;
            match self.peek() {
                Some(',') => { self.bump(); }
                Some(']') => { self.bump(); return Ok(items); }
                Some(c) => return Err(self.err(format!("unexpected '{c}' in flow array; expected ',' or ']'"))),
                None => return Err(self.err("unterminated flow array")),
            }
        }
    }

    fn parse_flow_table(&mut self) -> Result<IndexMap<String, Value>, ParseError> {
        self.bump(); // consume {
        let mut t: IndexMap<String, Value> = IndexMap::new();
        loop {
            self.skip_flow_ws()?;
            if self.peek() == Some('}') {
                self.bump();
                return Ok(t);
            }
            let key = self.parse_key()?;
            if self.peek() != Some(':') {
                return Err(self.err("expected ':' after flow-table key"));
            }
            self.bump();
            // require at least one space or newline (whitespace is insignificant in flow)
            if !matches!(self.peek(), Some(' ') | Some('\t') | Some('\n') | Some('\r')) {
                return Err(self.err("expected whitespace after ':'"));
            }
            self.skip_flow_ws()?;
            // Path push so OriginalLiteral records on the value get
            // attached to the key path.
            self.path.push(BreadcrumbSegment::Key(key.clone()));
            let parsed = self.parse_inline_value_or_heredoc_in_flow();
            self.path.pop();
            let v = parsed?;
            if t.contains_key(&key) {
                return Err(self.err(format!("duplicate key: {key}")));
            }
            t.insert(key, v);
            self.skip_flow_ws()?;
            match self.peek() {
                Some(',') => { self.bump(); }
                Some('}') => { self.bump(); return Ok(t); }
                Some(c) => return Err(self.err(format!("unexpected '{c}' in flow table; expected ',' or '}}'"))),
                None => return Err(self.err("unterminated flow table")),
            }
        }
    }

    /// Whitespace inside flow forms: spaces, tabs, newlines (LF/CRLF). NO comments.
    fn skip_flow_ws(&mut self) -> Result<(), ParseError> {
        loop {
            match self.peek() {
                Some(' ') | Some('\t') => { self.bump(); }
                Some('\n') => { self.bump(); self.advance_line(); }
                Some('\r') if self.rest().starts_with("\r\n") => {
                    self.pos += 2; self.advance_line();
                }
                Some('#') => return Err(self.err("comments not allowed inside flow forms")),
                Some('/') if self.rest().starts_with("//") => return Err(self.err("comments not allowed inside flow forms")),
                Some('/') if self.rest().starts_with("/*") => return Err(self.err("comments not allowed inside flow forms")),
                _ => return Ok(()),
            }
        }
    }

    /// Inside a flow form, heredocs are not permitted (would need newline-significant content).
    fn parse_inline_value_or_heredoc_in_flow(&mut self) -> Result<Value, ParseError> {
        match self.peek() {
            Some('"') if self.rest().starts_with("\"\"\"") => {
                Err(self.err("heredocs are not allowed inside flow forms"))
            }
            Some('\'') if self.rest().starts_with("'''") => {
                Err(self.err("heredocs are not allowed inside flow forms"))
            }
            _ => self.parse_inline_value_or_heredoc(),
        }
    }

    // ----- post-value -----

    fn consume_after_value(&mut self, allow_eof: bool) -> Result<(), ParseError> {
        let ws_start = self.pos;
        self.skip_inline_ws();
        let had_ws = self.pos > ws_start;
        // Same-line comment after a value attaches as `Trailing` on the
        // node currently identified by `self.path`. Callers (kvpair / list
        // item entry points) push the breadcrumb segment for that node
        // before parsing the value, so `self.path` is correct here.
        let mut trailing: Option<Comment> = None;
        match self.peek() {
            Some('#') if !self.rest().starts_with("###") => {
                if !had_ws {
                    return Err(self.err("expected whitespace before '#' comment"));
                }
                let raw = self.read_line_comment_to_eol();
                trailing = Some(Comment { content: raw, kind: CommentKind::Line });
            }
            Some('/') if self.rest().starts_with("//") => {
                if !had_ws {
                    return Err(self.err("expected whitespace before '//' comment"));
                }
                let raw = self.read_line_comment_to_eol();
                trailing = Some(Comment { content: raw, kind: CommentKind::Line });
            }
            Some('/') if self.rest().starts_with("/*") => {
                let raw = self.read_c_block_comment()?;
                trailing = Some(Comment { content: raw, kind: CommentKind::Block });
            }
            _ => {}
        }
        if let Some(c) = trailing {
            self.comments.push(AttachedComment {
                comment: c,
                position: CommentPosition::Trailing,
                path: self.path.clone(),
            });
        }
        // Now expect EOL, EOF, or — for nested contexts — nothing.
        match self.peek() {
            None => {
                if allow_eof { Ok(()) } else { Ok(()) }
            }
            Some('\n') => { self.bump(); self.advance_line(); Ok(()) }
            Some('\r') if self.rest().starts_with("\r\n") => {
                self.pos += 2; self.advance_line(); Ok(())
            }
            Some(c) => Err(self.err(format!("unexpected character '{c}' after value"))),
        }
    }
}

// ---------- helper structs ----------

struct NumTok {
    len: usize,
    is_float: bool,
}

#[derive(Debug, Clone)]
struct HMod {
    name: String,
    args: Vec<Value>,
}

struct HBody {
    lines: Vec<HLine>,
    strip_depth: usize,
}

struct HLine {
    text: String,
    line: usize,
    line_start: usize,
}

// ---------- number helpers ----------

fn is_base_digit(b: u8, _prev_for_disambig: u8) -> bool {
    // Base-agnostic: any hex digit qualifies. Final base-correctness check
    // happens in parse_integer / parse_nondec_float. Note: 'p' is NOT a
    // hex digit (it's the binary exponent marker for non-decimal floats).
    b.is_ascii_hexdigit()
}

fn parse_integer(s: &str) -> Result<i64, String> {
    let (sign, rest) = if let Some(r) = s.strip_prefix('-') { (-1i128, r) }
        else if let Some(r) = s.strip_prefix('+') { (1i128, r) }
        else { (1i128, s) };
    let (radix, body) = if let Some(r) = rest.strip_prefix("0x") {
        if rest.starts_with("0X") {
            return Err("hex prefix must be lowercase '0x'".into());
        }
        (16u32, r)
    } else if let Some(r) = rest.strip_prefix("0o") {
        (8u32, r)
    } else if let Some(r) = rest.strip_prefix("0b") {
        (2u32, r)
    } else {
        (10u32, rest)
    };
    if body.is_empty() {
        return Err("empty number".into());
    }
    if body.starts_with('_') || body.ends_with('_') {
        return Err("underscore must be between digits".into());
    }
    if radix == 10 && rest.len() > 1 && rest.starts_with('0') {
        return Err("leading zeros are not allowed on decimal integers".into());
    }
    // strip underscores, ensuring they're between digits
    let mut clean = String::with_capacity(body.len());
    let mut prev_is_digit = false;
    for c in body.chars() {
        if c == '_' {
            if !prev_is_digit {
                return Err("underscore must be between digits".into());
            }
            prev_is_digit = false;
        } else {
            if !c.is_digit(radix) {
                return Err(format!("invalid digit '{c}' for base {radix}"));
            }
            clean.push(c);
            prev_is_digit = true;
        }
    }
    if !prev_is_digit {
        return Err("underscore must be between digits".into());
    }
    let n = i128::from_str_radix(&clean, radix)
        .map_err(|_| "integer out of range".to_string())?;
    let signed = sign * n;
    if signed < i64::MIN as i128 || signed > i64::MAX as i128 {
        return Err("integer out of i64 range".into());
    }
    Ok(signed as i64)
}

fn parse_float(s: &str) -> Option<f64> {
    let (sign, rest) = if let Some(r) = s.strip_prefix('-') { (-1.0f64, r) }
        else if let Some(r) = s.strip_prefix('+') { (1.0f64, r) }
        else { (1.0f64, s) };
    if rest.starts_with("0x") || rest.starts_with("0o") || rest.starts_with("0b") {
        return parse_nondec_float(rest).map(|v| sign * v);
    }
    parse_dec_float(rest).map(|v| sign * v)
}

fn parse_dec_float(s: &str) -> Option<f64> {
    // require digit on both sides of '.'
    let (m, e) = if let Some(idx) = s.find(['e', 'E']) {
        (&s[..idx], Some(&s[idx + 1..]))
    } else {
        (s, None)
    };
    if !m.contains('.') {
        return None; // decimal float requires a dot in our spec slice
    }
    let parts: Vec<&str> = m.splitn(2, '.').collect();
    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
        return None;
    }
    if !parts[0].chars().all(|c| c.is_ascii_digit() || c == '_')
        || !parts[1].chars().all(|c| c.is_ascii_digit() || c == '_')
    {
        return None;
    }
    if !valid_underscores(parts[0]) || !valid_underscores(parts[1]) {
        return None;
    }
    let int_part: String = parts[0].chars().filter(|&c| c != '_').collect();
    let frac_part: String = parts[1].chars().filter(|&c| c != '_').collect();
    let mut full = format!("{int_part}.{frac_part}");
    if let Some(es) = e {
        let es_clean = es.trim_start_matches(['+', '-']);
        if es_clean.contains('_') {
            return None;
        }
        if !es.chars().all(|c| c.is_ascii_digit() || c == '+' || c == '-') {
            return None;
        }
        if es_clean.is_empty() {
            return None;
        }
        full.push('e');
        full.push_str(es);
    }
    full.parse::<f64>().ok()
}

fn parse_nondec_float(s: &str) -> Option<f64> {
    // `0xMANT[.FRAC]pEXP`
    let (radix, rest) = if let Some(r) = s.strip_prefix("0x") { (16u32, r) }
        else if let Some(r) = s.strip_prefix("0o") { (8u32, r) }
        else if let Some(r) = s.strip_prefix("0b") { (2u32, r) }
        else { return None; };
    let p_idx = rest.find('p')?;
    let mant = &rest[..p_idx];
    let exp_str = &rest[p_idx + 1..];
    if exp_str.is_empty() {
        return None;
    }
    if exp_str.contains('_') {
        return None;
    }
    let exp_clean = exp_str.trim_start_matches(['+', '-']);
    if !exp_clean.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }
    let exp: i32 = exp_str.parse().ok()?;
    // mantissa: digit on both sides of dot if dot present
    let (int_part, frac_part) = if let Some(idx) = mant.find('.') {
        (&mant[..idx], &mant[idx + 1..])
    } else {
        (mant, "")
    };
    if mant.contains('.') {
        if int_part.is_empty() || frac_part.is_empty() {
            return None;
        }
    }
    if !valid_underscores(int_part) || !valid_underscores(frac_part) {
        return None;
    }
    let int_clean: String = int_part.chars().filter(|&c| c != '_').collect();
    let frac_clean: String = frac_part.chars().filter(|&c| c != '_').collect();
    if int_clean.is_empty() && frac_clean.is_empty() {
        return None;
    }
    if !int_clean.chars().all(|c| c.is_digit(radix)) {
        return None;
    }
    if !frac_clean.chars().all(|c| c.is_digit(radix)) {
        return None;
    }
    let int_val = if int_clean.is_empty() {
        0u128
    } else {
        u128::from_str_radix(&int_clean, radix).ok()?
    };
    let mut frac_val = 0f64;
    let mut div = radix as f64;
    for c in frac_clean.chars() {
        let d = c.to_digit(radix)? as f64;
        frac_val += d / div;
        div *= radix as f64;
    }
    let mantissa = int_val as f64 + frac_val;
    Some(mantissa * (2f64).powi(exp))
}

fn valid_underscores(s: &str) -> bool {
    if s.is_empty() {
        return true;
    }
    if s.starts_with('_') || s.ends_with('_') {
        return false;
    }
    let mut prev_us = false;
    for c in s.chars() {
        if c == '_' {
            if prev_us {
                return false;
            }
            prev_us = true;
        } else {
            prev_us = false;
        }
    }
    true
}

// ---------- date/time validation ----------

fn validate_date(s: &str) -> Result<(), String> {
    if s.len() != 10 {
        return Err("invalid date length".into());
    }
    let b = s.as_bytes();
    if b[4] != b'-' || b[7] != b'-' {
        return Err("invalid date format".into());
    }
    for &i in &[0, 1, 2, 3, 5, 6, 8, 9] {
        if !b[i].is_ascii_digit() {
            return Err("date must be all digits".into());
        }
    }
    let year: u16 = (b[0] - b'0') as u16 * 1000
        + (b[1] - b'0') as u16 * 100
        + (b[2] - b'0') as u16 * 10
        + (b[3] - b'0') as u16;
    let month: u8 = (b[5] - b'0') * 10 + (b[6] - b'0');
    let day: u8 = (b[8] - b'0') * 10 + (b[9] - b'0');
    if month < 1 || month > 12 {
        return Err("month out of range".into());
    }
    if day < 1 || day > days_in_month(year, month) {
        return Err("day out of range".into());
    }
    Ok(())
}

fn validate_time(s: &str) -> Result<(), String> {
    if s.len() != 8 {
        return Err("invalid time length".into());
    }
    let b = s.as_bytes();
    if b[2] != b':' || b[5] != b':' {
        return Err("invalid time format".into());
    }
    for &i in &[0, 1, 3, 4, 6, 7] {
        if !b[i].is_ascii_digit() {
            return Err("time must be all digits".into());
        }
    }
    let hh: u8 = (b[0] - b'0') * 10 + (b[1] - b'0');
    let mm: u8 = (b[3] - b'0') * 10 + (b[4] - b'0');
    let ss: u8 = (b[6] - b'0') * 10 + (b[7] - b'0');
    if hh > 23 {
        return Err("hour out of range".into());
    }
    if mm > 59 {
        return Err("minute out of range".into());
    }
    if ss > 59 {
        return Err("second out of range (leap seconds not supported)".into());
    }
    Ok(())
}

fn days_in_month(year: u16, month: u8) -> u8 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 => if is_leap(year) { 29 } else { 28 },
        _ => 0,
    }
}

fn is_leap(y: u16) -> bool {
    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}

// ---------- heredoc body processing ----------

fn strip_indent_and_continuations(body: &HBody, allow_continuation: bool) -> Result<String, ParseError> {
    let mut out = String::new();
    let mut first = true;
    let mut pending_continuation = false;
    let mut last_line_pos = (1usize, 0usize); // for trailing-continuation error
    for line in &body.lines {
        let raw = &line.text;
        last_line_pos = (line.line, line.line_start);
        // blank lines (only whitespace) are exempt from strip-depth check
        let is_blank = raw.bytes().all(|b| b == b' ' || b == b'\t');
        let stripped: &str = if is_blank {
            ""
        } else {
            // count leading spaces; must be >= strip_depth
            let leading_spaces = raw.bytes().take_while(|&b| b == b' ').count();
            if leading_spaces < body.strip_depth {
                return Err(ParseError {
                    line: line.line,
                    column: leading_spaces + 1,
                    message: format!(
                        "heredoc body line indented {} spaces, less than strip depth {}",
                        leading_spaces, body.strip_depth
                    ),
                });
            }
            &raw[body.strip_depth..]
        };
        let mut piece = stripped.to_string();
        // line continuation: a `\` that is the last non-whitespace char
        let mut splice = false;
        if allow_continuation {
            // scan for trailing \ that is not preceded by an unescaped escape
            let trimmed_end = piece.trim_end_matches(|c: char| c == ' ' || c == '\t').to_string();
            if let Some(last_idx) = trimmed_end.rfind('\\') {
                if last_idx == trimmed_end.len() - 1 {
                    // count preceding backslashes
                    let preceding = trimmed_end[..last_idx]
                        .bytes()
                        .rev()
                        .take_while(|&b| b == b'\\')
                        .count();
                    if preceding % 2 == 0 {
                        // unescaped trailing backslash -> continuation
                        // remove the trailing \ and any following ws
                        piece = trimmed_end[..last_idx].to_string();
                        splice = true;
                    }
                }
            }
        }
        if first {
            out.push_str(&piece);
            first = false;
        } else if pending_continuation {
            // splice into previous: skip leading ws of this line
            let trimmed_start = piece.trim_start_matches(|c: char| c == ' ' || c == '\t');
            // also: a blank line during continuation is consumed entirely
            if !is_blank {
                out.push_str(trimmed_start);
            }
        } else {
            out.push('\n');
            out.push_str(&piece);
        }
        pending_continuation = splice;
    }
    if pending_continuation {
        return Err(ParseError {
            line: last_line_pos.0,
            column: 1,
            message: "trailing line continuation has nothing to splice to".into(),
        });
    }
    Ok(out)
}

fn apply_modifiers(s: String, mods: &[HMod]) -> Result<String, String> {
    let mut cur = s;
    for m in mods {
        match m.name.as_str() {
            "_fold_paragraphs" => {
                if !m.args.is_empty() {
                    return Err("fold_paragraphs() takes no arguments".into());
                }
                cur = fold_paragraphs(&cur);
            }
            "_trim" => {
                if m.args.len() < 2 || m.args.len() > 3 {
                    return Err("trim(chars, where, replacement = \"\") expects 2 or 3 arguments".into());
                }
                let chars = match &m.args[0] {
                    Value::String(s) => s.clone(),
                    _ => return Err("trim: first argument (chars) must be a string".into()),
                };
                let where_s = match &m.args[1] {
                    Value::String(s) => s.clone(),
                    _ => return Err("trim: second argument (where) must be a string".into()),
                };
                let replacement = if m.args.len() == 3 {
                    match &m.args[2] {
                        Value::String(s) => s.clone(),
                        _ => return Err("trim: third argument (replacement) must be a string".into()),
                    }
                } else {
                    String::new()
                };
                cur = apply_trim(&cur, &chars, &where_s, &replacement);
            }
            other => return Err(format!("unknown modifier: {other}")),
        }
    }
    Ok(cur)
}

fn fold_paragraphs(s: &str) -> String {
    // Split by double-newline (paragraph breaks); within each paragraph,
    // join lines with single space.
    let paragraphs: Vec<&str> = s.split("\n\n").collect();
    let folded: Vec<String> = paragraphs
        .iter()
        .map(|p| {
            let lines: Vec<&str> = p.split('\n').collect();
            lines
                .iter()
                .filter(|l| !l.is_empty())
                .copied()
                .collect::<Vec<&str>>()
                .join(" ")
        })
        .collect();
    folded.join("\n")
}

/// Swiss-army trim/replace.
///
/// `chars` = bag of characters to match (any single char in this string).
/// `where_s` = DSL flags. Recognized: `<` leading (whole string), `>`
/// trailing (whole string), `|` per-line edges, `*` every occurrence.
/// Unknown flags are silently ignored for forward compatibility.
/// `replacement` = what each run of matching chars becomes.
///
/// **Run collapse**: consecutive matching chars become one replacement,
/// not one per char.
///
/// If `*` is set, the whole-string and per-line flags become redundant
/// (every run anywhere is replaced). Otherwise, flags are applied in a
/// defined order: per-line (`|`) first, then leading (`<`), then
/// trailing (`>`).
fn apply_trim(s: &str, chars: &str, where_s: &str, replacement: &str) -> String {
    if chars.is_empty() {
        return s.to_string();
    }
    let char_set: std::collections::HashSet<char> = chars.chars().collect();
    let has_star = where_s.contains('*');
    let has_pipe = where_s.contains('|');
    let has_lt = where_s.contains('<');
    let has_gt = where_s.contains('>');
    if !(has_star || has_pipe || has_lt || has_gt) {
        return s.to_string();
    }
    if has_star {
        return replace_all_runs(s, &char_set, replacement);
    }
    let mut cur = s.to_string();
    if has_pipe {
        cur = per_line_edges(&cur, &char_set, replacement);
    }
    if has_lt {
        cur = replace_leading_run(&cur, &char_set, replacement);
    }
    if has_gt {
        cur = replace_trailing_run(&cur, &char_set, replacement);
    }
    cur
}

fn replace_all_runs(s: &str, char_set: &std::collections::HashSet<char>, replacement: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(c) = chars.next() {
        if char_set.contains(&c) {
            while let Some(&nc) = chars.peek() {
                if char_set.contains(&nc) {
                    chars.next();
                } else {
                    break;
                }
            }
            out.push_str(replacement);
        } else {
            out.push(c);
        }
    }
    out
}

fn replace_leading_run(s: &str, char_set: &std::collections::HashSet<char>, replacement: &str) -> String {
    let mut end = 0;
    for (i, c) in s.char_indices() {
        if char_set.contains(&c) {
            end = i + c.len_utf8();
        } else {
            break;
        }
    }
    if end == 0 {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len());
    out.push_str(replacement);
    out.push_str(&s[end..]);
    out
}

fn replace_trailing_run(s: &str, char_set: &std::collections::HashSet<char>, replacement: &str) -> String {
    let mut start = s.len();
    for (i, c) in s.char_indices().rev() {
        if char_set.contains(&c) {
            start = i;
        } else {
            break;
        }
    }
    if start == s.len() {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len());
    out.push_str(&s[..start]);
    out.push_str(replacement);
    out
}

fn per_line_edges(s: &str, char_set: &std::collections::HashSet<char>, replacement: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut lines = s.split('\n').peekable();
    let mut first = true;
    while let Some(line) = lines.next() {
        if !first {
            out.push('\n');
        }
        first = false;
        let trimmed_start = replace_leading_run(line, char_set, replacement);
        let trimmed = replace_trailing_run(&trimmed_start, char_set, replacement);
        out.push_str(&trimmed);
    }
    out
}

// ---------- to_dms emitter ----------

/// Re-emit a parsed `Document` as DMS source. See SPEC §to_dms.
///
/// Contract: `parse(to_dms(parse(source)))` is data-equivalent to
/// `parse(source)`, has the same comments at the same attached paths,
/// and uses the same literal forms for values where preserved
/// (integer base, string form). Float formatting is `ryu`-shortest;
/// indentation is 2 spaces; lists/tables default to block form.
///
/// Round-trip stability: `to_dms(parse(to_dms(parse(source))))` is
/// byte-equal to `to_dms(parse(source))`.
pub fn to_dms(doc: &Document) -> String {
    let mut emitter = DmsEmitter::new(doc);
    emitter.emit_document();
    emitter.out
}

struct DmsEmitter<'a> {
    out: String,
    /// `path -> NodeComments`, with NodeComments being three slices into
    /// `doc.comments` keyed by position. Built in one pass for O(1)
    /// lookup during the walk.
    comments_by_path: std::collections::HashMap<Vec<BreadcrumbSegment>, NodeComments<'a>>,
    /// `path -> &OriginalLiteral`. If two literals exist on the same
    /// path (shouldn't happen — same-path collision means a bug), the
    /// first wins.
    forms_by_path: std::collections::HashMap<Vec<BreadcrumbSegment>, &'a OriginalLiteral>,
    doc: &'a Document,
}

#[derive(Default)]
struct NodeComments<'a> {
    leading: Vec<&'a Comment>,
    trailing: Option<&'a Comment>,
    floating: Vec<&'a Comment>,
}

const INDENT_STR: &str = "  ";

impl<'a> DmsEmitter<'a> {
    fn new(doc: &'a Document) -> Self {
        let mut comments_by_path: std::collections::HashMap<Vec<BreadcrumbSegment>, NodeComments<'a>> =
            std::collections::HashMap::new();
        for ac in &doc.comments {
            let entry = comments_by_path.entry(ac.path.clone()).or_default();
            match ac.position {
                CommentPosition::Leading => entry.leading.push(&ac.comment),
                CommentPosition::Trailing => {
                    // Per parser invariant, at most one trailing comment
                    // per node — keep the first.
                    if entry.trailing.is_none() {
                        entry.trailing = Some(&ac.comment);
                    }
                }
                CommentPosition::Floating => entry.floating.push(&ac.comment),
            }
        }
        let mut forms_by_path: std::collections::HashMap<Vec<BreadcrumbSegment>, &'a OriginalLiteral> =
            std::collections::HashMap::new();
        for (path, lit) in &doc.original_forms {
            forms_by_path.entry(path.clone()).or_insert(lit);
        }
        Self {
            out: String::new(),
            comments_by_path,
            forms_by_path,
            doc,
        }
    }

    fn emit_document(&mut self) {
        // Front matter: emit the `+++` block whenever the parsed doc
        // carried one (meta=Some(...)), even if it's empty. The SPEC
        // §to_dms allows omitting an empty-meta block with no comments,
        // but round-trip data equivalence is cleaner when we preserve
        // the `Some({})` ↔ `None` distinction, since the canonical JSON
        // encoder keys off `meta.is_some()`. Emitting `+++\n+++\n` for
        // an empty-meta doc with no FM comments gives byte-stable round
        // trips through parse/emit.
        let has_fm_comments = self.doc.comments.iter().any(|ac| {
            matches!(ac.path.first(), Some(BreadcrumbSegment::Key(k)) if k == "__fm__")
        });
        let fm_present = self.doc.meta.is_some();
        if fm_present || has_fm_comments {
            self.out.push_str("+++\n");
            // Emit FM keys at indent 0, with paths prefixed by __fm__.
            let fm_path: Vec<BreadcrumbSegment> = vec![BreadcrumbSegment::Key("__fm__".to_string())];
            if let Some(meta) = &self.doc.meta {
                self.emit_table_block(meta, &fm_path, 0);
            } else {
                // No meta keys, only floating FM comments — emit them at
                // the FM root.
                self.emit_floating(&fm_path, 0);
            }
            self.out.push_str("+++\n\n");
        }
        // Body
        let body_path: Vec<BreadcrumbSegment> = Vec::new();
        match &self.doc.body {
            Value::Table(t) => self.emit_table_block(t, &body_path, 0),
            Value::List(items) => self.emit_list_block(items, &body_path, 0),
            other => {
                // Scalar root: emit any leading comments, then the value
                // on its own line, then trailing.
                let leading: Vec<&Comment> = self
                    .comments_by_path
                    .get(&body_path)
                    .map(|nc| nc.leading.iter().copied().collect())
                    .unwrap_or_default();
                for c in leading {
                    self.emit_comment_line(c, 0);
                }
                self.emit_value_inline(other, &body_path);
                self.emit_trailing_for(&body_path);
                self.out.push('\n');
                let floating: Vec<&Comment> = self
                    .comments_by_path
                    .get(&body_path)
                    .map(|nc| nc.floating.iter().copied().collect())
                    .unwrap_or_default();
                for c in floating {
                    self.emit_comment_line(c, 0);
                }
            }
        }
    }

    fn emit_table_block(
        &mut self,
        t: &IndexMap<String, Value>,
        path: &[BreadcrumbSegment],
        indent: usize,
    ) {
        for (k, v) in t {
            let mut child_path: Vec<BreadcrumbSegment> = path.to_vec();
            child_path.push(BreadcrumbSegment::Key(k.clone()));
            // Leading comments for this child node
            if let Some(nc) = self.comments_by_path.get(&child_path) {
                let leading: Vec<&Comment> = nc.leading.iter().copied().collect();
                for c in leading {
                    self.emit_comment_line(c, indent);
                }
            }
            // The "key:" line. For block-form children (non-empty
            // table/list), we use `key:\n` then a deeper-indented block;
            // for inline values, `key: value`.
            //
            // Exception: if the container has a trailing comment AND its
            // contents are all flow-safe (scalars + nested flow-safe
            // containers, no heredocs, no comments inside), we emit it
            // as flow form so the trailing comment can sit on the same
            // line. Block-form would force us to emit `key:\n  # ...`,
            // which the parser rejects (`:<space>#` is not valid
            // inline-value syntax).
            let has_trailing = self
                .comments_by_path
                .get(&child_path)
                .map(|nc| nc.trailing.is_some())
                .unwrap_or(false);
            let can_block = matches!(v,
                Value::Table(tt) if !tt.is_empty()
            ) || matches!(v,
                Value::List(items) if !items.is_empty()
            );
            let needs_block = can_block
                && !(has_trailing && self.is_flow_safe(v, &child_path));
            self.push_indent(indent);
            self.out.push_str(&format_key(k));
            self.out.push(':');
            if needs_block {
                self.out.push('\n');
                match v {
                    Value::Table(tt) => self.emit_table_block(tt, &child_path, indent + 1),
                    Value::List(items) => self.emit_list_block(items, &child_path, indent + 1),
                    _ => unreachable!(),
                }
            } else {
                self.out.push(' ');
                self.emit_value_inline(v, &child_path);
                self.emit_trailing_for(&child_path);
                self.out.push('\n');
            }
        }
        // Floating comments attached to this container
        self.emit_floating(path, indent);
    }

    fn emit_list_block(
        &mut self,
        items: &[Value],
        path: &[BreadcrumbSegment],
        indent: usize,
    ) {
        for (i, v) in items.iter().enumerate() {
            let mut child_path: Vec<BreadcrumbSegment> = path.to_vec();
            child_path.push(BreadcrumbSegment::Index(i));
            if let Some(nc) = self.comments_by_path.get(&child_path) {
                let leading: Vec<&Comment> = nc.leading.iter().copied().collect();
                for c in leading {
                    self.emit_comment_line(c, indent);
                }
            }
            self.push_indent(indent);
            self.out.push('+');
            match v {
                Value::Table(tt) if !tt.is_empty() => {
                    // List of tables: emit first key inline on the `+ key:`
                    // line for tighter idiomatic output, with siblings at
                    // (indent+1)*2-column. For simplicity we use the
                    // emit-as-block-after-colon shape for all keys: emit
                    // the tabel as a child block at indent+1.
                    self.emit_trailing_for(&child_path);
                    self.out.push('\n');
                    self.emit_table_block(tt, &child_path, indent + 1);
                }
                Value::List(inner) if !inner.is_empty() => {
                    self.emit_trailing_for(&child_path);
                    self.out.push('\n');
                    self.emit_list_block(inner, &child_path, indent + 1);
                }
                _ => {
                    self.out.push(' ');
                    self.emit_value_inline(v, &child_path);
                    self.emit_trailing_for(&child_path);
                    self.out.push('\n');
                }
            }
        }
        self.emit_floating(path, indent);
    }

    fn emit_value_inline(&mut self, v: &Value, path: &[BreadcrumbSegment]) {
        match v {
            Value::Bool(b) => self.out.push_str(if *b { "true" } else { "false" }),
            Value::Integer(n) => self.emit_integer(*n, path),
            Value::Float(f) => self.emit_float(*f),
            Value::String(s) => self.emit_string(s, path),
            Value::OffsetDateTime(s)
            | Value::LocalDateTime(s)
            | Value::LocalDate(s)
            | Value::LocalTime(s) => self.out.push_str(s),
            Value::List(items) => {
                if items.is_empty() {
                    self.out.push_str("[]");
                } else {
                    // Non-empty list emitted as flow form would clash with
                    // our block-form rule, but inline contexts only arrive
                    // here for empty lists (block-form path takes
                    // non-empty). Defensive: emit flow-form.
                    self.out.push('[');
                    for (i, item) in items.iter().enumerate() {
                        if i > 0 {
                            self.out.push_str(", ");
                        }
                        let mut sub_path: Vec<BreadcrumbSegment> = path.to_vec();
                        sub_path.push(BreadcrumbSegment::Index(i));
                        self.emit_value_inline(item, &sub_path);
                    }
                    self.out.push(']');
                }
            }
            Value::Table(t) => {
                if t.is_empty() {
                    self.out.push_str("{}");
                } else {
                    self.out.push('{');
                    let mut first = true;
                    for (k, vv) in t {
                        if !first {
                            self.out.push_str(", ");
                        }
                        first = false;
                        self.out.push_str(&format_key(k));
                        self.out.push_str(": ");
                        let mut sub_path: Vec<BreadcrumbSegment> = path.to_vec();
                        sub_path.push(BreadcrumbSegment::Key(k.clone()));
                        self.emit_value_inline(vv, &sub_path);
                    }
                    self.out.push('}');
                }
            }
        }
    }

    fn emit_integer(&mut self, n: i64, path: &[BreadcrumbSegment]) {
        if let Some(lit_ref) = self.forms_by_path.get(path).copied() {
            if let OriginalLiteral::Integer { lit } = lit_ref {
                self.out.push_str(lit.as_str());
                return;
            }
        }
        self.out.push_str(&n.to_string());
    }

    fn emit_float(&mut self, f: f64) {
        if f.is_nan() {
            self.out.push_str("nan");
        } else if f.is_infinite() {
            self.out.push_str(if f > 0.0 { "inf" } else { "-inf" });
        } else {
            let mut buf = ryu::Buffer::new();
            self.out.push_str(buf.format(f));
        }
    }

    fn emit_string(&mut self, s: &str, path: &[BreadcrumbSegment]) {
        let form_opt = self
            .forms_by_path
            .get(path)
            .and_then(|lit| match lit {
                OriginalLiteral::String { form } => Some(form.clone()),
                _ => None,
            });
        let form = form_opt.unwrap_or(StringForm::Basic);
        match form {
            StringForm::Basic => {
                self.out.push('"');
                self.out.push_str(&escape_basic(s));
                self.out.push('"');
            }
            StringForm::Literal => {
                self.out.push('\'');
                self.out.push_str(s);
                self.out.push('\'');
            }
            StringForm::Heredoc { flavor, label, modifiers } => {
                // The stored body is post-modifier. For byte-stable
                // round-trips, the emitted source must re-produce the
                // same post-modifier value under re-application. For
                // idempotent modifiers this is mostly automatic; for
                // `_fold_paragraphs()` (which joins lines within a
                // paragraph with spaces), we pre-expand each `\n` in
                // the stored value into a `\n\n` paragraph break so
                // that the re-applied modifier preserves (not merges)
                // line boundaries.
                let body_to_emit = if modifiers.iter().any(|m| m.name == "_fold_paragraphs") {
                    s.replace('\n', "\n\n")
                } else {
                    s.to_string()
                };
                self.emit_heredoc(&body_to_emit, flavor, label.as_deref(), &modifiers);
            }
        }
    }

    fn emit_heredoc(
        &mut self,
        body: &str,
        flavor: HeredocFlavor,
        label: Option<&str>,
        modifiers: &[HeredocModifierCall],
    ) {
        // The "current line" the kvpair was just written on already has
        // text up to and including ": " (or "+ "). The caller's
        // `emit_value_inline` placed us right after the space. We compute
        // the kvpair's indent from the most recent newline in `out`.
        let kv_indent_spaces = {
            let bytes = self.out.as_bytes();
            let mut last_nl = None;
            for i in (0..bytes.len()).rev() {
                if bytes[i] == b'\n' {
                    last_nl = Some(i);
                    break;
                }
            }
            let line_start = last_nl.map(|i| i + 1).unwrap_or(0);
            // count leading spaces on this line
            let mut k = line_start;
            let mut n = 0usize;
            while k < bytes.len() && bytes[k] == b' ' {
                n += 1;
                k += 1;
            }
            n
        };
        let body_indent_str = " ".repeat(kv_indent_spaces + INDENT_STR.len());
        let term_indent_str = " ".repeat(kv_indent_spaces + INDENT_STR.len());
        let opener = match flavor {
            HeredocFlavor::BasicTriple => "\"\"\"",
            HeredocFlavor::LiteralTriple => "'''",
        };
        self.out.push_str(opener);
        if let Some(lbl) = label {
            self.out.push_str(lbl);
        }
        for m in modifiers {
            self.out.push(' ');
            self.out.push_str(&m.name);
            self.out.push('(');
            for (i, a) in m.args.iter().enumerate() {
                if i > 0 {
                    self.out.push_str(", ");
                }
                self.emit_modifier_arg(a);
            }
            self.out.push(')');
        }
        self.out.push('\n');
        // Body lines, indented to body_indent_str. The body is the
        // post-strip content as stored in Value::String — to round-trip
        // through the parser, we re-indent each line to body_indent and
        // emit a final body line that is empty (so the terminator
        // appears alone on its line). The parser's strip-indent logic
        // then yields the same content.
        if body.is_empty() {
            // Emit just the terminator on its own line.
        } else {
            for line in body.split('\n') {
                if line.is_empty() {
                    // Blank source line — output just newline (no
                    // indentation prefix, matches strip-indent rule).
                    self.out.push('\n');
                } else {
                    self.out.push_str(&body_indent_str);
                    self.out.push_str(line);
                    self.out.push('\n');
                }
            }
        }
        // Terminator
        self.out.push_str(&term_indent_str);
        let terminator = match (flavor, label) {
            (HeredocFlavor::BasicTriple, None) => "\"\"\"".to_string(),
            (HeredocFlavor::LiteralTriple, None) => "'''".to_string(),
            (_, Some(lbl)) => lbl.to_string(),
        };
        self.out.push_str(&terminator);
    }

    fn emit_modifier_arg(&mut self, v: &Value) {
        match v {
            Value::Bool(b) => self.out.push_str(if *b { "true" } else { "false" }),
            Value::Integer(n) => self.out.push_str(&n.to_string()),
            Value::Float(f) => {
                if f.is_nan() {
                    self.out.push_str("nan");
                } else if f.is_infinite() {
                    self.out.push_str(if *f > 0.0 { "inf" } else { "-inf" });
                } else {
                    let mut buf = ryu::Buffer::new();
                    self.out.push_str(buf.format(*f));
                }
            }
            Value::String(s) => {
                self.out.push('"');
                self.out.push_str(&escape_basic(s));
                self.out.push('"');
            }
            Value::OffsetDateTime(s)
            | Value::LocalDateTime(s)
            | Value::LocalDate(s)
            | Value::LocalTime(s) => self.out.push_str(s),
            Value::List(_) | Value::Table(_) => {
                // Modifier args are scalars in practice; defensive
                // fallback emits empty container literal.
                if matches!(v, Value::List(_)) {
                    self.out.push_str("[]");
                } else {
                    self.out.push_str("{}");
                }
            }
        }
    }

    fn emit_comment_line(&mut self, c: &Comment, indent: usize) {
        // The comment's `content` field preserves its original internal
        // whitespace verbatim (block-comment body lines retain their
        // own indentation, line comments are single-line text). Only
        // the FIRST line gets re-indented to the current emit context;
        // subsequent body lines keep their stored indentation, so a
        // round-trip is byte-stable. The terminator on a `###LABEL`
        // block sits on its own line, also stored verbatim.
        let text = &c.content;
        let prefix = INDENT_STR.repeat(indent);
        if !text.contains('\n') {
            self.out.push_str(&prefix);
            self.out.push_str(text);
            self.out.push('\n');
            return;
        }
        let mut first = true;
        for line in text.split('\n') {
            if !first {
                self.out.push('\n');
            }
            if first {
                self.out.push_str(&prefix);
                first = false;
            }
            self.out.push_str(line);
        }
        self.out.push('\n');
    }

    fn emit_trailing_for(&mut self, path: &[BreadcrumbSegment]) {
        if let Some(nc) = self.comments_by_path.get(path) {
            if let Some(t) = nc.trailing {
                self.out.push_str("  ");
                self.out.push_str(&c_oneliner(t));
            }
        }
    }

    fn emit_floating(&mut self, path: &[BreadcrumbSegment], indent: usize) {
        if let Some(nc) = self.comments_by_path.get(path) {
            let floating: Vec<&Comment> = nc.floating.iter().copied().collect();
            for c in floating {
                self.emit_comment_line(c, indent);
            }
        }
    }

    fn push_indent(&mut self, indent: usize) {
        for _ in 0..indent {
            self.out.push_str(INDENT_STR);
        }
    }

    /// True if `v` (rooted at `path`) can be safely emitted as a flow
    /// form: no heredoc strings (heredocs require own line), no
    /// comments attached to any descendant. Used to decide flow-vs-block
    /// when a trailing comment forces flow form.
    fn is_flow_safe(&self, v: &Value, path: &[BreadcrumbSegment]) -> bool {
        // Any descendant comment ⇒ unsafe, since flow form has no
        // place to put it. We check by scanning all comment paths.
        for ac in &self.doc.comments {
            if ac.path.len() > path.len() && ac.path.starts_with(path) {
                // descendant has a comment
                return false;
            }
        }
        match v {
            Value::String(_) => {
                // If the string's original form is a heredoc, it's not
                // flow-safe (heredocs require their own line).
                if let Some(OriginalLiteral::String { form: StringForm::Heredoc { .. } }) =
                    self.forms_by_path.get(path).copied()
                {
                    return false;
                }
                true
            }
            Value::List(items) => {
                for (i, item) in items.iter().enumerate() {
                    let mut sub: Vec<BreadcrumbSegment> = path.to_vec();
                    sub.push(BreadcrumbSegment::Index(i));
                    if !self.is_flow_safe(item, &sub) {
                        return false;
                    }
                }
                true
            }
            Value::Table(t) => {
                for (k, vv) in t {
                    let mut sub: Vec<BreadcrumbSegment> = path.to_vec();
                    sub.push(BreadcrumbSegment::Key(k.clone()));
                    if !self.is_flow_safe(vv, &sub) {
                        return false;
                    }
                }
                true
            }
            _ => true,
        }
    }
}

/// Emit a comment whose content fits on one line, returning the text to
/// place after a value (no leading whitespace included). For block
/// comments that span multiple lines, the result still contains
/// newlines — only used in the `emit_trailing_for` path where the
/// parser invariant guarantees a single-line trailing comment.
fn c_oneliner(c: &Comment) -> String {
    c.content.clone()
}

fn escape_basic(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => {
                out.push_str(&format!("\\u{:04X}", c as u32));
            }
            c => out.push(c),
        }
    }
    out
}

fn format_key(k: &str) -> String {
    // If the key is a valid bare key (and non-empty), emit bare;
    // otherwise quote it. Bare key: `[A-Za-z0-9_-]+` plus unicode
    // letters/digits per `is_bare_key_char`.
    if !k.is_empty() && k.chars().all(is_bare_key_char) {
        k.to_string()
    } else {
        // Use literal quoting if the key contains no single quotes,
        // basic otherwise.
        if !k.contains('\'') && !k.contains('\n') && !k.contains('\r') {
            format!("'{}'", k)
        } else {
            format!("\"{}\"", escape_basic(k))
        }
    }
}

// ---------- tests ----------

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_table(src: &str) -> IndexMap<String, Value> {
        match parse(src).expect("parse failed") {
            Value::Table(t) => t,
            other => panic!("expected table, got {other:?}"),
        }
    }

    #[test]
    fn empty_doc() {
        assert!(parse_table("").is_empty());
    }

    #[test]
    fn single_int() {
        let t = parse_table("n: 42\n");
        assert_eq!(t["n"], Value::Integer(42));
    }

    #[test]
    fn hex_int() {
        let t = parse_table("n: 0xFF\n");
        assert_eq!(t["n"], Value::Integer(255));
    }

    #[test]
    fn float_dec() {
        let t = parse_table("n: 3.14\n");
        if let Value::Float(f) = t["n"] {
            assert!((f - 3.14).abs() < 1e-12);
        } else {
            panic!();
        }
    }

    #[test]
    fn float_inf() {
        let t = parse_table("n: inf\n");
        assert_eq!(t["n"], Value::Float(f64::INFINITY));
    }

    #[test]
    fn flow_array() {
        let t = parse_table("xs: [1, 2, 3]\n");
        if let Value::List(v) = &t["xs"] {
            assert_eq!(v.len(), 3);
        } else {
            panic!();
        }
    }

    #[test]
    fn flow_table() {
        let t = parse_table("p: {x: 1, y: 2}\n");
        if let Value::Table(v) = &t["p"] {
            assert_eq!(v.len(), 2);
        } else {
            panic!();
        }
    }

    #[test]
    fn nested_table() {
        let t = parse_table("a:\n  b: 1\n  c: 2\n");
        if let Value::Table(v) = &t["a"] {
            assert_eq!(v.len(), 2);
        } else {
            panic!();
        }
    }

    #[test]
    fn nested_list() {
        let t = parse_table("xs:\n  + 1\n  + 2\n  + 3\n");
        if let Value::List(v) = &t["xs"] {
            assert_eq!(v.len(), 3);
        } else {
            panic!();
        }
    }

    #[test]
    fn list_of_tables() {
        let src = "servers:\n  + name: \"web1\"\n    port: 80\n  + name: \"web2\"\n    port: 81\n";
        let t = parse_table(src);
        if let Value::List(v) = &t["servers"] {
            assert_eq!(v.len(), 2);
        } else {
            panic!();
        }
    }

    #[test]
    fn heredoc_basic() {
        let src = "doc: \"\"\"EOF\n    hello\n    world\n    EOF\n";
        let t = parse_table(src);
        assert_eq!(t["doc"], Value::String("hello\nworld".into()));
    }

    #[test]
    fn date_only() {
        let t = parse_table("d: 1979-05-27\n");
        assert_eq!(t["d"], Value::LocalDate("1979-05-27".into()));
    }

    #[test]
    fn offset_dt() {
        let t = parse_table("d: 1979-05-27T07:32:00Z\n");
        assert_eq!(t["d"], Value::OffsetDateTime("1979-05-27T07:32:00Z".into()));
    }

    #[test]
    fn rejects_tier1_in_front_matter() {
        let err = parse("+++\n_dms_tier: 1\n+++\nx: 1\n").unwrap_err();
        assert!(
            err.message.contains("not supported"),
            "expected tier-1 rejection, got: {}",
            err.message
        );
    }

    #[test]
    fn accepts_explicit_tier_zero() {
        let t = match parse_document("+++\n_dms_tier: 0\n+++\nx: 1\n").unwrap().body {
            Value::Table(t) => t,
            other => panic!("expected table, got {other:?}"),
        };
        assert_eq!(t["x"], Value::Integer(1));
    }

    // ----- comment-attachment tests (tier-0 feature) -----

    fn key(s: &str) -> BreadcrumbSegment {
        BreadcrumbSegment::Key(s.to_string())
    }

    #[test]
    fn comment_leading_attaches_to_next_kvpair() {
        // # leading\nport: 8080
        let doc = parse_document("# leading\nport: 8080\n").unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Leading);
        assert_eq!(ac.path, vec![key("port")]);
        assert_eq!(ac.comment.kind, CommentKind::Line);
        assert_eq!(ac.comment.content, "# leading");
    }

    #[test]
    fn comment_blank_line_gap_is_floating_on_container() {
        // # floating\n\nport: 8080  → blank line separates → floating on root
        let doc = parse_document("# floating\n\nport: 8080\n").unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Floating);
        // root path
        assert!(ac.path.is_empty(), "expected empty path, got {:?}", ac.path);
        assert_eq!(ac.comment.content, "# floating");
    }

    #[test]
    fn comment_trailing_on_same_line() {
        // port: 8080   # default
        let doc = parse_document("port: 8080   # default\n").unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Trailing);
        assert_eq!(ac.path, vec![key("port")]);
        assert_eq!(ac.comment.content, "# default");
    }

    #[test]
    fn comment_floating_at_end_of_block() {
        // a:\n  x: 1\n  # leftover            (no sibling follows in `a`)
        let src = "a:\n  x: 1\n  # leftover\n";
        let doc = parse_document(src).unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Floating);
        assert_eq!(ac.path, vec![key("a")]);
        assert_eq!(ac.comment.content, "# leftover");
    }

    #[test]
    fn block_comment_attaches_as_leading() {
        let src = "###\nNOTE\n###\nport: 8080\n";
        let doc = parse_document(src).unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Leading);
        assert_eq!(ac.path, vec![key("port")]);
        assert_eq!(ac.comment.kind, CommentKind::Block);
        assert!(
            ac.comment.content.starts_with("###") && ac.comment.content.ends_with("###"),
            "expected delimiters preserved, got: {:?}",
            ac.comment.content
        );
    }

    #[test]
    fn front_matter_comment_recorded_with_fm_prefix() {
        // +++\n# meta-leading\nauthor: \"x\"\n+++\nbody: 1\n
        let src = "+++\n# meta-leading\nauthor: \"x\"\n+++\nbody: 1\n";
        let doc = parse_document(src).unwrap();
        assert_eq!(doc.comments.len(), 1);
        let ac = &doc.comments[0];
        assert_eq!(ac.position, CommentPosition::Leading);
        assert_eq!(ac.path, vec![key("__fm__"), key("author")]);
        assert_eq!(ac.comment.content, "# meta-leading");
    }

    #[test]
    fn document_with_no_comments_has_empty_comments_vec() {
        let doc = parse_document("a: 1\nb: 2\n").unwrap();
        assert!(doc.comments.is_empty(), "expected no comments, got {:?}", doc.comments);
    }

    // ----- to_dms emitter tests (round-trip contract) -----

    fn roundtrip(src: &str) -> String {
        let doc = parse_document(src).expect("parse failed");
        to_dms(&doc)
    }

    #[test]
    fn to_dms_int_base_preserved() {
        let src = "a: 0x1F40\nb: 0o755\nc: 0b1010_0110\nd: 1_000_000\ne: +42\nf: -7\n";
        let doc = parse_document(src).unwrap();
        let out = to_dms(&doc);
        assert!(out.contains("a: 0x1F40"), "out:\n{out}");
        assert!(out.contains("b: 0o755"), "out:\n{out}");
        assert!(out.contains("c: 0b1010_0110"), "out:\n{out}");
        assert!(out.contains("d: 1_000_000"), "out:\n{out}");
        assert!(out.contains("e: +42"), "out:\n{out}");
        assert!(out.contains("f: -7"), "out:\n{out}");
        // round-trip preserves integer values
        let doc2 = parse_document(&out).unwrap();
        assert_eq!(doc.body, doc2.body);
    }

    #[test]
    fn to_dms_string_forms_preserved() {
        let src = concat!(
            "basic: \"hello\"\n",
            "lit: 'C:\\path'\n",
            "hd_b_lab: \"\"\"END\n",
            "  hello\n",
            "  END\n",
            "hd_b_unl: \"\"\"\n",
            "  one\n",
            "  \"\"\"\n",
            "hd_l_lab: '''END\n",
            "  raw\n",
            "  END\n",
            "hd_l_unl: '''\n",
            "  raw2\n",
            "  '''\n",
        );
        let doc = parse_document(src).unwrap();
        let out = to_dms(&doc);
        assert!(out.contains("basic: \"hello\""), "out:\n{out}");
        assert!(out.contains("lit: 'C:\\path'"), "out:\n{out}");
        assert!(out.contains("\"\"\"END"), "out:\n{out}");
        assert!(out.contains("'''END"), "out:\n{out}");
        // Round-trip values match.
        let doc2 = parse_document(&out).unwrap();
        assert_eq!(doc.body, doc2.body);
    }

    #[test]
    fn to_dms_heredoc_modifiers_preserved() {
        let src = concat!(
            "msg: \"\"\"END _trim(\"\\n\", \">\")\n",
            "  >  hi\n",
            "  END\n",
        );
        let doc = parse_document(src).unwrap();
        let out = to_dms(&doc);
        assert!(out.contains("_trim("), "expected _trim() in output:\n{out}");
        // Round-trip value match.
        let doc2 = parse_document(&out).unwrap();
        assert_eq!(doc.body, doc2.body);
    }

    #[test]
    fn to_dms_comments_at_attached_paths() {
        let src = concat!(
            "# leading on a\n",
            "a: 1   # trailing on a\n",
            "b:\n",
            "  x: 2\n",
            "  # floating in b\n",
        );
        let doc = parse_document(src).unwrap();
        let out = to_dms(&doc);
        // Re-parse and check comment paths survived.
        let doc2 = parse_document(&out).unwrap();
        assert_eq!(doc2.comments.len(), 3);
        let mut have_leading = false;
        let mut have_trailing = false;
        let mut have_floating = false;
        for ac in &doc2.comments {
            match ac.position {
                CommentPosition::Leading => {
                    if ac.path == vec![key("a")] {
                        have_leading = true;
                    }
                }
                CommentPosition::Trailing => {
                    if ac.path == vec![key("a")] {
                        have_trailing = true;
                    }
                }
                CommentPosition::Floating => {
                    if ac.path == vec![key("b")] {
                        have_floating = true;
                    }
                }
            }
        }
        assert!(have_leading && have_trailing && have_floating, "out:\n{out}\ncomments: {:?}", doc2.comments);
    }

    #[test]
    fn to_dms_front_matter_omitted_when_empty() {
        let src = "x: 1\n";
        let out = roundtrip(src);
        assert!(!out.contains("+++"), "expected no front matter in output:\n{out}");
    }

    #[test]
    fn to_dms_second_round_byte_stable() {
        let cases = [
            "a: 1\nb: 2\n",
            "# leading\nport: 0x1F40   # trailing\n",
            "+++\nauthor: \"x\"\n+++\nbody: 1\n",
            concat!(
                "msg: \"\"\"END\n",
                "  hello world\n",
                "  END\n",
                "ints:\n",
                "  + 0xFF\n",
                "  + 1_000\n",
            ),
            "items: [1, 2, 3]\np: {x: 1, y: 2}\n",
        ];
        for src in cases {
            let out1 = to_dms(&parse_document(src).unwrap());
            let out2 = to_dms(&parse_document(&out1).unwrap());
            assert_eq!(out1, out2, "round-trip not stable for src:\n{src}\nout1:\n{out1}\nout2:\n{out2}");
        }
    }

    // -- Modification scenarios (decode → edit → re-encode) --------------
    //
    // The SPEC §Comments §Round-trip semantics promises:
    //   "comments on still-present nodes travel with them; newly inserted
    //    nodes carry no comments; deleted nodes drop theirs."
    // These tests exercise that promise with explicit add / update / delete
    // mutations between parse and emit.

    fn count_comments_at_path(doc: &Document, path: &[BreadcrumbSegment]) -> usize {
        doc.comments.iter().filter(|c| c.path == path).count()
    }

    #[test]
    fn to_dms_value_update_preserves_attached_comments() {
        // Update a leaf value; leading + trailing comments on its kvpair
        // must travel with it.
        let src = "# the listening port\nport: 8080   # default for staging\nhost: \"localhost\"\n";
        let mut doc = parse_document(src).unwrap();
        if let Value::Table(ref mut t) = doc.body {
            // Replace 8080 with 5432.
            *t.get_mut("port").unwrap() = Value::Integer(5432);
        } else {
            panic!("expected table");
        }
        let emitted = to_dms(&doc);
        let doc2 = parse_document(&emitted).unwrap();
        // Value updated.
        if let Value::Table(t) = &doc2.body {
            assert_eq!(t["port"], Value::Integer(5432));
        } else {
            panic!("expected table after re-parse");
        }
        // Comments still attached to `port`.
        let port = vec![BreadcrumbSegment::Key("port".to_string())];
        let leading = doc2.comments.iter().filter(|c| c.path == port && c.position == CommentPosition::Leading).count();
        let trailing = doc2.comments.iter().filter(|c| c.path == port && c.position == CommentPosition::Trailing).count();
        assert_eq!(leading, 1, "leading comment on `port` should survive update; got doc2.comments = {:?}", doc2.comments);
        assert_eq!(trailing, 1, "trailing comment on `port` should survive update; got doc2.comments = {:?}", doc2.comments);
    }

    #[test]
    fn to_dms_deleted_key_drops_attached_comments() {
        // Delete a kvpair; its leading + trailing comments must NOT
        // survive (the node they attached to is gone).
        let src = "# keep this\nkeep: 1   # me too\n# drop this\ndrop: 2   # bye\n";
        let mut doc = parse_document(src).unwrap();
        if let Value::Table(ref mut t) = doc.body {
            t.shift_remove("drop");
        } else {
            panic!("expected table");
        }
        let emitted = to_dms(&doc);
        let doc2 = parse_document(&emitted).unwrap();
        // `drop` is gone.
        if let Value::Table(t) = &doc2.body {
            assert!(!t.contains_key("drop"), "deleted key should not reappear");
            assert!(t.contains_key("keep"), "non-deleted key should remain");
        } else {
            panic!("expected table after re-parse");
        }
        // No comments at the deleted path.
        let drop_path = vec![BreadcrumbSegment::Key("drop".to_string())];
        assert_eq!(
            count_comments_at_path(&doc2, &drop_path),
            0,
            "deleted key's comments must not survive; got: {:?}",
            doc2.comments
        );
        // `keep`'s comments still there.
        let keep_path = vec![BreadcrumbSegment::Key("keep".to_string())];
        let keep_leading = doc2.comments.iter().filter(|c| c.path == keep_path && c.position == CommentPosition::Leading).count();
        let keep_trailing = doc2.comments.iter().filter(|c| c.path == keep_path && c.position == CommentPosition::Trailing).count();
        assert_eq!(keep_leading, 1, "leading comment on `keep` must survive sibling deletion");
        assert_eq!(keep_trailing, 1, "trailing comment on `keep` must survive sibling deletion");
    }

    #[test]
    fn to_dms_inserted_key_carries_no_comments() {
        // Insert a new kvpair; it must come back with zero attached
        // comments.
        let src = "# leading on existing\nexisting: 1   # trailing on existing\n";
        let mut doc = parse_document(src).unwrap();
        if let Value::Table(ref mut t) = doc.body {
            t.insert("inserted".to_string(), Value::String("new".to_string()));
        } else {
            panic!("expected table");
        }
        let emitted = to_dms(&doc);
        let doc2 = parse_document(&emitted).unwrap();
        // Both keys present.
        if let Value::Table(t) = &doc2.body {
            assert_eq!(t["existing"], Value::Integer(1));
            assert_eq!(t["inserted"], Value::String("new".to_string()));
        } else {
            panic!("expected table after re-parse");
        }
        // Inserted key has no comments.
        let inserted_path = vec![BreadcrumbSegment::Key("inserted".to_string())];
        assert_eq!(
            count_comments_at_path(&doc2, &inserted_path),
            0,
            "newly inserted key must not pick up comments; got: {:?}",
            doc2.comments
        );
        // Existing key's comments preserved.
        let existing_path = vec![BreadcrumbSegment::Key("existing".to_string())];
        assert_eq!(
            count_comments_at_path(&doc2, &existing_path),
            2,
            "existing key's leading + trailing comments must survive insertion of a sibling"
        );
    }

    #[test]
    fn to_dms_combined_mutations() {
        // Stress: do all three at once — update one value, delete one
        // key, insert a new one. Each should behave per the SPEC
        // independently.
        let src = "# A\na: 1   # a-trail\n# B\nb: 2   # b-trail\n# C\nc: 3   # c-trail\n";
        let mut doc = parse_document(src).unwrap();
        if let Value::Table(ref mut t) = doc.body {
            *t.get_mut("a").unwrap() = Value::Integer(100);  // update
            t.shift_remove("b");                              // delete
            t.insert("d".to_string(), Value::Integer(4));     // insert
        } else {
            panic!("expected table");
        }
        let emitted = to_dms(&doc);
        let doc2 = parse_document(&emitted).unwrap();
        if let Value::Table(t) = &doc2.body {
            assert_eq!(t["a"], Value::Integer(100));
            assert!(!t.contains_key("b"));
            assert_eq!(t["c"], Value::Integer(3));
            assert_eq!(t["d"], Value::Integer(4));
        } else {
            panic!("expected table after re-parse");
        }
        let p = |k: &str| vec![BreadcrumbSegment::Key(k.to_string())];
        // a: leading + trailing survived update
        assert_eq!(count_comments_at_path(&doc2, &p("a")), 2, "a should keep both comments");
        // b: deleted, no comments
        assert_eq!(count_comments_at_path(&doc2, &p("b")), 0, "b should be gone with its comments");
        // c: untouched, both comments present
        assert_eq!(count_comments_at_path(&doc2, &p("c")), 2, "c should keep both comments");
        // d: inserted, no comments
        assert_eq!(count_comments_at_path(&doc2, &p("d")), 0, "d should have no comments");
    }
}
