Skip to main content

lattice_format/
apply.rs

1//! Turning a formatter's whole-file output into a minimal edit set.
2//!
3//! A formatter returns a new file, not a patch. Splicing that over the
4//! buffer would be the obvious implementation and the wrong one: it
5//! destroys cursor position, marks and folds, invalidates every
6//! renderer fast path, and shows as a full-viewport repaint — a UX veto
7//! even when the visible text is unchanged.
8//!
9//! `lattice-diff` already computes line-granular hunks for the diff
10//! subsystem, and line granularity is the right resolution here: a
11//! formatter's unit of change *is* the line.
12
13use lattice_diff::compute::compute_diff;
14use lattice_diff::types::DiffAlgorithm;
15use lattice_protocol::edit::Edit;
16use lattice_protocol::position::{Position, Range};
17use ropey::Rope;
18
19/// Edits that transform `old` into `new`, one per changed region.
20///
21/// Returned **bottom-up**, so applying them in order does not shift the
22/// positions of edits not yet applied — the same ordering `>` / `<` /
23/// `=` use.
24///
25/// An empty result means the formatter agreed with the buffer. That is
26/// the common case for an already-formatted file and must cost nothing:
27/// no edit, no undo entry, no repaint.
28pub fn minimal_edits(old: &str, new: &str) -> Vec<Edit> {
29    if old == new {
30        return Vec::new();
31    }
32    let old_rope = Rope::from_str(old);
33    let new_rope = Rope::from_str(new);
34    let Ok(index) = compute_diff(&[old_rope, new_rope], DiffAlgorithm::default()) else {
35        // The diff engine declined. Rather than fall back to a
36        // whole-buffer replace — the exact thing this module exists to
37        // avoid — report no edits and let the caller surface it.
38        tracing::debug!("format: diff engine declined; applying no edits");
39        return Vec::new();
40    };
41
42    let new_lines: Vec<&str> = new.split_inclusive('\n').collect();
43    // Line count the way the DOCUMENT counts: a trailing newline
44    // opens a final empty line. `split_inclusive` does not produce
45    // that line, so counting its items undercounts by one for the
46    // common newline-terminated file.
47    let old_line_count = old.matches('\n').count() + 1;
48
49    let mut edits: Vec<Edit> = Vec::new();
50    for hunk in &index.hunks {
51        // `ranges[0]` is the old side, `ranges[1]` the new side.
52        let (Some(old_range), Some(new_range)) = (hunk.ranges.first(), hunk.ranges.get(1)) else {
53            continue;
54        };
55        let replacement: String = (new_range.start..new_range.end)
56            .filter_map(|l| new_lines.get(l as usize).copied())
57            .collect();
58
59        let start = Position::new(old_range.start, 0);
60        // A hunk's end is exclusive, so the range runs to the START of
61        // the following line — which takes the preceding newline with
62        // it, and is what makes a pure deletion remove the line rather
63        // than leave a blank one.
64        //
65        // Clamped to the document's last line index. An earlier
66        // revision used `Position::new(last, u32::MAX)` as a
67        // "to end of line" sentinel; that is NOT this codebase's
68        // convention and `position_to_byte` rejects it outright. The
69        // unit test below passed anyway because it special-cased the
70        // same invented sentinel — validating the code against itself
71        // rather than against the buffer. Only the host-level test
72        // caught it.
73        let last_line = old_line_count.saturating_sub(1) as u32;
74        let end = Position::new(old_range.end.min(last_line), 0);
75        edits.push(Edit::replace(Range::new(start, end), replacement));
76    }
77    // Bottom-up.
78    edits.sort_by(|a, b| b.range.start.line.cmp(&a.range.start.line));
79    edits
80}
81
82/// Whether `new` differs from `old` in more than leading whitespace.
83///
84/// Used to tell an *indent* filter (an `external:` rung of
85/// `format.indent`, which is specified to
86/// adjust leading whitespace only) from a *reformatter* that was
87/// pointed at the wrong option. A tool that rewrites content is not an
88/// indent filter, and running it from `=` would break the operator's
89/// contract with motions.
90pub fn changes_more_than_indentation(old: &str, new: &str) -> bool {
91    let strip = |s: &str| {
92        s.lines()
93            .map(|l| l.trim_start_matches([' ', '\t']))
94            .collect::<Vec<_>>()
95            .join("\n")
96    };
97    strip(old) != strip(new)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// Apply `edits` to `old` the way the buffer would, so the test
105    /// checks the edits rather than restating the diff.
106    fn apply(old: &str, edits: &[Edit]) -> String {
107        let mut rope = Rope::from_str(old);
108        for edit in edits {
109            let r = edit.range;
110            let start_line = (r.start.line as usize).min(rope.len_lines());
111            let start = rope.line_to_char(start_line) + r.start.byte as usize;
112            let end_line = (r.end.line as usize).min(rope.len_lines());
113            let end = rope.line_to_char(end_line) + r.end.byte as usize;
114            let start = start.min(rope.len_chars());
115            let end = end.min(rope.len_chars()).max(start);
116            rope.remove(start..end);
117            let lattice_protocol::edit::EditKind::Replace { text } = &edit.kind;
118            rope.insert(start, text);
119        }
120        rope.to_string()
121    }
122
123    #[test]
124    fn an_already_formatted_buffer_produces_no_edits() {
125        // The idempotence guard. If a whole-buffer replace ever sneaks
126        // back in, this is what catches it — a replace is never empty.
127        assert!(minimal_edits("a\nb\nc\n", "a\nb\nc\n").is_empty());
128    }
129
130    #[test]
131    fn a_single_changed_line_touches_only_that_line() {
132        let edits = minimal_edits("a\nb\nc\n", "a\nB\nc\n");
133        assert_eq!(edits.len(), 1, "one hunk, one edit");
134        assert_eq!(edits[0].range.start.line, 1);
135        assert_eq!(apply("a\nb\nc\n", &edits), "a\nB\nc\n");
136    }
137
138    #[test]
139    fn edits_round_trip_for_a_reindent() {
140        let old = "fn f() {\nx();\n        y();\n}\n";
141        let new = "fn f() {\n    x();\n    y();\n}\n";
142        let edits = minimal_edits(old, new);
143        assert!(!edits.is_empty());
144        assert_eq!(apply(old, &edits), new);
145    }
146
147    #[test]
148    fn edits_round_trip_for_an_insertion() {
149        let old = "a\nd\n";
150        let new = "a\nb\nc\nd\n";
151        let edits = minimal_edits(old, new);
152        assert_eq!(apply(old, &edits), new);
153    }
154
155    #[test]
156    fn edits_round_trip_for_a_deletion() {
157        let old = "a\nb\nc\nd\n";
158        let new = "a\nd\n";
159        let edits = minimal_edits(old, new);
160        assert_eq!(apply(old, &edits), new);
161    }
162
163    #[test]
164    fn indent_only_changes_are_recognised_as_such() {
165        assert!(!changes_more_than_indentation(
166            "fn f() {\nx();\n}\n",
167            "fn f() {\n    x();\n}\n"
168        ));
169        // A reformatter moving a brace is not an indent filter.
170        assert!(changes_more_than_indentation(
171            "fn f() {\nx();\n}\n",
172            "fn f()\n{\n    x();\n}\n"
173        ));
174    }
175}