Skip to main content

tsu_editor/
buffer.rs

1use std::path::Path;
2
3use ropey::Rope;
4
5use crate::unicode::column_to_char;
6
7#[derive(Debug)]
8/// The internal representation of an opened file
9///
10/// This entity contains the contents of a file, whether that file exists or is to be written, and
11/// maintains state information about the file, such as whether it is "dirty" (changes have been
12/// made that have not been saved yet), etc.
13pub struct Buffer {
14    /// The path to the file; optional because we can open empty buffers that can be written to files
15    /// using `Action::SaveAs`
16    pub file: Option<String>,
17    /// The contents of the file stored as a Rope which is a fast (worst case O(log N)) data structure
18    /// for editing utf8 text
19    content: Rope,
20    /// State flag for keeping track of unsaved (dirty) changes
21    pub dirty: bool,
22    /// Cursor position within the buffer
23    pub position: (usize, usize),
24    /// The y coordinate of the top of the view
25    pub vtop: usize,
26}
27
28impl Buffer {
29    #[must_use]
30    /// Create a new buffer using a file path and some file contents
31    ///
32    /// # Arguments
33    /// - `file`: Optional file path
34    /// - `contents`: String representation of the contents of the associated file
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use tsu_editor::buffer::Buffer;
40    ///
41    /// let file = Some("some_file.rs".to_string());
42    /// let contents = "";
43    ///
44    /// let buffer = Buffer::new(file.clone(), contents.to_string());
45    ///
46    /// assert_eq!(buffer.file, file);
47    /// ```
48    pub fn new(file: Option<String>, contents: String) -> Self {
49        let contents = if contents.is_empty() {
50            "\n".to_string()
51        } else {
52            contents
53        };
54
55        Self {
56            file,
57            content: Rope::from_str(&contents),
58            dirty: false,
59            position: (0, 0),
60            vtop: 0,
61        }
62    }
63
64    #[allow(clippy::unused_async)]
65    /// Create a new buffer by opening a file and retrieving its contents
66    ///
67    /// # Arguments
68    /// - `file`: An optional file path
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use tsu_editor::buffer::Buffer;
74    ///
75    /// # use std::fs::{self, File};
76    /// # use std::io::Write;
77    /// # use std::env;
78    ///
79    /// # tokio_test::block_on(async {
80    /// # let dir = env::temp_dir();
81    /// # let file_path = dir.join("some_file.rs");
82    /// # let mut file = File::create(&file_path).unwrap();
83    /// let file_path_str = Some(file_path.clone().into_os_string().into_string().unwrap());
84    /// let buffer = Buffer::from_file(file_path_str.clone()).await.unwrap();
85    /// assert_eq!(buffer.file, file_path_str);
86    ///
87    /// # std::fs::remove_file(file_path).unwrap();
88    /// # })
89    /// ```
90    ///
91    /// # Errors
92    /// Can return an `IoError` if the file cannot be read from
93    pub async fn from_file(file: Option<String>) -> anyhow::Result<Self> {
94        match &file {
95            Some(file) => {
96                let path = Path::new(file);
97                if !path.exists() {
98                    return Err(anyhow::anyhow!("file {file} not found"));
99                }
100
101                let contents = std::fs::read_to_string(file)?;
102
103                if contents
104                    .chars()
105                    .any(|c| c as u32 >= 0x1F300 && c as u32 <= 0x1F9FF)
106                {
107                    // NOOP
108                }
109
110                Ok(Self::new(Some(file.to_string()), contents))
111            }
112            None => Ok(Self::new(file, "\n".to_string())),
113        }
114    }
115
116    #[must_use]
117    /// Returns the entirety of the content as a String
118    ///
119    /// # Returns
120    /// - The contents of self as a String
121    pub fn contents(&self) -> String {
122        self.content.to_string()
123    }
124
125    /// Writes the contents of self to `file`
126    ///
127    /// # Returns
128    /// - A message to print to the command line of the editor
129    ///
130    /// # Errors
131    /// Can return an `IoError` if it fails to write the contents to the provided file
132    pub fn save(&mut self) -> anyhow::Result<String> {
133        if let Some(file) = &self.file {
134            let contents = self.contents();
135            std::fs::write(file, &contents)?;
136            self.dirty = false;
137            let message = format!("{:?} {}L, {}B written", file, self.len(), contents.len());
138            Ok(message)
139        } else {
140            Err(anyhow::anyhow!("No file name"))
141        }
142    }
143
144    /// Writes the contents of self to a new file
145    ///
146    /// # Arguments
147    /// - `new_file_name`: The path to the file to be written
148    ///
149    /// # Returns
150    /// - A message to print to the command line of the editor
151    ///
152    /// # Errors
153    /// Can return an `IoError` if it fails to write the contents to the provided file
154    pub fn save_as(&mut self, new_file_name: &str) -> anyhow::Result<String> {
155        let contents = self.contents();
156        std::fs::write(new_file_name, &contents)?;
157        self.dirty = false;
158        self.file = Some(new_file_name.to_string());
159        let message = format!(
160            "{:?} {}L, {}B written",
161            new_file_name,
162            self.len(),
163            contents.len()
164        );
165        Ok(message)
166    }
167
168    #[must_use]
169    /// Retrieves the name of the buffer which corresponds to the file path it was created from
170    ///
171    /// # Returns
172    /// - The name of the buffer; if the buffer was not created from a file, returns "[No name]"
173    pub fn name(&self) -> &str {
174        self.file.as_deref().unwrap_or("[No name]")
175    }
176
177    #[must_use]
178    /// Check if the buffer is dirty, i.e., has unsaved changes'
179    ///
180    /// # Returns
181    /// - `true` if the buffer is dirty, `false` otherwise
182    pub fn is_dirty(&self) -> bool {
183        self.dirty
184    }
185
186    #[must_use]
187    /// Retrieves the contents at a specific line in the buffer
188    ///
189    /// # Arguments
190    /// - `line`: The line number
191    ///
192    /// # Returns
193    /// - The contents of the line if the line exists, i.e., is within the bounds of the buffer
194    pub fn get(&self, line: usize) -> Option<String> {
195        if line > self.len() {
196            return None;
197        }
198
199        Some(self.content.line(line).to_string())
200    }
201
202    #[must_use]
203    /// Get the length of the buffer, i.e., the number of lines
204    ///
205    /// # Returns
206    /// - The size of the buffer expressed as the number of lines
207    pub fn len(&self) -> usize {
208        self.content.len_lines() - 1
209    }
210
211    #[must_use]
212    /// Check if the buffer is empty, i.e., the number of lines is 0
213    ///
214    /// # Returns
215    /// - True if the buffer is empty, false otherwise
216    pub fn is_empty(&self) -> bool {
217        self.content.len_bytes() == 0
218    }
219
220    /// Put a character at a coordinate position within the buffer
221    ///
222    /// # Arguments
223    /// - `x`: The coordinate along the x axis (column)
224    /// - `y`: The coordinate along the y axis (row)
225    /// - `c`: Character to insert at the specified coordinates
226    pub fn insert(&mut self, x: usize, y: usize, c: char) {
227        let char_idx = self.position_to_char_idx(x, y);
228        let total_chars = self.content.len_chars();
229
230        crate::debug!(
231            "Buffer::insert - x: {x}, y: {y}, char: '{c}', char_idx: {char_idx}, total_chars: {total_chars}"
232        );
233
234        if char_idx > total_chars {
235            crate::error!(
236                "char_idx {char_idx} exceeds total_chars {total_chars}! Clamping to end."
237            );
238            self.content.insert_char(total_chars, c);
239        } else {
240            self.content.insert_char(char_idx, c);
241        }
242        self.dirty = true;
243    }
244
245    /// Insert a line at the specified line index (row)
246    ///
247    /// # Arguments
248    /// - `line`: The line index where the contents will be inserted
249    /// - `content`: The string to insert at the specified row
250    pub fn insert_line(&mut self, line: usize, content: &str) {
251        let char_idx = if line >= self.content.len_lines() {
252            self.content.len_chars()
253        } else {
254            self.content.line_to_char(line)
255        };
256        self.content.insert(char_idx, &format!("{content}\n"));
257        self.dirty = true;
258    }
259
260    /// Remove a character at the specified coordinates
261    ///
262    /// # Arguments
263    /// - `x`: The coordinate along the x axis (column)
264    /// - `y`: The coordinate along the y axis (row)
265    pub fn remove(&mut self, x: usize, y: usize) {
266        let char_idx = self.position_to_char_idx(x, y);
267        if char_idx < self.content.len_chars() {
268            self.content.remove(char_idx..=char_idx);
269            self.dirty = true;
270        }
271    }
272
273    /// Replaces the line at a given line index with a new String
274    ///
275    /// # Arguments
276    /// - `line`: The index of the line being replaced
277    /// - `new_line`: The String to be written to the line index
278    pub fn replace_line(&mut self, line: usize, new_line: &str) {
279        if line >= self.len() {
280            return;
281        }
282
283        let start_char = self.content.line_to_char(line);
284        let end_char = if line + 1 < self.len() {
285            self.content.line_to_char(line + 1)
286        } else {
287            self.content.len_chars()
288        };
289
290        self.content.remove(start_char..end_char);
291        self.content.insert(start_char, &format!("{new_line}\n"));
292        self.dirty = true;
293    }
294
295    /// Removes a line at the given line index
296    ///
297    /// # Arguments
298    /// - `line`: The index of the line being removed
299    pub fn remove_line(&mut self, line: usize) {
300        if line >= self.content.len_lines() {
301            return;
302        }
303
304        let start_char = self.content.line_to_char(line);
305        let end_char = if line + 1 < self.content.len_lines() {
306            self.content.line_to_char(line + 1)
307        } else {
308            self.content.len_chars()
309        };
310
311        self.content.remove(start_char..end_char);
312        self.dirty = true;
313    }
314
315    #[must_use]
316    /// Calculates the view inside the buffer using a specified view top and view height
317    ///
318    /// # Arguments
319    /// - `vtop`: The top of the view corresponding to a line index
320    /// - `vheight`: The height of the view corresponding to the number of lines to display
321    ///
322    /// # Returns
323    /// - The contents within the calculated view as a String
324    pub fn view(&self, vtop: usize, vheight: usize) -> String {
325        let height = std::cmp::min(vtop + vheight, self.content.len_lines());
326        let mut result = String::new();
327        for i in vtop..height {
328            result.push_str(&self.content.line(i).to_string());
329        }
330        result
331    }
332
333    /// Converts a coordinate pair to a character index within the `contents` Rope
334    ///
335    /// # Arguments
336    /// - `x`: The coordinate along the x axis (column)
337    /// - `y`: The coordinate along the y axis (row)
338    ///
339    /// # Returns
340    /// - The calculated index of the character within the Rope
341    fn position_to_char_idx(&self, x: usize, y: usize) -> usize {
342        if y >= self.content.len_lines() {
343            return self.content.len_chars();
344        }
345
346        let line_start_char = self.content.line_to_char(y);
347
348        let line = self.content.line(y);
349        let line_chars = line.len_chars();
350
351        let line_chars_no_newline = if line_chars > 0 && line.char(line_chars - 1) == '\n' {
352            line_chars - 1
353        } else {
354            line_chars
355        };
356
357        let x = x.min(line_chars_no_newline);
358
359        line_start_char + x
360    }
361
362    #[must_use]
363    /// Converts a column index to a character index
364    ///
365    /// # Arguments
366    /// - `column`: The column index which is to be converted to a character index
367    /// - `y`: The y coordinate which corresponds to the line number
368    ///
369    /// # Returns
370    /// - The character index as calculated from the column index and the specified line number
371    pub fn column_to_char_index(&self, column: usize, y: usize) -> usize {
372        if let Some(line) = self.get(y) {
373            let line = line.trim_end_matches('\n');
374            column_to_char(line, column)
375        } else {
376            0
377        }
378    }
379}
380
381#[cfg(test)]
382mod test {
383    use super::*;
384
385    #[test]
386    fn test_view() {
387        let buffer = Buffer::new(
388            Some("example".to_string()),
389            "this\nis\na\ntest\nusing\nmultiple\nlines".to_string(),
390        );
391
392        assert_eq!(buffer.view(0, 2), "this\nis\n");
393    }
394
395    #[test]
396    fn test_view_with_small_buffer() {
397        let buffer = Buffer::new(Some("example".to_string()), "a\ntest".to_string());
398        assert_eq!(buffer.view(0, 5), "a\ntest");
399    }
400}