Skip to main content

noyalib/cst/
annotated.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Comment-aware read view over a [`crate::cst::Document`].
5//!
6//! The CST already round-trips comments byte-for-byte through
7//! [`Document::set`] and friends — touched spans are rewritten,
8//! everything else (indentation, comments, blank lines) is left alone.
9//! What this module adds is the *read* side: given a path-shaped
10//! query, return the human-authored YAML comments that decorate the
11//! node at that path. Tools (linters, AI agents reading config files,
12//! IDE plugins) need that to understand what each field *means*.
13//!
14//! # Definitions
15//!
16//! - **Inline comment**: a `#`-introduced comment on the *same* line
17//!   as the node's content, after that content. `key: val # inline`.
18//! - **Leading comments**: a contiguous run of comment-only or blank
19//!   lines immediately above the node's first byte. `# pre\nkey: val`
20//!   yields one leading comment.
21//!
22//! # Examples
23//!
24//! ```
25//! use noyalib::cst::parse_document;
26//!
27//! let src = "# top of file\nname: noyalib  # the project\n# next field\nversion: 0.0.1\n";
28//! let doc = parse_document(src).unwrap();
29//!
30//! let name = doc.comments_at("name");
31//! assert_eq!(name.before.len(), 1);
32//! assert_eq!(name.before[0].text, " top of file");
33//! assert_eq!(name.inline.as_ref().unwrap().text, " the project");
34//!
35//! let version = doc.comments_at("version");
36//! assert_eq!(version.before.len(), 1);
37//! assert_eq!(version.before[0].text, " next field");
38//! assert!(version.inline.is_none());
39//! ```
40
41use crate::comments::Comment;
42use crate::cst::Document;
43use crate::error::{Error, Result};
44use crate::prelude::*;
45
46/// Comments that decorate a single node, organised by their position
47/// relative to the node.
48///
49/// Returned by [`Document::comments_at`]. Both fields are empty /
50/// `None` when no comments decorate the queried path or when the path
51/// does not resolve to a node in the document.
52///
53/// # Examples
54///
55/// ```
56/// use noyalib::cst::parse_document;
57///
58/// let doc = parse_document("port: 8080  # the listen port\n").unwrap();
59/// let bundle = doc.comments_at("port");
60/// assert!(bundle.before.is_empty());
61/// assert_eq!(bundle.inline.unwrap().text, " the listen port");
62/// ```
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct CommentBundle {
65    /// Contiguous run of comment lines that appear immediately above
66    /// the node, in source order. An interleaved blank line does not
67    /// break the run — only another content node does. Empty when
68    /// the node has no leading comments.
69    pub before: Vec<Comment>,
70    /// The trailing comment on the node's own line, if any. `None`
71    /// when no `#`-introduced comment follows the node's content on
72    /// the same source line. Multi-line nodes (block mappings,
73    /// block sequences) do not have an inline comment in this sense
74    /// — query individual entries instead.
75    pub inline: Option<Comment>,
76}
77
78impl CommentBundle {
79    /// `true` when the bundle has neither a leading nor an inline
80    /// comment. Convenience for the common "decide whether to render"
81    /// branch in tooling.
82    #[must_use]
83    pub fn is_empty(&self) -> bool {
84        self.before.is_empty() && self.inline.is_none()
85    }
86}
87
88impl Document {
89    /// Comments decorating the node at `path`, classified by position.
90    ///
91    /// Returns an empty [`CommentBundle`] when `path` does not
92    /// resolve. Path syntax matches [`Document::span_at`] —
93    /// `foo.bar`, `items[0]`, `items[0].name`. Wildcard /
94    /// recursive-descent segments are not supported (a non-singular
95    /// span has no canonical "above" line).
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use noyalib::cst::parse_document;
101    ///
102    /// let src = "# A multi-line\n# leading block\nport: 8080  # inline\n";
103    /// let doc = parse_document(src).unwrap();
104    ///
105    /// let b = doc.comments_at("port");
106    /// assert_eq!(b.before.len(), 2);
107    /// assert_eq!(b.inline.as_ref().unwrap().text, " inline");
108    /// ```
109    #[must_use]
110    pub fn comments_at(&self, path: &str) -> CommentBundle {
111        let Some((start, end)) = self.comment_anchor_span(path) else {
112            return CommentBundle::default();
113        };
114
115        // Comment scanning is independent of edits — always run on the
116        // current source. The comment count is small (one per line at
117        // most), so per-call scanning is cheaper than caching.
118        let comments = match crate::comments::load_comments(self.source()) {
119            Ok(c) => c,
120            // A document that successfully parsed cannot fail comment
121            // scan — but if it does, treat as no comments rather than
122            // bubbling up: this is a read-only convenience API.
123            Err(_) => return CommentBundle::default(),
124        };
125
126        let src = self.source();
127        // The leading run is measured from the entry's own line; the inline
128        // comment is still the one following the value on the value's line.
129        let lead_anchor = self.leading_comment_anchor(path).unwrap_or(start);
130        let line_start_idx = line_start(src, lead_anchor);
131        let line_end_idx = line_end(src, end.saturating_sub(1).max(start));
132
133        let mut bundle = CommentBundle::default();
134
135        // Inline only applies to single-line nodes — a multi-line
136        // block (mapping, sequence) does not have a single inline
137        // comment of its own; query individual entries instead.
138        let is_single_line = !src[start..end].contains('\n');
139        if is_single_line {
140            for c in &comments {
141                if c.start >= end && c.start <= line_end_idx {
142                    bundle.inline = Some(c.clone());
143                    break;
144                }
145            }
146        }
147
148        // Leading: walk *upwards* from the line containing `start`,
149        // collecting comment-only lines and skipping pure-blank lines.
150        // Stop at the first line containing non-comment content (or at
151        // the start of input).
152        let mut cursor = line_start_idx;
153        let mut acc: Vec<Comment> = Vec::new();
154        while cursor > 0 {
155            // `cursor` points at the first byte of a line; step back
156            // to the previous line's start.
157            let prev_line_end = cursor - 1; // the '\n' that ended the previous line
158            let prev_line_start = line_start(src, prev_line_end.saturating_sub(1));
159            let line_text = &src[prev_line_start..prev_line_end];
160            let trimmed = line_text.trim_start_matches([' ', '\t']);
161
162            if trimmed.is_empty() {
163                // Blank line — does not break the run, does not
164                // contribute a comment.
165                cursor = prev_line_start;
166                continue;
167            }
168            if trimmed.starts_with('#') {
169                // Comment-only line. Find the matching scanned comment
170                // (we already have spans for all of them).
171                if let Some(c) = comments
172                    .iter()
173                    .find(|c| c.start >= prev_line_start && c.start < prev_line_end)
174                {
175                    acc.push(c.clone());
176                }
177                cursor = prev_line_start;
178                continue;
179            }
180            // Content line — stop walking up.
181            break;
182        }
183
184        // Walked upward, so the natural order is bottom-up; reverse to
185        // restore source order.
186        acc.reverse();
187        bundle.before = acc;
188
189        bundle
190    }
191
192    /// Set (or replace) the **inline** comment on the single-line node
193    /// at `path` — the `#`-introduced comment that follows the value on
194    /// the same line.
195    ///
196    /// `text` is the comment body without the leading `#`; it renders as
197    /// `# <text>` (a single space after `#`, or a bare `#` when `text`
198    /// is empty). If the node already has an inline comment, its body is
199    /// replaced in place, keeping the existing separating whitespace;
200    /// otherwise `  # <text>` is appended after the value.
201    ///
202    /// Guarded like the other mutators: the edit must re-parse and leave
203    /// the document's typed value unchanged (a comment carries no data),
204    /// or it is rolled back.
205    ///
206    /// # Errors
207    ///
208    /// - `path` does not resolve to a node.
209    /// - The node spans multiple lines — it has no inline comment of its
210    ///   own; comment its entries instead.
211    /// - `text` contains a newline (a comment is a single line).
212    /// - The splice would not re-parse or would change data (roll back).
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use noyalib::cst::parse_document;
218    ///
219    /// let mut doc = parse_document("port: 8080\n").unwrap();
220    /// doc.set_inline_comment("port", "the listen port").unwrap();
221    /// assert_eq!(doc.source(), "port: 8080  # the listen port\n");
222    /// doc.set_inline_comment("port", "changed").unwrap();
223    /// assert_eq!(doc.source(), "port: 8080  # changed\n");
224    /// ```
225    pub fn set_inline_comment(&mut self, path: &str, text: &str) -> Result<()> {
226        if text.contains('\n') {
227            return Err(Error::Parse(format!(
228                "set_inline_comment: comment text for `{path}` contains a newline; \
229                 an inline comment is a single line"
230            )));
231        }
232        let Some((start, end)) = self.comment_anchor_span(path) else {
233            return Err(Error::Parse(format!(
234                "set_inline_comment: path `{path}` did not resolve to a node"
235            )));
236        };
237        if self.source()[start..end].contains('\n') {
238            return Err(Error::Parse(format!(
239                "set_inline_comment: `{path}` is a multi-line node and has no inline \
240                 comment of its own; comment its individual entries instead"
241            )));
242        }
243
244        let rendered = if text.is_empty() {
245            "#".to_string()
246        } else {
247            format!("# {text}")
248        };
249        let existing = self.comments_at(path).inline;
250        let snapshot = self.clone();
251        let expected = self.as_value().clone();
252
253        let splice = match existing {
254            Some(c) => self.replace_span(c.start, c.end, &rendered),
255            None => self.replace_span(end, end, &format!("  {rendered}")),
256        };
257        self.finish_comment_edit("set_inline_comment", path, splice, snapshot, &expected)
258    }
259
260    /// Remove the **inline** comment on the node at `path`, if any,
261    /// taking the separating whitespace with it so no trailing space is
262    /// left. A no-op returning `Ok(())` when the node has no inline
263    /// comment (or the path does not resolve).
264    ///
265    /// Guarded and rolled back exactly like
266    /// [`set_inline_comment`](Self::set_inline_comment).
267    ///
268    /// # Errors
269    ///
270    /// - The removal would not re-parse or would change data (rolls
271    ///   back). A missing comment or path is a no-op, not an error.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use noyalib::cst::parse_document;
277    ///
278    /// let mut doc = parse_document("port: 8080  # noise\n").unwrap();
279    /// doc.remove_inline_comment("port").unwrap();
280    /// assert_eq!(doc.source(), "port: 8080\n");
281    /// ```
282    pub fn remove_inline_comment(&mut self, path: &str) -> Result<()> {
283        let Some((_start, end)) = self.comment_anchor_span(path) else {
284            return Ok(());
285        };
286        let Some(c) = self.comments_at(path).inline else {
287            return Ok(());
288        };
289        let snapshot = self.clone();
290        let expected = self.as_value().clone();
291        // The value ends at `end`; the bytes from there to `c.end` are
292        // the separating whitespace plus the `# …` comment.
293        let splice = self.replace_span(end, c.end, "");
294        self.finish_comment_edit("remove_inline_comment", path, splice, snapshot, &expected)
295    }
296
297    /// Set (or replace) the **leading** comment block above the mapping
298    /// entry at `path` — the run of comment lines that
299    /// `comments_at(path).before` reports.
300    ///
301    /// `text` becomes one comment line per `\n`-separated segment, each
302    /// rendered at the key's indentation as `# <segment>` (a bare `#`
303    /// for an empty segment). An existing leading block is replaced in
304    /// place; otherwise the block is inserted immediately above the
305    /// entry's line.
306    ///
307    /// Scope: block **mapping keys**, whatever their value spans. The
308    /// comment goes above the key's own line, so a value that runs on for
309    /// twenty lines changes nothing about where it lands. Sequence items
310    /// have no key token and so no entry line to anchor on; they remain a
311    /// follow-up. Guarded like the other mutators: the edit must re-parse
312    /// and leave the typed value unchanged, or it rolls back.
313    ///
314    /// # Errors
315    ///
316    /// - `path` does not address a block-mapping key.
317    /// - The splice would not re-parse or would change data (roll back).
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use noyalib::cst::parse_document;
323    ///
324    /// let mut doc = parse_document("port: 8080\n").unwrap();
325    /// doc.set_leading_comment("port", "the listen port").unwrap();
326    /// assert_eq!(doc.source(), "# the listen port\nport: 8080\n");
327    /// doc.set_leading_comment("port", "line one\nline two").unwrap();
328    /// assert_eq!(doc.source(), "# line one\n# line two\nport: 8080\n");
329    /// ```
330    pub fn set_leading_comment(&mut self, path: &str, text: &str) -> Result<()> {
331        let (key_start, entry_line_start, indent) = self.leading_comment_site(path)?;
332        let _ = key_start;
333        let nl = comment_line_break(self.source());
334        let rendered: String = text
335            .split('\n')
336            .map(|line| {
337                if line.is_empty() {
338                    format!("{indent}#{nl}")
339                } else {
340                    format!("{indent}# {line}{nl}")
341                }
342            })
343            .collect();
344
345        let before = self.comments_at(path).before;
346        let snapshot = self.clone();
347        let expected = self.as_value().clone();
348
349        let splice = match (before.first(), before.last()) {
350            (Some(first), Some(last)) => {
351                let block_start = line_start(self.source(), first.start);
352                let block_end = line_end(self.source(), last.start) + 1;
353                self.replace_span(block_start, block_end, &rendered)
354            }
355            _ => self.replace_span(entry_line_start, entry_line_start, &rendered),
356        };
357        self.finish_comment_edit("set_leading_comment", path, splice, snapshot, &expected)
358    }
359
360    /// Remove the **leading** comment block above the mapping entry at
361    /// `path`, if any. A no-op returning `Ok(())` when there is none (or
362    /// the path does not address a mapping key).
363    ///
364    /// Guarded and rolled back exactly like
365    /// [`set_leading_comment`](Self::set_leading_comment).
366    ///
367    /// # Errors
368    ///
369    /// - The removal would not re-parse or would change data (rolls
370    ///   back).
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// use noyalib::cst::parse_document;
376    ///
377    /// let mut doc = parse_document("# noise\n# more\nport: 8080\n").unwrap();
378    /// doc.remove_leading_comment("port").unwrap();
379    /// assert_eq!(doc.source(), "port: 8080\n");
380    /// ```
381    pub fn remove_leading_comment(&mut self, path: &str) -> Result<()> {
382        // A path that is not a mapping key simply has no leading block
383        // this method owns — treat as a no-op.
384        if self.leading_comment_site(path).is_err() {
385            return Ok(());
386        }
387        let before = self.comments_at(path).before;
388        let (Some(first), Some(last)) = (before.first(), before.last()) else {
389            return Ok(());
390        };
391        let block_start = line_start(self.source(), first.start);
392        let block_end = line_end(self.source(), last.start) + 1;
393        let snapshot = self.clone();
394        let expected = self.as_value().clone();
395        let splice = self.replace_span(block_start, block_end, "");
396        self.finish_comment_edit("remove_leading_comment", path, splice, snapshot, &expected)
397    }
398
399    /// Resolve `path` to a block-mapping key and return its key-token
400    /// start, its line start, and the indent (whitespace) prefix of that
401    /// line. `Err` for anything that is not such a key.
402    ///
403    /// The value's own extent is not consulted: a leading comment sits
404    /// above the key's line, so how many lines the value occupies decides
405    /// nothing here. This refused a multi-line entry until the leading
406    /// anchor moved to the key, which had put every block-valued key out of
407    /// reach of the one leading-comment API that already anchored correctly.
408    fn leading_comment_site(&self, path: &str) -> Result<(usize, usize, String)> {
409        let Some((key_start, _key_end)) = self.key_span(path) else {
410            return Err(Error::Parse(format!(
411                "leading comment: `{path}` does not address a block-mapping key"
412            )));
413        };
414        if self.comment_anchor_span(path).is_none() {
415            return Err(Error::Parse(format!(
416                "leading comment: `{path}` did not resolve to a value"
417            )));
418        }
419        let ls = line_start(self.source(), key_start);
420        let indent = self.source()[ls..key_start].to_string();
421        Ok((key_start, ls, indent))
422    }
423
424    /// Shared tail for the comment mutators: apply the re-parse and
425    /// value-unchanged guard, rolling back to `snapshot` on any failure.
426    fn finish_comment_edit(
427        &mut self,
428        op: &str,
429        path: &str,
430        splice: Result<()>,
431        snapshot: Self,
432        expected: &crate::Value,
433    ) -> Result<()> {
434        if let Err(e) = splice {
435            *self = snapshot;
436            return Err(Error::Parse(format!(
437                "{op}: editing the comment on `{path}` could not be spliced ({e}); \
438                 the document was left unchanged"
439            )));
440        }
441        if let Err(e) = self.validate() {
442            *self = snapshot;
443            return Err(Error::Parse(format!(
444                "{op}: editing the comment on `{path}` left the document unable to \
445                 re-parse ({e}); the document was left unchanged"
446            )));
447        }
448        if super::document::oracle_rejects(&self.as_value(), expected) {
449            *self = snapshot;
450            return Err(Error::Parse(format!(
451                "{op}: editing the comment on `{path}` changed the document's data; \
452                 the document was left unchanged"
453            )));
454        }
455        Ok(())
456    }
457}
458
459#[inline]
460fn line_start(src: &str, byte: usize) -> usize {
461    let bytes = src.as_bytes();
462    let mut i = byte.min(bytes.len());
463    while i > 0 && bytes[i - 1] != b'\n' {
464        i -= 1;
465    }
466    i
467}
468
469#[inline]
470fn line_end(src: &str, byte: usize) -> usize {
471    let bytes = src.as_bytes();
472    let mut i = byte.min(bytes.len().saturating_sub(1));
473    while i < bytes.len() && bytes[i] != b'\n' {
474        i += 1;
475    }
476    i
477}
478
479/// Where a comment sits relative to the node it decorates.
480///
481/// Mirrors the two positions [`CommentBundle`] reports, so a read with
482/// [`Document::comments_at`] and a write with
483/// [`Document::set_comment`] address the same thing.
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485pub enum CommentPosition {
486    /// The trailing comment on the node's own line.
487    Inline,
488    /// The contiguous run of comment lines immediately above the node.
489    Before,
490}
491
492impl Document {
493    /// Set the comment at `path` in the given position.
494    ///
495    /// Replaces an existing comment, or writes a new one when there is
496    /// none. `text` is the comment body without the leading `#`; a
497    /// single leading space is added when `text` does not already begin
498    /// with whitespace, so `set_comment(p, Inline, "note")` yields
499    /// `# note`.
500    ///
501    /// For [`CommentPosition::Before`], a `text` containing newlines
502    /// becomes one `#` line per line, each at the node's own
503    /// indentation.
504    ///
505    /// The edit goes through [`Document::replace_span`], so it inherits
506    /// the same guard: an edit that would make the document re-parse
507    /// differently is rejected rather than written.
508    ///
509    /// # Errors
510    ///
511    /// - `path` does not resolve to a node.
512    /// - The resulting document would not re-parse to the same value.
513    ///
514    /// # Examples
515    ///
516    /// ```
517    /// use noyalib::cst::{parse_document, CommentPosition};
518    ///
519    /// let mut doc = parse_document("port: 8080\n").unwrap();
520    /// doc.set_comment("port", CommentPosition::Inline, "listen port").unwrap();
521    /// assert_eq!(doc.source(), "port: 8080  # listen port\n");
522    /// ```
523    pub fn set_comment(&mut self, path: &str, position: CommentPosition, text: &str) -> Result<()> {
524        let p = path.to_owned();
525        let txt = text.to_owned();
526        self.guarded_comment_edit("set_comment", move |d| {
527            d.set_comment_inner(&p, position, &txt)
528        })
529    }
530
531    fn set_comment_inner(
532        &mut self,
533        path: &str,
534        position: CommentPosition,
535        text: &str,
536    ) -> Result<()> {
537        // A comment body cannot contain a line break: a bare `\r` (or
538        // `\r\n`) ends the comment token in YAML, so the remainder of
539        // the text would land *outside* the comment and change — or
540        // break — the document (found by fuzz_editors: an inline text
541        // of "t[[\r---" split the document in two). `Before` accepts
542        // `\n` by documented design (one `#` line per line) but the
543        // same carriage-return breakout applies to each line.
544        let breaking = match position {
545            CommentPosition::Inline => text.contains('\n') || text.contains('\r'),
546            CommentPosition::Before => text.contains('\r'),
547        };
548        if breaking {
549            return Err(Error::Parse(format!(
550                "set_comment: comment text for `{path}` contains a line \
551                 break that would end the comment token and leak into the \
552                 document; inline comments take a single line, and Before \
553                 comments split on `\n` only"
554            )));
555        }
556        let Some((start, end)) = self.comment_anchor_span(path) else {
557            return Err(Error::Parse(format!(
558                "set_comment: path `{path}` does not resolve"
559            )));
560        };
561        let bundle = self.comments_at(path);
562
563        match position {
564            CommentPosition::Inline => {
565                let body = normalise_body(text);
566                if let Some(existing) = bundle.inline {
567                    self.replace_span(existing.start, existing.end, &format!("#{body}"))
568                } else {
569                    // No inline comment yet: append at the end of the
570                    // node's own line, before the *whole* break. Landing
571                    // on the `\n` of a `\r\n` would splice between the
572                    // two and strand a lone `\r`.
573                    let line_end = line_break_start(self.source(), end);
574                    self.replace_span(line_end, line_end, &format!("  #{body}"))
575                }
576            }
577            CommentPosition::Before => {
578                // Both the indent and the insertion point come from the
579                // entry's own line. Taking them from a block collection's
580                // value would indent the comment one level too deep and
581                // splice it inside the block, where it documents the first
582                // child instead of the entry.
583                let anchor = self.leading_comment_anchor(path).unwrap_or(start);
584                let indent = indent_of_line_containing(self.source(), anchor);
585                let nl = comment_line_break(self.source());
586                let block: String = text
587                    .split('\n')
588                    .map(|l| format!("{indent}#{}{nl}", normalise_body(l)))
589                    .collect();
590                if let (Some(first), Some(last)) = (bundle.before.first(), bundle.before.last()) {
591                    // Replace the existing run, including the newline
592                    // that terminates its last line.
593                    let end_of_run = line_end_from(self.source(), last.end) + 1;
594                    let start_of_run = line_start_from(self.source(), first.start);
595                    self.replace_span(start_of_run, end_of_run.min(self.source().len()), &block)
596                } else {
597                    let line_start = line_start_from(self.source(), anchor);
598                    self.replace_span(line_start, line_start, &block)
599                }
600            }
601        }
602    }
603
604    /// Remove the comment at `path` in the given position.
605    ///
606    /// A no-op when there is no comment there. For
607    /// [`CommentPosition::Inline`] the whitespace separating the
608    /// comment from the node's content goes with it, so no trailing
609    /// spaces are left behind. For [`CommentPosition::Before`] the
610    /// whole run of comment lines is removed.
611    ///
612    /// # Errors
613    ///
614    /// As [`Document::set_comment`].
615    ///
616    /// # Examples
617    ///
618    /// ```
619    /// use noyalib::cst::{parse_document, CommentPosition};
620    ///
621    /// let mut doc = parse_document("port: 8080  # note\n").unwrap();
622    /// doc.remove_comment("port", CommentPosition::Inline).unwrap();
623    /// assert_eq!(doc.source(), "port: 8080\n");
624    /// ```
625    pub fn remove_comment(&mut self, path: &str, position: CommentPosition) -> Result<()> {
626        let p = path.to_owned();
627        self.guarded_comment_edit("remove_comment", move |doc| {
628            doc.remove_comment_inner(&p, position)
629        })
630    }
631
632    fn remove_comment_inner(&mut self, path: &str, position: CommentPosition) -> Result<()> {
633        if self.comment_anchor_span(path).is_none() {
634            return Err(Error::Parse(format!(
635                "remove_comment: path `{path}` does not resolve"
636            )));
637        }
638        let bundle = self.comments_at(path);
639        match position {
640            CommentPosition::Inline => {
641                let Some(c) = bundle.inline else {
642                    return Ok(());
643                };
644                // Take the run of whitespace before the `#` too.
645                let from = trim_back_whitespace(self.source(), c.start);
646                self.replace_span(from, c.end, "")
647            }
648            CommentPosition::Before => {
649                let (Some(first), Some(last)) = (bundle.before.first(), bundle.before.last())
650                else {
651                    return Ok(());
652                };
653                let start_of_run = line_start_from(self.source(), first.start);
654                let end_of_run =
655                    (line_end_from(self.source(), last.end) + 1).min(self.source().len());
656                self.replace_span(start_of_run, end_of_run, "")
657            }
658        }
659    }
660}
661
662impl Document {
663    /// Run a comment edit, then require the document's *value* to be
664    /// unchanged.
665    ///
666    /// Comments are trivia: an edit to them that alters what the
667    /// document means is a bug by definition. Enforcing that as an
668    /// invariant beats reasoning about every context, because the
669    /// contexts are not obvious. A folded block scalar is the case that
670    /// found this:
671    ///
672    /// ```text
673    /// >        set_comment("", Inline, "")        >  #
674    /// ```
675    ///
676    /// Appending `  #` to a block scalar does not write a comment — the
677    /// text becomes scalar *content*, and the value changes from `">"`
678    /// to `">  #"`. The `fuzz_editors` target found it within a minute
679    /// of existing.
680    fn guarded_comment_edit<F>(&mut self, what: &str, edit: F) -> Result<()>
681    where
682        F: FnOnce(&mut Self) -> Result<()>,
683    {
684        // Both loads run under the document's own configuration, so a
685        // document that only opens with a relaxed budget is guarded
686        // too instead of silently skipping the check.
687        let before = crate::parser::parse_one_value(self.source(), self.config()).ok();
688        let snapshot = self.clone();
689        edit(self)?;
690
691        if let Some(before) = before {
692            let unchanged = matches!(
693                crate::parser::parse_one_value(self.source(), self.config()),
694                Ok(a) if a == before
695            );
696            if !unchanged {
697                *self = snapshot;
698                return Err(Error::Parse(format!(
699                    "{what}: the edit would change the document's value, not just its \
700                     comments — it was left unchanged. This happens where a `#` is not \
701                     a comment, such as inside a block scalar."
702                )));
703            }
704        }
705        Ok(())
706    }
707}
708
709/// Give a comment body exactly one leading space unless it already
710/// starts with whitespace, so `"note"` and `" note"` both render as
711/// `# note`.
712fn normalise_body(text: &str) -> String {
713    if text.starts_with(char::is_whitespace) || text.is_empty() {
714        text.to_owned()
715    } else {
716        format!(" {text}")
717    }
718}
719
720/// Byte index of the newline ending the line containing `idx`, or the
721/// end of the source when the last line is unterminated.
722fn line_end_from(src: &str, idx: usize) -> usize {
723    src[idx..].find('\n').map_or(src.len(), |n| idx + n)
724}
725
726/// Byte index where the line break ending the line containing `idx`
727/// *begins* — the `\r` of a `\r\n`, not the `\n`.
728///
729/// [`line_end_from`] answers "where is the `\n`", which is the right
730/// question for reading a line and the wrong one for appending to it: an
731/// insertion there lands between the `\r` and the `\n`.
732fn line_break_start(src: &str, idx: usize) -> usize {
733    let end = line_end_from(src, idx);
734    if end > idx && src.as_bytes()[end - 1] == b'\r' {
735        end - 1
736    } else {
737        end
738    }
739}
740
741/// The line break to give a comment line this edit adds.
742///
743/// `"\r\n"` only when the document is wholly CRLF — every `\n` preceded
744/// by a `\r`. A mixed document has no convention to honour, so it keeps
745/// the `"\n"` default rather than being rewritten to a guess.
746fn comment_line_break(src: &str) -> &'static str {
747    let bytes = src.as_bytes();
748    let mut saw = false;
749    for (i, &b) in bytes.iter().enumerate() {
750        if b == b'\n' {
751            if i == 0 || bytes[i - 1] != b'\r' {
752                return "\n";
753            }
754            saw = true;
755        }
756    }
757    if saw { "\r\n" } else { "\n" }
758}
759
760/// Byte index just after the newline preceding `idx`, i.e. the start of
761/// the line containing it.
762fn line_start_from(src: &str, idx: usize) -> usize {
763    src[..idx].rfind('\n').map_or(0, |n| n + 1)
764}
765
766/// Walk back over spaces and tabs immediately before `idx`.
767fn trim_back_whitespace(src: &str, idx: usize) -> usize {
768    let bytes = src.as_bytes();
769    let mut i = idx;
770    while i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
771        i -= 1;
772    }
773    i
774}
775
776/// The leading whitespace of the line containing `idx`.
777fn indent_of_line_containing(src: &str, idx: usize) -> String {
778    let start = line_start_from(src, idx);
779    src[start..]
780        .chars()
781        .take_while(|c| *c == ' ' || *c == '\t')
782        .collect()
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use crate::cst::parse_document;
789
790    #[test]
791    fn inline_comment_on_simple_value() {
792        let doc = parse_document("port: 8080  # the listen port\n").unwrap();
793        let b = doc.comments_at("port");
794        assert!(b.before.is_empty(), "expected empty, got {:?}", b.before);
795        assert_eq!(b.inline.as_ref().unwrap().text, " the listen port");
796    }
797
798    #[test]
799    fn leading_single_comment() {
800        let doc = parse_document("# pre\nkey: val\n").unwrap();
801        let b = doc.comments_at("key");
802        assert_eq!(b.before.len(), 1);
803        assert_eq!(b.before[0].text, " pre");
804        assert!(b.inline.is_none());
805    }
806
807    #[test]
808    fn leading_multi_with_blank_lines_preserves_run() {
809        let doc = parse_document(
810            "# first\n\
811             \n\
812             # second\n\
813             key: val\n",
814        )
815        .unwrap();
816        let b = doc.comments_at("key");
817        assert_eq!(b.before.len(), 2);
818        assert_eq!(b.before[0].text, " first");
819        assert_eq!(b.before[1].text, " second");
820    }
821
822    #[test]
823    fn content_line_breaks_leading_run() {
824        let doc = parse_document(
825            "name: noyalib\n\
826             # this comment belongs to version, not name\n\
827             version: 0.0.1\n",
828        )
829        .unwrap();
830        let name = doc.comments_at("name");
831        assert!(
832            name.before.is_empty(),
833            "expected empty, got {:?}",
834            name.before
835        );
836        let version = doc.comments_at("version");
837        assert_eq!(version.before.len(), 1);
838        assert!(version.before[0].text.contains("belongs to version"));
839    }
840
841    #[test]
842    fn nested_path_inline_comment() {
843        let doc = parse_document(
844            "server:\n\
845             \x20 host: localhost  # bind address\n\
846             \x20 port: 8080\n",
847        )
848        .unwrap();
849        let host = doc.comments_at("server.host");
850        assert_eq!(host.inline.as_ref().unwrap().text, " bind address");
851        let port = doc.comments_at("server.port");
852        assert!(port.inline.is_none());
853    }
854
855    #[test]
856    fn unknown_path_returns_empty_bundle() {
857        let doc = parse_document("a: 1\n").unwrap();
858        let b = doc.comments_at("nonexistent");
859        assert!(b.is_empty(), "expected empty, got {b:?}");
860    }
861
862    #[test]
863    fn comments_survive_lossless_edit() {
864        let mut doc = parse_document(
865            "# version is bumped by Renovate\n\
866             version: 0.0.1  # do not edit by hand\n",
867        )
868        .unwrap();
869        doc.set("version", "0.0.2").unwrap();
870        let b = doc.comments_at("version");
871        assert_eq!(b.before.len(), 1);
872        assert_eq!(b.before[0].text, " version is bumped by Renovate");
873        assert_eq!(b.inline.as_ref().unwrap().text, " do not edit by hand");
874        assert!(doc.to_string().contains("version: 0.0.2"));
875        assert!(doc.to_string().contains("# version is bumped by Renovate"));
876        assert!(doc.to_string().contains("# do not edit by hand"));
877    }
878
879    #[test]
880    fn multiline_block_does_not_inherit_child_inline() {
881        // Bug guard: querying a multi-line block must not return the
882        // inline comment of its last child entry. The user's mental
883        // model of `comments_at("server")` is "comments on `server`",
884        // not "comments on `server.port`".
885        let doc =
886            parse_document("server:\n  host: localhost\n  port: 8080  # main HTTP port\n").unwrap();
887        let server = doc.comments_at("server");
888        assert!(
889            server.inline.is_none(),
890            "block must not inherit child inline"
891        );
892        let port = doc.comments_at("server.port");
893        assert_eq!(port.inline.as_ref().unwrap().text, " main HTTP port");
894    }
895
896    #[test]
897    fn sequence_item_inline_comment() {
898        let doc = parse_document(
899            "items:\n\
900             \x20 - one  # the first\n\
901             \x20 - two  # the second\n",
902        )
903        .unwrap();
904        let first = doc.comments_at("items[0]");
905        assert_eq!(first.inline.as_ref().unwrap().text, " the first");
906        let second = doc.comments_at("items[1]");
907        assert_eq!(second.inline.as_ref().unwrap().text, " the second");
908    }
909}