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}