1use language::LineIndent;
2use std::{cmp, iter};
3
4#[derive(Copy, Clone, Debug)]
5pub enum IndentDelta {
6 Spaces(isize),
7 Tabs(isize),
8}
9
10impl IndentDelta {
11 pub fn character(&self) -> char {
12 match self {
13 IndentDelta::Spaces(_) => ' ',
14 IndentDelta::Tabs(_) => '\t',
15 }
16 }
17
18 pub fn len(&self) -> isize {
19 match self {
20 IndentDelta::Spaces(n) => *n,
21 IndentDelta::Tabs(n) => *n,
22 }
23 }
24}
25
26pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent) -> IndentDelta {
27 if buffer_indent.tabs > 0 {
28 IndentDelta::Tabs(buffer_indent.tabs as isize - query_indent.tabs as isize)
29 } else {
30 IndentDelta::Spaces(buffer_indent.spaces as isize - query_indent.spaces as isize)
31 }
32}
33
34/// Synchronous re-indentation adapter. Buffers incomplete lines and applies
35/// an `IndentDelta` to each line's leading whitespace before emitting it.
36pub struct Reindenter {
37 delta: IndentDelta,
38 buffer: String,
39 in_leading_whitespace: bool,
40}
41
42impl Reindenter {
43 pub fn new(delta: IndentDelta) -> Self {
44 Self {
45 delta,
46 buffer: String::new(),
47 in_leading_whitespace: true,
48 }
49 }
50
51 /// Feed a chunk of text and return the re-indented portion that is
52 /// ready to emit. Incomplete trailing lines are buffered internally.
53 pub fn push(&mut self, chunk: &str) -> String {
54 self.buffer.push_str(chunk);
55 self.drain(false)
56 }
57
58 /// Flush any remaining buffered content (call when the stream is done).
59 pub fn finish(&mut self) -> String {
60 self.drain(true)
61 }
62
63 fn drain(&mut self, is_final: bool) -> String {
64 let mut indented = String::new();
65 let mut start_ix = 0;
66 let mut newlines = self.buffer.match_indices('\n');
67 loop {
68 let (line_end, is_pending_line) = match newlines.next() {
69 Some((ix, _)) => (ix, false),
70 None => (self.buffer.len(), true),
71 };
72 let line = &self.buffer[start_ix..line_end];
73
74 if self.in_leading_whitespace {
75 if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) {
76 // We found a non-whitespace character, adjust indentation
77 // based on the delta.
78 let new_indent_len =
79 cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize;
80 indented.extend(iter::repeat(self.delta.character()).take(new_indent_len));
81 indented.push_str(&line[non_whitespace_ix..]);
82 self.in_leading_whitespace = false;
83 } else if is_pending_line && !is_final {
84 // We're still in leading whitespace and this line is incomplete.
85 // Stop processing until we receive more input.
86 break;
87 } else {
88 // This line is entirely whitespace. Push it without indentation.
89 indented.push_str(line);
90 }
91 } else {
92 indented.push_str(line);
93 }
94
95 if is_pending_line {
96 start_ix = line_end;
97 break;
98 } else {
99 self.in_leading_whitespace = true;
100 indented.push('\n');
101 start_ix = line_end + 1;
102 }
103 }
104 self.buffer.replace_range(..start_ix, "");
105 if is_final {
106 indented.push_str(&self.buffer);
107 self.buffer.clear();
108 }
109 indented
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn test_indent_single_chunk() {
119 let mut r = Reindenter::new(IndentDelta::Spaces(2));
120 let out = r.push(" abc\n def\n ghi");
121 // All three lines are emitted: "ghi" starts with spaces but
122 // contains non-whitespace, so it's processed immediately.
123 assert_eq!(out, " abc\n def\n ghi");
124 let out = r.finish();
125 assert_eq!(out, "");
126 }
127
128 #[test]
129 fn test_outdent_tabs() {
130 let mut r = Reindenter::new(IndentDelta::Tabs(-2));
131 let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi");
132 assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi");
133 let out = r.finish();
134 assert_eq!(out, "");
135 }
136
137 #[test]
138 fn test_incremental_chunks() {
139 let mut r = Reindenter::new(IndentDelta::Spaces(2));
140 // Feed " ab" — the `a` is non-whitespace, so the line is
141 // processed immediately even without a trailing newline.
142 let out = r.push(" ab");
143 assert_eq!(out, " ab");
144 // Feed "c\n" — appended to the already-processed line (no longer
145 // in leading whitespace).
146 let out = r.push("c\n");
147 assert_eq!(out, "c\n");
148 let out = r.finish();
149 assert_eq!(out, "");
150 }
151
152 #[test]
153 fn test_zero_delta() {
154 let mut r = Reindenter::new(IndentDelta::Spaces(0));
155 let out = r.push(" hello\n world\n");
156 assert_eq!(out, " hello\n world\n");
157 let out = r.finish();
158 assert_eq!(out, "");
159 }
160
161 #[test]
162 fn test_clamp_negative_indent() {
163 let mut r = Reindenter::new(IndentDelta::Spaces(-10));
164 let out = r.push(" abc\n");
165 // max(0, 2 - 10) = 0, so no leading spaces.
166 assert_eq!(out, "abc\n");
167 let out = r.finish();
168 assert_eq!(out, "");
169 }
170
171 #[test]
172 fn test_whitespace_only_lines() {
173 let mut r = Reindenter::new(IndentDelta::Spaces(2));
174 let out = r.push(" \n code\n");
175 // First line is all whitespace — emitted verbatim. Second line is indented.
176 assert_eq!(out, " \n code\n");
177 let out = r.finish();
178 assert_eq!(out, "");
179 }
180
181 #[test]
182 fn test_compute_indent_delta_spaces() {
183 let buffer = LineIndent {
184 tabs: 0,
185 spaces: 8,
186 line_blank: false,
187 };
188 let query = LineIndent {
189 tabs: 0,
190 spaces: 4,
191 line_blank: false,
192 };
193 let delta = compute_indent_delta(buffer, query);
194 assert_eq!(delta.len(), 4);
195 assert_eq!(delta.character(), ' ');
196 }
197
198 #[test]
199 fn test_compute_indent_delta_tabs() {
200 let buffer = LineIndent {
201 tabs: 2,
202 spaces: 0,
203 line_blank: false,
204 };
205 let query = LineIndent {
206 tabs: 3,
207 spaces: 0,
208 line_blank: false,
209 };
210 let delta = compute_indent_delta(buffer, query);
211 assert_eq!(delta.len(), -1);
212 assert_eq!(delta.character(), '\t');
213 }
214}