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}