Skip to main content

rs_hack/commands/
comments.rs

1//! `comments` command: list every comment span in a set of files, apply a hash-guarded
2//! batch of delete/replace edits to them, and verify that code is unchanged once comments
3//! are ignored.
4//!
5//! syn drops `//` and `/* */` comments and turns `///` / `//!` into `#[doc]` attributes, so
6//! it cannot answer "where are the comments". This module runs a small lexer over the raw
7//! source that understands strings, raw strings, char literals vs lifetimes and nested block
8//! comments, and uses syn only for the questions syn is good at: which item a comment is
9//! attached to, whether it sits inside a fn body, and whether two files are the same code.
10//!
11//! Every `apply` is gated by the same `verify` check before anything is written, and records
12//! a whole-file backup under one run_id so `rs-hack revert <run_id>` undoes the batch.
13
14use std::collections::{BTreeMap, HashMap, HashSet};
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result, anyhow, bail};
18use proc_macro2::{Delimiter, LineColumn, Spacing, TokenStream, TokenTree};
19use quote::ToTokens;
20use serde::{Deserialize, Serialize};
21use syn::visit::Visit;
22
23use crate::files::collect_rust_files_with_exclusions;
24use crate::operations::{BackupNode, NodeLocation};
25use crate::state::{
26    FileModification, RunMetadata, RunStatus, generate_run_id, hash_file, load_run_metadata,
27    save_backup_nodes, save_run_metadata,
28};
29
30/// Node type recorded on the whole-file backup an `apply` run saves; `restore_from_nodes`
31/// writes `original_content` straight back for it.
32pub const FILE_BACKUP_NODE_TYPE: &str = "file";
33
34// ---------------------------------------------------------------------------------------------
35// Lexer
36// ---------------------------------------------------------------------------------------------
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum CommentKind {
41    /// `// ...` (and `//// ...`, which rustc does not treat as doc)
42    Line,
43    /// `/* ... */` (and `/*** ... */`, `/**/`)
44    Block,
45    /// `/// ...` or `/** ... */` — becomes an outer `#[doc]` attribute
46    Doc,
47    /// `//! ...` or `/*! ... */` — becomes an inner `#![doc]` attribute
48    InnerDoc,
49}
50
51/// One comment token as the lexer sees it: byte range `[start, end)`, never including the
52/// trailing newline of a line comment.
53#[derive(Debug, Clone, Copy)]
54struct RawComment {
55    start: usize,
56    end: usize,
57    kind: CommentKind,
58    is_block: bool,
59}
60
61const fn is_ident_char(b: u8) -> bool {
62    b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
63}
64
65fn classify_line(text: &[u8]) -> CommentKind {
66    if text.starts_with(b"//!") {
67        CommentKind::InnerDoc
68    } else if text.starts_with(b"///") && !text.starts_with(b"////") {
69        CommentKind::Doc
70    } else {
71        CommentKind::Line
72    }
73}
74
75fn classify_block(text: &[u8]) -> CommentKind {
76    if text.starts_with(b"/*!") {
77        CommentKind::InnerDoc
78    } else if text.starts_with(b"/**") && !text.starts_with(b"/***") && text != b"/**/" {
79        CommentKind::Doc
80    } else {
81        CommentKind::Block
82    }
83}
84
85/// Skip a quoted string body starting just after the opening `"`; returns the offset just past
86/// the closing quote.
87const fn skip_string(b: &[u8], mut i: usize) -> usize {
88    while i < b.len() {
89        match b[i] {
90            b'\\' => i += 2,
91            b'"' => return i + 1,
92            _ => i += 1,
93        }
94    }
95    b.len()
96}
97
98/// Skip a raw string: `i` points at the first `#` or `"` after the `r`. Returns the offset past
99/// the terminator, or None if this is not a raw string (e.g. a raw identifier `r#foo`).
100fn skip_raw_string(b: &[u8], mut i: usize) -> Option<usize> {
101    let mut hashes = 0;
102    while i < b.len() && b[i] == b'#' {
103        hashes += 1;
104        i += 1;
105    }
106    if i >= b.len() || b[i] != b'"' {
107        return None;
108    }
109    i += 1;
110    while i < b.len() {
111        if b[i] == b'"'
112            && b[i + 1..]
113                .iter()
114                .take(hashes)
115                .filter(|c| **c == b'#')
116                .count()
117                == hashes
118        {
119            return Some(i + 1 + hashes);
120        }
121        i += 1;
122    }
123    Some(b.len())
124}
125
126/// Lex every comment in `src`, in source order.
127fn lex_comments(src: &str) -> Vec<RawComment> {
128    let b = src.as_bytes();
129    let mut out = Vec::new();
130    // A shebang line is not a comment, but `#![attr]` on line 1 is not a shebang either.
131    let mut i = if b.starts_with(b"#!") && !src[2..].trim_start().starts_with('[') {
132        src.find('\n').unwrap_or(b.len())
133    } else {
134        0
135    };
136    while i < b.len() {
137        let c = b[i];
138        match c {
139            b'/' if b.get(i + 1) == Some(&b'/') => {
140                let end = src[i..].find('\n').map_or(b.len(), |n| i + n);
141                // `\r\n` endings: keep the `\r` out of the comment text.
142                let end = if end > i && b[end - 1] == b'\r' {
143                    end - 1
144                } else {
145                    end
146                };
147                out.push(RawComment {
148                    start: i,
149                    end,
150                    kind: classify_line(&b[i..end]),
151                    is_block: false,
152                });
153                i = end;
154            }
155            b'/' if b.get(i + 1) == Some(&b'*') => {
156                let mut depth = 0usize;
157                let mut j = i;
158                while j < b.len() {
159                    if b[j] == b'/' && b.get(j + 1) == Some(&b'*') {
160                        depth += 1;
161                        j += 2;
162                    } else if b[j] == b'*' && b.get(j + 1) == Some(&b'/') {
163                        depth -= 1;
164                        j += 2;
165                        if depth == 0 {
166                            break;
167                        }
168                    } else {
169                        j += 1;
170                    }
171                }
172                let end = j.min(b.len());
173                out.push(RawComment {
174                    start: i,
175                    end,
176                    kind: classify_block(&b[i..end]),
177                    is_block: true,
178                });
179                i = end;
180            }
181            b'"' => i = skip_string(b, i + 1),
182            b'\'' => {
183                // Char literal vs lifetime/label.
184                if b.get(i + 1) == Some(&b'\\') {
185                    let mut j = i + 1;
186                    while j < b.len() && b[j] != b'\'' && b[j] != b'\n' {
187                        j += if b[j] == b'\\' { 2 } else { 1 };
188                    }
189                    i = j + 1;
190                } else if let Some(ch) = src[i + 1..].chars().next() {
191                    let after = i + 1 + ch.len_utf8();
192                    if b.get(after) == Some(&b'\'') {
193                        i = after + 1;
194                    } else {
195                        i += 1;
196                    }
197                } else {
198                    i += 1;
199                }
200            }
201            _ if is_ident_char(c) => {
202                let prev_is_ident = i > 0 && is_ident_char(b[i - 1]);
203                if !prev_is_ident {
204                    // String-literal prefixes: r, b, c, br, cr.
205                    let (prefix_len, raw) = match (c, b.get(i + 1).copied()) {
206                        (b'r', Some(b'"' | b'#')) => (1, true),
207                        (b'b' | b'c', Some(b'r')) if matches!(b.get(i + 2), Some(b'"' | b'#')) => {
208                            (2, true)
209                        }
210                        (b'b' | b'c', Some(b'"')) => (1, false),
211                        (b'b', Some(b'\'')) => {
212                            // byte char literal b'x' / b'\n'
213                            let mut j = i + 2;
214                            while j < b.len() && b[j] != b'\'' && b[j] != b'\n' {
215                                j += if b[j] == b'\\' { 2 } else { 1 };
216                            }
217                            i = j + 1;
218                            continue;
219                        }
220                        _ => (0, false),
221                    };
222                    if prefix_len > 0 {
223                        if raw {
224                            if let Some(end) = skip_raw_string(b, i + prefix_len) {
225                                i = end;
226                                continue;
227                            }
228                        } else {
229                            i = skip_string(b, i + prefix_len + 1);
230                            continue;
231                        }
232                    }
233                }
234                i += 1;
235                while i < b.len() && is_ident_char(b[i]) {
236                    i += 1;
237                }
238            }
239            _ => i += 1,
240        }
241    }
242    out
243}
244
245// ---------------------------------------------------------------------------------------------
246// Line/offset helpers
247// ---------------------------------------------------------------------------------------------
248
249struct LineIndex {
250    starts: Vec<usize>,
251}
252
253impl LineIndex {
254    fn new(src: &str) -> Self {
255        let mut starts = vec![0];
256        for (i, b) in src.bytes().enumerate() {
257            if b == b'\n' {
258                starts.push(i + 1);
259            }
260        }
261        Self { starts }
262    }
263
264    /// 1-based line of a byte offset.
265    fn line_of(&self, offset: usize) -> usize {
266        match self.starts.binary_search(&offset) {
267            Ok(i) => i + 1,
268            Err(i) => i,
269        }
270    }
271
272    /// proc-macro2 fallback spans count columns in chars; convert to a byte offset.
273    fn offset_of(&self, src: &str, lc: LineColumn) -> Option<usize> {
274        let start = *self.starts.get(lc.line.checked_sub(1)?)?;
275        let rest = &src[start..];
276        let delta = rest
277            .char_indices()
278            .nth(lc.column)
279            .map_or(rest.len(), |(i, _)| i);
280        Some(start + delta)
281    }
282}
283
284/// `ordinal` is this span's 0-based position among every span in the file whose text is
285/// byte-identical to this one (see `file_spans`), so two bare `//!` lines with the same text
286/// hash differently. A hash therefore identifies exactly one span in its file.
287fn short_hash(text: &str, ordinal: usize) -> String {
288    let mut hasher = blake3::Hasher::new();
289    hasher.update(&(ordinal as u64).to_le_bytes());
290    hasher.update(text.as_bytes());
291    hasher.finalize().to_hex().as_str()[..16].to_string()
292}
293
294fn is_ws(s: &str) -> bool {
295    s.chars().all(char::is_whitespace)
296}
297
298// ---------------------------------------------------------------------------------------------
299// Annotation detection
300// ---------------------------------------------------------------------------------------------
301
302/// Annotation markers the board indexes; a span holding one is refused by `apply` unless the
303/// caller opts in, and flagged by `list` so callers can exclude it.
304pub const ANNOTATION_MARKERS: [&str; 2] = ["@yah:", "@arch:"];
305
306/// Paren/quote state of an annotation value that has not closed yet, carried across comment
307/// lines so a `@yah:next("...` wrapped onto the next `//!` line keeps the continuation flagged.
308#[derive(Default, Clone, Copy)]
309struct AnnoState {
310    depth: usize,
311    in_str: bool,
312}
313
314impl AnnoState {
315    const fn open(self) -> bool {
316        self.depth > 0
317    }
318
319    /// Scan `text` starting inside an open value (or at a marker), return the state at the end.
320    fn scan(mut self, text: &str) -> Self {
321        let mut chars = text.chars();
322        while let Some(ch) = chars.next() {
323            if self.in_str {
324                match ch {
325                    '\\' => {
326                        chars.next();
327                    }
328                    '"' => self.in_str = false,
329                    _ => {}
330                }
331                continue;
332            }
333            match ch {
334                '"' if self.depth > 0 => self.in_str = true,
335                '(' => self.depth += 1,
336                ')' if self.depth > 0 => {
337                    self.depth -= 1;
338                    if self.depth == 0 {
339                        // A later marker on the same line starts fresh.
340                        return Self::default().scan_from_marker(chars.as_str());
341                    }
342                }
343                _ => {}
344            }
345        }
346        self
347    }
348
349    /// Find the first marker in `text` and scan its value.
350    fn scan_from_marker(self, text: &str) -> Self {
351        let Some(pos) = ANNOTATION_MARKERS.iter().filter_map(|m| text.find(m)).min() else {
352            return self;
353        };
354        let after = &text[pos..];
355        let name_end = after.find(':').map_or(after.len(), |i| i + 1);
356        let rest = &after[name_end..];
357        let ident_len = rest
358            .find(|c: char| !(c.is_alphanumeric() || c == '_'))
359            .unwrap_or(rest.len());
360        let tail = &rest[ident_len..];
361        if tail.starts_with('(') {
362            Self::default().scan(tail)
363        } else {
364            Self::default().scan_from_marker(tail)
365        }
366    }
367}
368
369fn has_marker(text: &str) -> bool {
370    ANNOTATION_MARKERS.iter().any(|m| text.contains(m))
371}
372
373/// Per-comment-token annotation flag, with wrapped-value continuation.
374fn annotation_flags(src: &str, raws: &[RawComment], lines: &LineIndex) -> Vec<bool> {
375    let mut flags = vec![false; raws.len()];
376    let mut state = AnnoState::default();
377    let mut prev_line: Option<(usize, bool)> = None; // (line, is_line_comment)
378    for (idx, rc) in raws.iter().enumerate() {
379        let text = &src[rc.start..rc.end];
380        let line = lines.line_of(rc.start);
381        let continues =
382            state.open() && !rc.is_block && matches!(prev_line, Some((l, true)) if l + 1 == line);
383        if !continues {
384            state = AnnoState::default();
385        }
386        if rc.is_block {
387            flags[idx] = has_marker(text);
388            state = AnnoState::default();
389        } else if continues {
390            flags[idx] = true;
391            state = state.scan(text);
392        } else if has_marker(text) {
393            flags[idx] = true;
394            state = AnnoState::default().scan_from_marker(text);
395        }
396        prev_line = Some((lines.line_of(rc.end), !rc.is_block));
397    }
398    flags
399}
400
401// ---------------------------------------------------------------------------------------------
402// Item attachment (syn)
403// ---------------------------------------------------------------------------------------------
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406pub struct AttachedItem {
407    pub node_type: String,
408    pub name: String,
409    pub visibility: String,
410}
411
412#[derive(Default)]
413struct ItemMap {
414    /// byte offset of an item's first non-doc token -> item
415    heads: HashMap<usize, AttachedItem>,
416    /// `[open_brace, close_brace]` byte offsets of every fn body
417    bodies: Vec<(usize, usize)>,
418}
419
420fn vis_str(vis: &syn::Visibility) -> String {
421    match vis {
422        syn::Visibility::Inherited => "private".to_string(),
423        other => other
424            .to_token_stream()
425            .to_string()
426            .replace(" (", "(")
427            .replace("( ", "(")
428            .replace(" )", ")"),
429    }
430}
431
432/// True if a `#`-led token run at `tts[i..]` is a doc attribute (`#[doc = ...]` or
433/// `#![doc = ...]`); returns how many token trees it spans.
434fn doc_attr_len(tts: &[TokenTree], i: usize) -> Option<usize> {
435    let TokenTree::Punct(p) = &tts[i] else {
436        return None;
437    };
438    if p.as_char() != '#' {
439        return None;
440    }
441    let mut j = i + 1;
442    if let Some(TokenTree::Punct(bang)) = tts.get(j)
443        && bang.as_char() == '!'
444    {
445        j += 1;
446    }
447    let TokenTree::Group(g) = tts.get(j)? else {
448        return None;
449    };
450    if g.delimiter() != Delimiter::Bracket {
451        return None;
452    }
453    let mut inner = g.stream().into_iter();
454    let is_doc = matches!(inner.next(), Some(TokenTree::Ident(id)) if id == "doc")
455        && matches!(inner.next(), Some(TokenTree::Punct(eq)) if eq.as_char() == '=');
456    is_doc.then_some(j + 1 - i)
457}
458
459struct ItemCollector<'a> {
460    src: &'a str,
461    lines: &'a LineIndex,
462    map: ItemMap,
463}
464
465impl ItemCollector<'_> {
466    fn head_offset<T: ToTokens>(&self, node: &T) -> Option<usize> {
467        let tts: Vec<TokenTree> = node.to_token_stream().into_iter().collect();
468        let mut i = 0;
469        while i < tts.len() {
470            if let Some(n) = doc_attr_len(&tts, i) {
471                i += n;
472                continue;
473            }
474            return self.lines.offset_of(self.src, tts[i].span().start());
475        }
476        None
477    }
478
479    fn record<T: ToTokens>(&mut self, node: &T, node_type: &str, name: String, vis: String) {
480        if let Some(off) = self.head_offset(node) {
481            self.map.heads.entry(off).or_insert_with(|| AttachedItem {
482                node_type: node_type.to_string(),
483                name,
484                visibility: vis,
485            });
486        }
487    }
488
489    fn record_body(&mut self, block: &syn::Block) {
490        let open = self
491            .lines
492            .offset_of(self.src, block.brace_token.span.open().start());
493        let close = self
494            .lines
495            .offset_of(self.src, block.brace_token.span.close().start());
496        if let (Some(o), Some(c)) = (open, close) {
497            self.map.bodies.push((o, c));
498        }
499    }
500}
501
502impl<'ast> Visit<'ast> for ItemCollector<'_> {
503    fn visit_item(&mut self, item: &'ast syn::Item) {
504        use syn::Item;
505        let info: Option<(&str, String, String)> = match item {
506            Item::Fn(i) => Some(("function", i.sig.ident.to_string(), vis_str(&i.vis))),
507            Item::Struct(i) => Some(("struct", i.ident.to_string(), vis_str(&i.vis))),
508            Item::Enum(i) => Some(("enum", i.ident.to_string(), vis_str(&i.vis))),
509            Item::Union(i) => Some(("union", i.ident.to_string(), vis_str(&i.vis))),
510            Item::Trait(i) => Some(("trait", i.ident.to_string(), vis_str(&i.vis))),
511            Item::TraitAlias(i) => Some(("trait-alias", i.ident.to_string(), vis_str(&i.vis))),
512            Item::Mod(i) => Some(("mod", i.ident.to_string(), vis_str(&i.vis))),
513            Item::Const(i) => Some(("const", i.ident.to_string(), vis_str(&i.vis))),
514            Item::Static(i) => Some(("static", i.ident.to_string(), vis_str(&i.vis))),
515            Item::Type(i) => Some(("type-alias", i.ident.to_string(), vis_str(&i.vis))),
516            Item::ExternCrate(i) => Some(("extern-crate", i.ident.to_string(), vis_str(&i.vis))),
517            Item::Use(i) => Some((
518                "use",
519                i.tree.to_token_stream().to_string().replace(' ', ""),
520                vis_str(&i.vis),
521            )),
522            Item::Impl(i) => Some(("impl", impl_name(i), "private".to_string())),
523            Item::Macro(i) => Some((
524                "macro",
525                i.ident
526                    .as_ref()
527                    .map_or_else(|| path_str(&i.mac.path), ToString::to_string),
528                "private".to_string(),
529            )),
530            Item::ForeignMod(_) => Some(("extern-block", String::new(), "private".to_string())),
531            _ => None,
532        };
533        if let Some((kind, name, vis)) = info {
534            self.record(item, kind, name, vis);
535        }
536        if let Item::Fn(f) = item {
537            self.record_body(&f.block);
538        }
539        syn::visit::visit_item(self, item);
540    }
541
542    fn visit_item_impl(&mut self, imp: &'ast syn::ItemImpl) {
543        let owner = impl_name(imp);
544        for it in &imp.items {
545            match it {
546                syn::ImplItem::Fn(f) => {
547                    self.record(
548                        f,
549                        "impl-method",
550                        format!("{owner}::{}", f.sig.ident),
551                        vis_str(&f.vis),
552                    );
553                    self.record_body(&f.block);
554                }
555                syn::ImplItem::Const(c) => self.record(
556                    c,
557                    "impl-const",
558                    format!("{owner}::{}", c.ident),
559                    vis_str(&c.vis),
560                ),
561                syn::ImplItem::Type(t) => self.record(
562                    t,
563                    "impl-type",
564                    format!("{owner}::{}", t.ident),
565                    vis_str(&t.vis),
566                ),
567                _ => {}
568            }
569        }
570        syn::visit::visit_item_impl(self, imp);
571    }
572
573    fn visit_item_trait(&mut self, tr: &'ast syn::ItemTrait) {
574        for it in &tr.items {
575            let name = |id: &syn::Ident| format!("{}::{id}", tr.ident);
576            match it {
577                syn::TraitItem::Fn(f) => {
578                    self.record(f, "trait-method", name(&f.sig.ident), "private".into());
579                    if let Some(b) = &f.default {
580                        self.record_body(b);
581                    }
582                }
583                syn::TraitItem::Const(c) => {
584                    self.record(c, "trait-const", name(&c.ident), "private".into());
585                }
586                syn::TraitItem::Type(t) => {
587                    self.record(t, "trait-type", name(&t.ident), "private".into());
588                }
589                _ => {}
590            }
591        }
592        syn::visit::visit_item_trait(self, tr);
593    }
594
595    fn visit_item_struct(&mut self, s: &'ast syn::ItemStruct) {
596        for (idx, f) in s.fields.iter().enumerate() {
597            let fname = f
598                .ident
599                .as_ref()
600                .map_or_else(|| idx.to_string(), ToString::to_string);
601            self.record(f, "field", format!("{}::{fname}", s.ident), vis_str(&f.vis));
602        }
603        syn::visit::visit_item_struct(self, s);
604    }
605
606    fn visit_item_enum(&mut self, e: &'ast syn::ItemEnum) {
607        for v in &e.variants {
608            self.record(
609                v,
610                "variant",
611                format!("{}::{}", e.ident, v.ident),
612                "private".into(),
613            );
614            for (idx, f) in v.fields.iter().enumerate() {
615                let fname = f
616                    .ident
617                    .as_ref()
618                    .map_or_else(|| idx.to_string(), ToString::to_string);
619                self.record(
620                    f,
621                    "field",
622                    format!("{}::{}::{fname}", e.ident, v.ident),
623                    "private".into(),
624                );
625            }
626        }
627        syn::visit::visit_item_enum(self, e);
628    }
629}
630
631fn path_str(p: &syn::Path) -> String {
632    p.to_token_stream().to_string().replace(' ', "")
633}
634
635fn impl_name(imp: &syn::ItemImpl) -> String {
636    let ty = imp.self_ty.to_token_stream().to_string().replace(' ', "");
637    match &imp.trait_ {
638        Some((bang, path, _)) => format!(
639            "{}{} for {ty}",
640            if bang.is_some() { "!" } else { "" },
641            path_str(path)
642        ),
643        None => ty,
644    }
645}
646
647// ---------------------------------------------------------------------------------------------
648// list
649// ---------------------------------------------------------------------------------------------
650
651#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct CommentSpan {
653    pub file: PathBuf,
654    /// Byte range `[start, end)` in the file. Pass it back verbatim to `apply`.
655    pub span: [usize; 2],
656    /// 1-based inclusive line range.
657    pub lines: [usize; 2],
658    pub kind: CommentKind,
659    /// Raw source text of the span, comment markers included.
660    pub text: String,
661    /// Guard for `apply` and the sole key it resolves an op by: blake3 of `text` plus this
662    /// span's occurrence ordinal among same-text spans in the file, first 16 hex chars. Unique
663    /// per file by construction — `file_spans` hard-errors if it ever were not.
664    pub hash: String,
665    pub attached_item: Option<AttachedItem>,
666    pub in_body: bool,
667    /// True if the span holds an `@yah:` / `@arch:` annotation (or its wrapped continuation).
668    pub annotation: bool,
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, Default)]
672pub struct ListReport {
673    pub files_scanned: usize,
674    pub span_count: usize,
675    pub annotation_span_count: usize,
676    /// Comment lines inside annotation spans; comparable to `grep -c '@yah:\|@arch:'` plus
677    /// wrapped continuation lines.
678    pub annotation_line_count: usize,
679    /// Files syn could not parse: their spans are still listed, without attachment/in_body.
680    pub parse_errors: Vec<(PathBuf, String)>,
681    pub spans: Vec<CommentSpan>,
682}
683
684pub struct ListArgs {
685    pub paths: Vec<PathBuf>,
686    pub exclude: Vec<String>,
687    /// Drop annotation spans from the output (still counted).
688    pub exclude_annotations: bool,
689    /// 1-indexed inclusive line range: keep only spans whose `lines` falls entirely inside it.
690    pub lines: Option<[usize; 2]>,
691}
692
693/// Parse a `--lines A-B` argument: 1-indexed, inclusive, `A <= B`.
694pub fn parse_line_range(s: &str) -> Result<[usize; 2]> {
695    let (a, b) = s
696        .split_once('-')
697        .ok_or_else(|| anyhow!("--lines expects A-B (e.g. 1-200), got {s:?}"))?;
698    let a: usize = a
699        .trim()
700        .parse()
701        .with_context(|| format!("--lines: {a:?} is not a line number"))?;
702    let b: usize = b
703        .trim()
704        .parse()
705        .with_context(|| format!("--lines: {b:?} is not a line number"))?;
706    if a == 0 || b < a {
707        bail!("--lines {s:?}: lines are 1-indexed and the range must not be inverted");
708    }
709    Ok([a, b])
710}
711
712/// Spans of one file. Consecutive own-line `//`-style comments of the same kind on adjacent
713/// lines merge into one span; a run splits where the annotation flag flips, so the prose
714/// around an annotation block stays editable while the annotation itself stays guarded.
715///
716/// Hard-errors if two spans in this file would hash identically — should be impossible given
717/// `short_hash`'s occurrence-ordinal scheme, but `apply` resolves ops by hash alone, so a
718/// collision here must stop everything rather than silently pick one of the two spans.
719fn file_spans(path: &Path, src: &str) -> Result<(Vec<CommentSpan>, Option<String>)> {
720    let lines = LineIndex::new(src);
721    let raws = lex_comments(src);
722    let flags = annotation_flags(src, &raws, &lines);
723
724    let (map, parse_err) = match syn::parse_file(src) {
725        Ok(file) => {
726            let mut c = ItemCollector {
727                src,
728                lines: &lines,
729                map: ItemMap::default(),
730            };
731            c.visit_file(&file);
732            (c.map, None)
733        }
734        Err(e) => (ItemMap::default(), Some(e.to_string())),
735    };
736
737    let own_line = |rc: &RawComment| {
738        let ls = lines.starts[lines.line_of(rc.start) - 1];
739        is_ws(&src[ls..rc.start])
740    };
741
742    let mut groups: Vec<(usize, usize)> = Vec::new(); // inclusive raw index ranges
743    for (idx, rc) in raws.iter().enumerate() {
744        if let Some((gs, ge)) = groups.last_mut() {
745            let prev = &raws[*ge];
746            let joinable = !rc.is_block
747                && !prev.is_block
748                && prev.kind == rc.kind
749                && flags[*ge] == flags[idx]
750                && own_line(rc)
751                && own_line(&raws[*gs])
752                && lines.line_of(rc.start) == lines.line_of(prev.end) + 1
753                && is_ws(&src[prev.end..rc.start]);
754            if joinable {
755                *ge = idx;
756                continue;
757            }
758        }
759        groups.push((idx, idx));
760    }
761
762    let mut occurrences: HashMap<String, usize> = HashMap::new();
763    let spans: Vec<CommentSpan> = groups
764        .into_iter()
765        .map(|(gs, ge)| {
766            let start = raws[gs].start;
767            let end = raws[ge].end;
768            let kind = raws[gs].kind;
769            let text = src[start..end].to_string();
770            let attached_item = if kind == CommentKind::InnerDoc {
771                None
772            } else {
773                next_code_offset(src, &raws, ge).and_then(|p| map.heads.get(&p).cloned())
774            };
775            let ordinal = occurrences.entry(text.clone()).or_insert(0);
776            let hash = short_hash(&text, *ordinal);
777            *ordinal += 1;
778            CommentSpan {
779                file: path.to_path_buf(),
780                span: [start, end],
781                lines: [lines.line_of(start), lines.line_of(end.max(start + 1) - 1)],
782                kind,
783                hash,
784                text,
785                attached_item,
786                in_body: map.bodies.iter().any(|(o, c)| *o < start && start < *c),
787                annotation: flags[gs..=ge].iter().any(|f| *f),
788            }
789        })
790        .collect();
791
792    let mut seen_hashes: HashSet<&str> = HashSet::with_capacity(spans.len());
793    for s in &spans {
794        if !seen_hashes.insert(s.hash.as_str()) {
795            bail!(
796                "duplicate hash {} in {}: two spans hashed identically, which should be \
797                 impossible under the occurrence-ordinal scheme -- refusing to list this file",
798                s.hash,
799                path.display()
800            );
801        }
802    }
803
804    Ok((spans, parse_err))
805}
806
807/// Offset of the first code byte after raw comment `idx`, skipping whitespace and comments.
808fn next_code_offset(src: &str, raws: &[RawComment], idx: usize) -> Option<usize> {
809    let b = src.as_bytes();
810    let mut pos = raws[idx].end;
811    let mut next = idx + 1;
812    loop {
813        while pos < b.len() && b[pos].is_ascii_whitespace() {
814            pos += 1;
815        }
816        if next < raws.len() && raws[next].start == pos {
817            pos = raws[next].end;
818            next += 1;
819            continue;
820        }
821        return (pos < b.len()).then_some(pos);
822    }
823}
824
825pub fn list(args: &ListArgs) -> Result<ListReport> {
826    let files = collect_rust_files_with_exclusions(&args.paths, &args.exclude)?;
827    let mut report = ListReport {
828        files_scanned: files.len(),
829        ..Default::default()
830    };
831    for path in files {
832        let src = std::fs::read_to_string(&path)
833            .with_context(|| format!("Failed to read {}", path.display()))?;
834        let (spans, err) = file_spans(&path, &src)?;
835        if let Some(e) = err {
836            report.parse_errors.push((path.clone(), e));
837        }
838        for s in spans {
839            if let Some([a, b]) = args.lines
840                && (s.lines[0] < a || s.lines[1] > b)
841            {
842                continue;
843            }
844            report.span_count += 1;
845            if s.annotation {
846                report.annotation_span_count += 1;
847                report.annotation_line_count += s.lines[1] - s.lines[0] + 1;
848                if args.exclude_annotations {
849                    continue;
850                }
851            }
852            report.spans.push(s);
853        }
854    }
855    Ok(report)
856}
857
858/// The `comments list --format batch` payload: one ready-made `"keep"` op stub per
859/// non-annotation span in `report`.
860///
861/// `{file, hash, op: "keep", lines, text}` -- copy the array verbatim and flip `op` on the
862/// entries you're changing. `lines`/`text` are read-only context on anything but `replace`;
863/// `apply` ignores a `"keep"` op entirely.
864pub fn to_batch(report: &ListReport) -> Vec<CommentOp> {
865    report
866        .spans
867        .iter()
868        .filter(|s| !s.annotation)
869        .map(|s| CommentOp {
870            file: s.file.clone(),
871            hash: s.hash.clone(),
872            op: OpKind::Keep,
873            text: Some(s.text.clone()),
874            lines: Some(s.lines),
875        })
876        .collect()
877}
878
879pub fn render_list(report: &ListReport) {
880    for s in &report.spans {
881        let attached = s
882            .attached_item
883            .as_ref()
884            .map(|a| format!(" -> {} {}", a.node_type, a.name))
885            .unwrap_or_default();
886        println!(
887            "{}:{}-{} [{}..{}] {:?}{}{}{} {}",
888            s.file.display(),
889            s.lines[0],
890            s.lines[1],
891            s.span[0],
892            s.span[1],
893            s.kind,
894            if s.in_body { " in-body" } else { "" },
895            if s.annotation { " ANNOTATION" } else { "" },
896            attached,
897            s.hash
898        );
899    }
900    println!(
901        "{} span(s) in {} file(s); {} annotation span(s) covering {} line(s)",
902        report.span_count,
903        report.files_scanned,
904        report.annotation_span_count,
905        report.annotation_line_count
906    );
907}
908
909// ---------------------------------------------------------------------------------------------
910// verify
911// ---------------------------------------------------------------------------------------------
912
913/// One comparable unit of code: a label naming it and its token string with every comment,
914/// every whitespace difference and every `#[doc = ...]` attribute removed.
915#[derive(Debug, Clone)]
916struct CodeUnit {
917    label: String,
918    line: usize,
919    tokens: String,
920    children: Vec<Self>,
921}
922
923fn push_tokens(ts: TokenStream, out: &mut String) {
924    let tts: Vec<TokenTree> = ts.into_iter().collect();
925    let mut i = 0;
926    while i < tts.len() {
927        if let Some(n) = doc_attr_len(&tts, i) {
928            i += n;
929            continue;
930        }
931        match &tts[i] {
932            TokenTree::Group(g) => {
933                let (open, close) = match g.delimiter() {
934                    Delimiter::Parenthesis => ("(", ")"),
935                    Delimiter::Brace => ("{", "}"),
936                    Delimiter::Bracket => ("[", "]"),
937                    Delimiter::None => ("<<", ">>"),
938                };
939                out.push_str(open);
940                out.push(' ');
941                push_tokens(g.stream(), out);
942                out.push_str(close);
943                out.push(' ');
944            }
945            TokenTree::Punct(p) => {
946                out.push(p.as_char());
947                if p.spacing() == Spacing::Alone {
948                    out.push(' ');
949                }
950            }
951            TokenTree::Ident(id) => {
952                out.push_str(&id.to_string());
953                out.push(' ');
954            }
955            TokenTree::Literal(l) => {
956                out.push_str(&l.to_string());
957                out.push(' ');
958            }
959        }
960        i += 1;
961    }
962}
963
964fn norm<T: ToTokens>(node: &T) -> String {
965    let mut s = String::new();
966    push_tokens(node.to_token_stream(), &mut s);
967    s
968}
969
970fn item_label(item: &syn::Item) -> String {
971    use syn::Item;
972    match item {
973        Item::Fn(i) => format!("fn {}", i.sig.ident),
974        Item::Struct(i) => format!("struct {}", i.ident),
975        Item::Enum(i) => format!("enum {}", i.ident),
976        Item::Union(i) => format!("union {}", i.ident),
977        Item::Trait(i) => format!("trait {}", i.ident),
978        Item::TraitAlias(i) => format!("trait {}", i.ident),
979        Item::Mod(i) => format!("mod {}", i.ident),
980        Item::Const(i) => format!("const {}", i.ident),
981        Item::Static(i) => format!("static {}", i.ident),
982        Item::Type(i) => format!("type {}", i.ident),
983        Item::ExternCrate(i) => format!("extern crate {}", i.ident),
984        Item::Use(i) => format!(
985            "use {}",
986            i.tree.to_token_stream().to_string().replace(' ', "")
987        ),
988        Item::Impl(i) => format!("impl {}", impl_name(i)),
989        Item::Macro(i) => i.ident.as_ref().map_or_else(
990            || format!("{}!", path_str(&i.mac.path)),
991            |id| format!("macro_rules! {id}"),
992        ),
993        Item::ForeignMod(_) => "extern block".to_string(),
994        _ => "item".to_string(),
995    }
996}
997
998fn unit_line<T: syn::spanned::Spanned>(node: &T) -> usize {
999    node.span().start().line
1000}
1001
1002fn item_units(items: &[syn::Item], prefix: &str) -> Vec<CodeUnit> {
1003    items
1004        .iter()
1005        .map(|item| {
1006            let label = format!("{prefix}{}", item_label(item));
1007            let children = match item {
1008                syn::Item::Impl(i) => i
1009                    .items
1010                    .iter()
1011                    .map(|it| CodeUnit {
1012                        label: format!("{label}::{}", impl_item_label(it)),
1013                        line: unit_line(it),
1014                        tokens: norm(it),
1015                        children: Vec::new(),
1016                    })
1017                    .collect(),
1018                syn::Item::Trait(t) => t
1019                    .items
1020                    .iter()
1021                    .map(|it| CodeUnit {
1022                        label: format!("{label}::{}", trait_item_label(it)),
1023                        line: unit_line(it),
1024                        tokens: norm(it),
1025                        children: Vec::new(),
1026                    })
1027                    .collect(),
1028                syn::Item::Mod(m) => m
1029                    .content
1030                    .as_ref()
1031                    .map(|(_, items)| item_units(items, &format!("{label}::")))
1032                    .unwrap_or_default(),
1033                _ => Vec::new(),
1034            };
1035            CodeUnit {
1036                label,
1037                line: unit_line(item),
1038                tokens: norm(item),
1039                children,
1040            }
1041        })
1042        .collect()
1043}
1044
1045fn impl_item_label(it: &syn::ImplItem) -> String {
1046    match it {
1047        syn::ImplItem::Fn(f) => format!("fn {}", f.sig.ident),
1048        syn::ImplItem::Const(c) => format!("const {}", c.ident),
1049        syn::ImplItem::Type(t) => format!("type {}", t.ident),
1050        syn::ImplItem::Macro(m) => format!("{}!", path_str(&m.mac.path)),
1051        _ => "item".to_string(),
1052    }
1053}
1054
1055fn trait_item_label(it: &syn::TraitItem) -> String {
1056    match it {
1057        syn::TraitItem::Fn(f) => format!("fn {}", f.sig.ident),
1058        syn::TraitItem::Const(c) => format!("const {}", c.ident),
1059        syn::TraitItem::Type(t) => format!("type {}", t.ident),
1060        syn::TraitItem::Macro(m) => format!("{}!", path_str(&m.mac.path)),
1061        _ => "item".to_string(),
1062    }
1063}
1064
1065fn file_units(src: &str) -> Result<Vec<CodeUnit>> {
1066    let file = syn::parse_file(src).map_err(|e| anyhow!("parse error: {e}"))?;
1067    let mut crate_attrs = String::new();
1068    for a in &file.attrs {
1069        push_tokens(a.to_token_stream(), &mut crate_attrs);
1070    }
1071    let mut units = vec![CodeUnit {
1072        label: "crate attributes".to_string(),
1073        line: 1,
1074        tokens: crate_attrs,
1075        children: Vec::new(),
1076    }];
1077    units.extend(item_units(&file.items, ""));
1078    Ok(units)
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1082pub struct Difference {
1083    /// Item as it was before, or None if the after side has an extra item here.
1084    pub before_item: Option<String>,
1085    pub before_line: Option<usize>,
1086    pub after_item: Option<String>,
1087    pub after_line: Option<usize>,
1088}
1089
1090impl std::fmt::Display for Difference {
1091    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092        let side = |item: &Option<String>, line: &Option<usize>| match (item, line) {
1093            (Some(i), Some(l)) => format!("`{i}` (line {l})"),
1094            (Some(i), None) => format!("`{i}`"),
1095            _ => "<nothing>".to_string(),
1096        };
1097        if self.before_item == self.after_item {
1098            write!(
1099                f,
1100                "{} changed: before line {}, after line {}",
1101                side(&self.before_item, &None),
1102                self.before_line.unwrap_or(0),
1103                self.after_line.unwrap_or(0)
1104            )
1105        } else {
1106            write!(
1107                f,
1108                "before {} vs after {}",
1109                side(&self.before_item, &self.before_line),
1110                side(&self.after_item, &self.after_line)
1111            )
1112        }
1113    }
1114}
1115
1116fn first_difference(before: &[CodeUnit], after: &[CodeUnit]) -> Option<Difference> {
1117    let n = before.len().max(after.len());
1118    for i in 0..n {
1119        match (before.get(i), after.get(i)) {
1120            (Some(b), Some(a)) if b.label == a.label && b.tokens == a.tokens => {}
1121            (Some(b), Some(a)) => {
1122                // Same container on both sides: name the member that changed, if any.
1123                if b.label == a.label
1124                    && let Some(d) = first_difference(&b.children, &a.children)
1125                {
1126                    return Some(d);
1127                }
1128                return Some(Difference {
1129                    before_item: Some(b.label.clone()),
1130                    before_line: Some(b.line),
1131                    after_item: Some(a.label.clone()),
1132                    after_line: Some(a.line),
1133                });
1134            }
1135            (b, a) => {
1136                return Some(Difference {
1137                    before_item: b.map(|u| u.label.clone()),
1138                    before_line: b.map(|u| u.line),
1139                    after_item: a.map(|u| u.label.clone()),
1140                    after_line: a.map(|u| u.line),
1141                });
1142            }
1143        }
1144    }
1145    None
1146}
1147
1148/// Compare two sources as code. `Ok(None)` means equal modulo comments, whitespace and doc
1149/// attributes; `Ok(Some(d))` names the first item that differs.
1150pub fn compare_sources(before: &str, after: &str) -> Result<Option<Difference>> {
1151    let b = file_units(before).context("before")?;
1152    let a = file_units(after).context("after")?;
1153    Ok(first_difference(&b, &a))
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1157pub struct FileVerdict {
1158    pub file: PathBuf,
1159    pub equal: bool,
1160    pub difference: Option<Difference>,
1161    /// Set when either side failed to parse; `equal` is false.
1162    pub error: Option<String>,
1163}
1164
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub struct VerifyReport {
1167    pub equal: bool,
1168    pub files: Vec<FileVerdict>,
1169}
1170
1171impl VerifyReport {
1172    fn from_files(files: Vec<FileVerdict>) -> Self {
1173        Self {
1174            equal: files.iter().all(|f| f.equal),
1175            files,
1176        }
1177    }
1178}
1179
1180fn verdict(file: PathBuf, before: &str, after: &str) -> FileVerdict {
1181    match compare_sources(before, after) {
1182        Ok(d) => FileVerdict {
1183            file,
1184            equal: d.is_none(),
1185            difference: d,
1186            error: None,
1187        },
1188        Err(e) => FileVerdict {
1189            file,
1190            equal: false,
1191            difference: None,
1192            error: Some(format!("{e:#}")),
1193        },
1194    }
1195}
1196
1197pub enum VerifyArgs {
1198    /// `before` is a file path, or a git revision whose copy of `after` is compared.
1199    Files { before: String, after: PathBuf },
1200    /// Compare every file a `comments apply` run modified against its backup.
1201    RunId { run_id: String, state_dir: PathBuf },
1202}
1203
1204fn read_before(before: &str, after: &Path) -> Result<String> {
1205    let p = Path::new(before);
1206    if p.is_file() {
1207        return std::fs::read_to_string(p).with_context(|| format!("Failed to read {before}"));
1208    }
1209    // Treat as a git revision: `git show <rev>:./<file>` from the after file's directory.
1210    let dir = after
1211        .parent()
1212        .filter(|d| !d.as_os_str().is_empty())
1213        .unwrap_or_else(|| Path::new("."));
1214    let name = after
1215        .file_name()
1216        .ok_or_else(|| anyhow!("--after has no file name"))?
1217        .to_string_lossy();
1218    let out = std::process::Command::new("git")
1219        .arg("-C")
1220        .arg(dir)
1221        .arg("show")
1222        .arg(format!("{before}:./{name}"))
1223        .output()
1224        .context("Failed to run git show")?;
1225    if !out.status.success() {
1226        bail!(
1227            "--before `{before}` is neither a file nor a git revision containing {}: {}",
1228            after.display(),
1229            String::from_utf8_lossy(&out.stderr).trim()
1230        );
1231    }
1232    Ok(String::from_utf8(out.stdout)?)
1233}
1234
1235pub fn verify(args: &VerifyArgs) -> Result<VerifyReport> {
1236    match args {
1237        VerifyArgs::Files { before, after } => {
1238            let before_src = read_before(before, after)?;
1239            let after_src = std::fs::read_to_string(after)
1240                .with_context(|| format!("Failed to read {}", after.display()))?;
1241            Ok(VerifyReport::from_files(vec![verdict(
1242                after.clone(),
1243                &before_src,
1244                &after_src,
1245            )]))
1246        }
1247        VerifyArgs::RunId { run_id, state_dir } => {
1248            let run = load_run_metadata(run_id, state_dir)?;
1249            let mut files = Vec::new();
1250            for fm in &run.files_modified {
1251                let Some(backup) = fm
1252                    .backup_nodes
1253                    .iter()
1254                    .find(|n| n.node_type == FILE_BACKUP_NODE_TYPE)
1255                else {
1256                    bail!(
1257                        "run {run_id} has no whole-file backup for {} — only `comments apply` runs can be verified by run id",
1258                        fm.path.display()
1259                    );
1260                };
1261                let after_src = std::fs::read_to_string(&fm.path)
1262                    .with_context(|| format!("Failed to read {}", fm.path.display()))?;
1263                files.push(verdict(
1264                    fm.path.clone(),
1265                    &backup.original_content,
1266                    &after_src,
1267                ));
1268            }
1269            Ok(VerifyReport::from_files(files))
1270        }
1271    }
1272}
1273
1274pub fn render_verify(report: &VerifyReport) {
1275    for f in &report.files {
1276        if f.equal {
1277            println!(
1278                "✓ {}: code unchanged (comments/whitespace/doc ignored)",
1279                f.file.display()
1280            );
1281        } else if let Some(e) = &f.error {
1282            println!("✗ {}: {e}", f.file.display());
1283        } else if let Some(d) = &f.difference {
1284            println!("✗ {}: first differing item: {d}", f.file.display());
1285        }
1286    }
1287}
1288
1289// ---------------------------------------------------------------------------------------------
1290// apply
1291// ---------------------------------------------------------------------------------------------
1292
1293#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1294#[serde(rename_all = "lowercase")]
1295pub enum OpKind {
1296    /// `comments list --format batch`'s default: no change. `apply` ignores this op entirely.
1297    Keep,
1298    /// Remove the span; an own-line span takes its whole lines with it.
1299    Delete,
1300    /// Replace the span's text with `text`, which must itself be only comments and whitespace.
1301    Replace,
1302}
1303
1304/// One op in an apply batch, resolved by `hash` alone -- pre-1.0, there is no `span` field and
1305/// no fallback.
1306///
1307/// `hash` is looked up against a fresh `comments list` of `file` at apply time, so a batch
1308/// built before a sibling op shifted the file's byte offsets still resolves correctly (see
1309/// `apply`'s per-file hash table).
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct CommentOp {
1312    pub file: PathBuf,
1313    /// The `hash` `comments list` reported for this span. The only thing that identifies which
1314    /// span this op targets.
1315    pub hash: String,
1316    pub op: OpKind,
1317    /// Required for `replace`. Read-only context on `keep`/`delete` (ignored by `apply`) --
1318    /// `comments list --format batch` fills it with the span's current text so a worker can
1319    /// read what it's deciding about without a second lookup.
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub text: Option<String>,
1322    /// Read-only context from `comments list --format batch`; ignored by `apply`.
1323    #[serde(default, skip_serializing_if = "Option::is_none")]
1324    pub lines: Option<[usize; 2]>,
1325}
1326
1327#[derive(Deserialize)]
1328#[serde(untagged)]
1329enum BatchShape {
1330    Ops(Vec<CommentOp>),
1331    Wrapped { ops: Vec<CommentOp> },
1332}
1333
1334pub fn parse_batch(json: &str) -> Result<Vec<CommentOp>> {
1335    let shape: BatchShape = serde_json::from_str(json)
1336        .context("batch must be a JSON array of ops or {\"ops\": [...]}")?;
1337    Ok(match shape {
1338        BatchShape::Ops(v) | BatchShape::Wrapped { ops: v } => v,
1339    })
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize)]
1343pub struct OpOutcome {
1344    /// Index of the op in the batch.
1345    pub index: usize,
1346    pub file: PathBuf,
1347    /// The span this op resolved to, if `hash` was found. `None` for an unknown-hash refusal
1348    /// and for a whole file that could not be read -- there is nothing to resolve against.
1349    pub span: Option<[usize; 2]>,
1350    /// Why the op was refused; None for an accepted op.
1351    pub reason: Option<String>,
1352}
1353
1354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1355pub struct ApplyReport {
1356    pub dry_run: bool,
1357    /// Set only when files were written.
1358    pub run_id: Option<String>,
1359    pub accepted: Vec<OpOutcome>,
1360    pub refused: Vec<OpOutcome>,
1361    pub files_changed: Vec<PathBuf>,
1362    /// Unified diff of every changed file (dry-run and apply alike).
1363    pub diff: String,
1364}
1365
1366pub struct ApplyArgs {
1367    pub ops: Vec<CommentOp>,
1368    pub apply: bool,
1369    pub allow_annotations: bool,
1370    pub state_dir: PathBuf,
1371    /// Recorded on the run for `rs-hack history`.
1372    pub command_line: String,
1373}
1374
1375/// Resolve a delete to the byte range actually removed: whole lines for an own-line span, the
1376/// comment plus its leading horizontal whitespace for a trailing one.
1377fn delete_range(src: &str, [s, e]: [usize; 2]) -> (usize, usize) {
1378    let ls = src[..s].rfind('\n').map_or(0, |i| i + 1);
1379    let le = src[e..].find('\n').map_or(src.len(), |i| e + i);
1380    if is_ws(&src[ls..s]) && is_ws(&src[e..le]) {
1381        (ls, (le + 1).min(src.len()))
1382    } else {
1383        let trimmed = src[..s].trim_end_matches([' ', '\t']).len();
1384        (trimmed, e)
1385    }
1386}
1387
1388pub fn apply(args: &ApplyArgs) -> Result<ApplyReport> {
1389    let mut report = ApplyReport {
1390        dry_run: !args.apply,
1391        ..Default::default()
1392    };
1393
1394    let mut by_file: BTreeMap<PathBuf, Vec<(usize, &CommentOp)>> = BTreeMap::new();
1395    for (i, op) in args.ops.iter().enumerate() {
1396        if op.op == OpKind::Keep {
1397            continue;
1398        }
1399        by_file.entry(op.file.clone()).or_default().push((i, op));
1400    }
1401
1402    let run_id = generate_run_id();
1403    let mut modifications = Vec::new();
1404
1405    for (file, ops) in by_file {
1406        let refuse =
1407            |report: &mut ApplyReport, i: usize, op: &CommentOp, span: Option<[usize; 2]>, why: String| {
1408                report.refused.push(OpOutcome {
1409                    index: i,
1410                    file: op.file.clone(),
1411                    span,
1412                    reason: Some(why),
1413                });
1414            };
1415        let src = match std::fs::read_to_string(&file) {
1416            Ok(s) => s,
1417            Err(e) => {
1418                for (i, op) in ops {
1419                    refuse(&mut report, i, op, None, format!("cannot read file: {e}"));
1420                }
1421                continue;
1422            }
1423        };
1424        let lines = LineIndex::new(&src);
1425        // Fresh per file, per call: resolving by hash against a table built from THIS read is
1426        // what lets a batch built before a sibling op shifted the file's byte offsets still
1427        // resolve correctly, and it's why an op can no longer carry its own span.
1428        let (spans, _parse_err) = file_spans(&file, &src)?;
1429        let by_hash: HashMap<&str, &CommentSpan> =
1430            spans.iter().map(|s| (s.hash.as_str(), s)).collect();
1431
1432        // (index, op, removed range, replacement)
1433        let mut edits: Vec<(usize, &CommentOp, (usize, usize), String)> = Vec::new();
1434        for (i, op) in ops {
1435            let Some(span) = by_hash.get(op.hash.as_str()).copied() else {
1436                refuse(
1437                    &mut report,
1438                    i,
1439                    op,
1440                    None,
1441                    format!(
1442                        "unknown hash {} in {}: no span currently has this hash — re-run \
1443                         `comments list`",
1444                        op.hash,
1445                        file.display()
1446                    ),
1447                );
1448                continue;
1449            };
1450            if span.annotation && !args.allow_annotations {
1451                refuse(
1452                    &mut report,
1453                    i,
1454                    op,
1455                    Some(span.span),
1456                    "span holds an @yah:/@arch: annotation (pass --allow-annotations to edit it)"
1457                        .to_string(),
1458                );
1459                continue;
1460            }
1461            let (range, text) = match op.op {
1462                OpKind::Keep => unreachable!("keep ops are filtered out of by_file above"),
1463                OpKind::Delete => (delete_range(&src, span.span), String::new()),
1464                OpKind::Replace => {
1465                    let Some(text) = op.text.as_deref() else {
1466                        refuse(
1467                            &mut report,
1468                            i,
1469                            op,
1470                            Some(span.span),
1471                            "op: \"replace\" with no `text`".to_string(),
1472                        );
1473                        continue;
1474                    };
1475                    let rep = lex_comments(text);
1476                    let mut pos = 0;
1477                    let mut only_comments = true;
1478                    for r in &rep {
1479                        only_comments &= is_ws(&text[pos..r.start]);
1480                        pos = r.end;
1481                    }
1482                    only_comments &= is_ws(&text[pos..]);
1483                    if !only_comments {
1484                        refuse(
1485                            &mut report,
1486                            i,
1487                            op,
1488                            Some(span.span),
1489                            "replacement text must contain only comments and whitespace"
1490                                .to_string(),
1491                        );
1492                        continue;
1493                    }
1494                    if !args.allow_annotations && has_marker(text) {
1495                        refuse(
1496                            &mut report,
1497                            i,
1498                            op,
1499                            Some(span.span),
1500                            "replacement text introduces an @yah:/@arch: annotation (pass --allow-annotations)"
1501                                .to_string(),
1502                        );
1503                        continue;
1504                    }
1505                    if text.is_empty() {
1506                        (delete_range(&src, span.span), String::new())
1507                    } else {
1508                        ((span.span[0], span.span[1]), text.to_string())
1509                    }
1510                }
1511            };
1512            if let Some((j, _, r, _)) = edits
1513                .iter()
1514                .find(|(_, _, r, _)| range.0 < r.1 && r.0 < range.1)
1515            {
1516                refuse(
1517                    &mut report,
1518                    i,
1519                    op,
1520                    Some(span.span),
1521                    format!("overlaps op #{j} range [{}, {})", r.0, r.1),
1522                );
1523                continue;
1524            }
1525            edits.push((i, op, range, text));
1526        }
1527        if edits.is_empty() {
1528            continue;
1529        }
1530
1531        edits.sort_by_key(|(_, _, r, _)| std::cmp::Reverse(r.0));
1532        let mut new_src = src.clone();
1533        for (_, _, (s, e), text) in &edits {
1534            new_src.replace_range(*s..*e, text);
1535        }
1536
1537        // The gate: a comment edit must leave the code identical.
1538        match compare_sources(&src, &new_src) {
1539            Ok(None) => {}
1540            other => {
1541                let why = match other {
1542                    Ok(Some(d)) => format!("batch would change code in this file: {d}"),
1543                    Err(e) => format!("batch would leave this file unparseable: {e:#}"),
1544                    Ok(None) => unreachable!(),
1545                };
1546                for (i, op, range, _) in edits {
1547                    refuse(&mut report, i, op, Some(range.into()), why.clone());
1548                }
1549                continue;
1550            }
1551        }
1552
1553        for (i, op, range, _) in &edits {
1554            report.accepted.push(OpOutcome {
1555                index: *i,
1556                file: op.file.clone(),
1557                span: Some((*range).into()),
1558                reason: None,
1559            });
1560        }
1561        report
1562            .diff
1563            .push_str(&crate::diff::generate_unified_diff(&file, &src, &new_src, 3).0);
1564        report.files_changed.push(file.clone());
1565
1566        if args.apply {
1567            let abs = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
1568            let backup = BackupNode {
1569                node_type: FILE_BACKUP_NODE_TYPE.to_string(),
1570                identifier: abs.display().to_string(),
1571                original_content: src.clone(),
1572                location: NodeLocation {
1573                    line: 1,
1574                    column: 0,
1575                    end_line: lines.starts.len(),
1576                    end_column: 0,
1577                },
1578            };
1579            let hash_before = hash_file(&abs)?;
1580            save_backup_nodes(
1581                &abs,
1582                std::slice::from_ref(&backup),
1583                &run_id,
1584                &args.state_dir,
1585            )?;
1586            std::fs::write(&abs, &new_src)
1587                .with_context(|| format!("Failed to write {}", abs.display()))?;
1588            modifications.push(FileModification {
1589                path: abs.clone(),
1590                hash_before,
1591                hash_after: hash_file(&abs)?,
1592                backup_nodes: vec![backup],
1593            });
1594        }
1595    }
1596
1597    report.accepted.sort_by_key(|o| o.index);
1598    report.refused.sort_by_key(|o| o.index);
1599
1600    if !modifications.is_empty() {
1601        save_run_metadata(
1602            &RunMetadata {
1603                run_id: run_id.clone(),
1604                timestamp: chrono::Utc::now(),
1605                command: args.command_line.clone(),
1606                operation: "comments-apply".to_string(),
1607                files_modified: modifications,
1608                status: RunStatus::Applied,
1609                can_revert: true,
1610            },
1611            &args.state_dir,
1612        )?;
1613        report.run_id = Some(run_id);
1614    }
1615    Ok(report)
1616}
1617
1618pub fn render_apply(report: &ApplyReport) {
1619    if !report.diff.is_empty() {
1620        println!("{}", report.diff);
1621    }
1622    for r in &report.refused {
1623        let loc = r
1624            .span
1625            .map(|[s, e]| format!(" [{s}, {e})"))
1626            .unwrap_or_default();
1627        println!(
1628            "✗ op #{} {}{loc}: {}",
1629            r.index,
1630            r.file.display(),
1631            r.reason.as_deref().unwrap_or("")
1632        );
1633    }
1634    println!(
1635        "{} op(s) accepted, {} refused, {} file(s) {}",
1636        report.accepted.len(),
1637        report.refused.len(),
1638        report.files_changed.len(),
1639        if report.dry_run {
1640            "would change (dry run; pass --apply to write)"
1641        } else {
1642            "changed"
1643        }
1644    );
1645    if let Some(id) = &report.run_id {
1646        println!("run_id: {id} (undo with `rs-hack revert {id}`)");
1647    }
1648}