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}