Skip to main content

source_edit/
lib.rs

1//! Surgical source rewriting: replace exactly one [`syn::Item`]'s byte span
2//! with a re-printed, mutated version of itself, leaving the rest of the
3//! file byte-identical.
4//!
5//! `taint-generate` and `taint-refactor` both need to add an attribute (or
6//! insert a call) to one specific `fn`/`mod` inside a real source file
7//! without disturbing anything else in it. A full `syn::parse_file` +
8//! `prettyplease::unparse` round-trip of the *whole file* would work, but
9//! it silently reformats every other item and strips blank-line structure
10//! wherever `syn` doesn't preserve it — an unacceptable side effect for a
11//! tool whose entire job is "add one attribute, touch nothing else". This
12//! crate instead: (1) locates the exact byte range of the *one* item being
13//! changed via [`span_byte_range`] (using [`proc_macro2`]'s `span-locations`
14//! line/column data, since this always runs outside a real proc-macro), (2)
15//! re-prints just that mutated item alone via `prettyplease`
16//! ([`print_item`]), and (3) splices it back into the original text
17//! ([`apply_edits`]). Everything outside a changed item's own span is
18//! untouched; the changed item itself may come out less indented than its
19//! surrounding context if it was nested (`prettyplease` always prints from
20//! column zero) — recommend running `cargo fmt` after applying edits.
21
22#![warn(missing_docs)]
23#![allow(
24    clippy::cargo_common_metadata,
25    reason = "workspace-wide dependency-graph check, not something a single-crate pass can fix or meaningfully scope"
26)]
27
28use proc_macro2::Span;
29use syn::parse::Parser;
30use syn::{Attribute, Item};
31
32/// One pending replacement: swap the original source's `[start, end)` byte
33/// range for `replacement`.
34#[derive(Debug, Clone)]
35pub struct SourceEdit {
36    /// Start byte offset (inclusive) in the *original* source.
37    pub start: usize,
38    /// End byte offset (exclusive) in the *original* source.
39    pub end: usize,
40    /// The text to put in that range's place.
41    pub replacement: String,
42}
43
44/// Convert a 1-indexed line number and 0-indexed, char-counted column
45/// (`proc_macro2::LineColumn`'s own convention) into a byte offset.
46///
47/// Assumes LF (`\n`) line endings, matching every file this workspace's own
48/// tooling writes.
49#[must_use]
50pub fn byte_offset(source: &str, line: usize, column: usize) -> usize {
51    let mut offset = 0usize;
52    for (idx, line_str) in source.split('\n').enumerate() {
53        if idx + 1 == line {
54            let prefix_len: usize = line_str.chars().take(column).map(char::len_utf8).sum();
55            return offset + prefix_len;
56        }
57        offset += line_str.len() + 1; // the '\n' that `split` consumed
58    }
59    source.len()
60}
61
62/// The `[start, end)` byte range `span` covers in `source`.
63#[must_use]
64pub fn span_byte_range(source: &str, span: Span) -> (usize, usize) {
65    let start = span.start();
66    let end = span.end();
67    (
68        byte_offset(source, start.line, start.column),
69        byte_offset(source, end.line, end.column),
70    )
71}
72
73/// Pretty-print `item` alone (wrapped in a bare, attribute-less
74/// [`syn::File`]) via `prettyplease`, trimmed of its trailing newline so
75/// callers can splice it directly into a [`SourceEdit::replacement`].
76#[must_use]
77pub fn print_item(item: &Item) -> String {
78    let file = syn::File {
79        shebang: None,
80        attrs: Vec::new(),
81        items: vec![item.clone()],
82    };
83    prettyplease::unparse(&file)
84        .trim_end_matches('\n')
85        .to_string()
86}
87
88/// Parse a single standalone `#[...]` outer attribute from `text`.
89///
90/// `syn::Attribute` has no direct [`syn::parse::Parse`] impl of its own
91/// (an attribute is only ever parsed as part of something else) — this is
92/// the `Attribute::parse_outer`-based equivalent of `syn::parse_str` for
93/// exactly this one node type, since generating one from a formatted
94/// string (`format!("#[{name}({args})]")`) is how every caller in this
95/// workspace builds a new attribute to insert.
96///
97/// # Errors
98///
99/// Returns `Err` if `text` isn't a single valid `#[...]` attribute.
100pub fn parse_attribute(text: &str) -> syn::Result<Attribute> {
101    let mut attrs = Attribute::parse_outer.parse_str(text)?;
102    if attrs.len() != 1 {
103        return Err(syn::Error::new(
104            Span::call_site(),
105            format!("expected exactly one attribute, got {}", attrs.len()),
106        ));
107    }
108    Ok(attrs.remove(0))
109}
110
111/// Apply every edit to `source`, returning the rewritten text.
112///
113/// Edits are applied from the highest byte offset to the lowest so that
114/// earlier edits' offsets stay valid as later (already-applied) ones shift
115/// the text around them. Overlapping edits are not supported — callers
116/// must ensure each targets a disjoint span (true by construction here:
117/// every caller in this workspace produces at most one edit per top-level
118/// item).
119#[must_use]
120pub fn apply_edits(source: &str, edits: &[SourceEdit]) -> String {
121    let mut ordered: Vec<&SourceEdit> = edits.iter().collect();
122    ordered.sort_by(|a, b| b.start.cmp(&a.start));
123
124    let mut result = source.to_string();
125    for edit in ordered {
126        result.replace_range(edit.start..edit.end, &edit.replacement);
127    }
128    result
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use syn::spanned::Spanned;
135
136    #[test]
137    fn byte_offset_finds_start_of_a_later_line() {
138        let source = "fn a() {}\nfn b() {}\n";
139        assert_eq!(byte_offset(source, 2, 0), 10);
140    }
141
142    #[test]
143    fn byte_offset_handles_a_mid_line_column() {
144        let source = "fn a() {}\nfn b() {}\n";
145        assert_eq!(byte_offset(source, 2, 3), 13);
146    }
147
148    #[test]
149    fn span_byte_range_covers_exactly_the_item_text() {
150        let source = "fn a() {}\nfn b() {}\n";
151        let file: syn::File = syn::parse_str(source).unwrap();
152        let second = &file.items[1];
153        let (start, end) = span_byte_range(source, second.span());
154        assert_eq!(&source[start..end], "fn b() {}");
155    }
156
157    #[test]
158    fn print_item_renders_valid_rust_with_no_trailing_blank_line() {
159        let item: Item = syn::parse_quote! {
160            fn hello() {
161                println!("hi");
162            }
163        };
164        let rendered = print_item(&item);
165        assert!(rendered.starts_with("fn hello"));
166        assert!(!rendered.ends_with('\n'));
167    }
168
169    #[test]
170    fn parse_attribute_parses_a_single_outer_attribute() {
171        let attr = parse_attribute("#[capability(alloc(none), io(none), ptr(none))]").unwrap();
172        assert!(attr.path().is_ident("capability"));
173    }
174
175    #[test]
176    fn parse_attribute_rejects_malformed_text() {
177        assert!(parse_attribute("not an attribute").is_err());
178    }
179
180    #[test]
181    fn apply_edits_replaces_the_targeted_item_and_nothing_else() {
182        let source = "fn a() {}\nfn b() {}\nfn c() {}\n";
183        let file: syn::File = syn::parse_str(source).unwrap();
184        let (start, end) = span_byte_range(source, file.items[1].span());
185        let rewritten = apply_edits(
186            source,
187            &[SourceEdit {
188                start,
189                end,
190                replacement: "fn b() { /* patched */ }".to_string(),
191            }],
192        );
193        assert_eq!(
194            rewritten,
195            "fn a() {}\nfn b() { /* patched */ }\nfn c() {}\n"
196        );
197    }
198
199    #[test]
200    fn apply_edits_handles_multiple_disjoint_edits_in_one_pass() {
201        let source = "fn a() {}\nfn b() {}\nfn c() {}\n";
202        let file: syn::File = syn::parse_str(source).unwrap();
203        let (a_start, a_end) = span_byte_range(source, file.items[0].span());
204        let (c_start, c_end) = span_byte_range(source, file.items[2].span());
205        let rewritten = apply_edits(
206            source,
207            &[
208                SourceEdit {
209                    start: a_start,
210                    end: a_end,
211                    replacement: "fn a() { /* one */ }".to_string(),
212                },
213                SourceEdit {
214                    start: c_start,
215                    end: c_end,
216                    replacement: "fn c() { /* two */ }".to_string(),
217                },
218            ],
219        );
220        assert_eq!(
221            rewritten,
222            "fn a() { /* one */ }\nfn b() {}\nfn c() { /* two */ }\n"
223        );
224    }
225}