reindent.rs

  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}