1pub mod history;
5
6use history::{Edit, EditKind, History};
7use ropey::Rope;
8
9pub struct Buffer {
11 pub rope: Rope,
12 pub path: Option<String>,
13 pub dirty: bool,
14 pub epoch: u64,
16 pub readonly: bool,
18 pub name: Option<String>,
21 pub history: History,
24 pub replaying: bool,
26 disk_stamp: Option<std::time::SystemTime>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Range {
33 pub start: usize,
34 pub end: usize,
35 pub linewise: bool,
38}
39
40impl Range {
41 pub fn charwise(start: usize, end: usize) -> Self {
42 debug_assert!(start <= end);
43 Self {
44 start,
45 end,
46 linewise: false,
47 }
48 }
49 pub fn linewise(start: usize, end: usize) -> Self {
50 debug_assert!(start <= end);
51 Self {
52 start,
53 end,
54 linewise: true,
55 }
56 }
57 pub fn len(&self) -> usize {
58 self.end - self.start
59 }
60 pub fn is_empty(&self) -> bool {
61 self.start == self.end
62 }
63}
64
65impl Buffer {
66 pub fn from_text(text: &str) -> Self {
67 Self {
68 rope: Rope::from_str(text),
69 path: None,
70 dirty: false,
71 epoch: 0,
72 readonly: false,
73 name: None,
74 history: History::default(),
75 replaying: false,
76 disk_stamp: None,
77 }
78 }
79
80 pub fn open(path: &str) -> std::io::Result<Self> {
83 let text = match std::fs::read_to_string(path) {
84 Ok(t) => t,
85 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
86 Err(e) => return Err(e),
87 };
88 let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
89 Ok(Self {
90 rope: Rope::from_str(&text),
91 path: Some(path.to_string()),
92 dirty: false,
93 epoch: 0,
94 readonly: false,
95 name: None,
96 history: History::default(),
97 replaying: false,
98 disk_stamp,
99 })
100 }
101
102 pub fn save(&mut self, force: bool) -> std::io::Result<()> {
106 let Some(path) = self.path.clone() else {
107 return Ok(());
108 };
109 let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
110 if !force && current.is_some() && current != self.disk_stamp {
111 return Err(std::io::Error::new(
112 std::io::ErrorKind::PermissionDenied,
113 "file changed on disk — :w! to force",
114 ));
115 }
116 let target = std::path::Path::new(&path);
117 let tmp = target.with_file_name(format!(
118 ".strop-tmp-{}-{}",
119 std::process::id(),
120 target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
121 ));
122 std::fs::write(&tmp, self.rope.to_string())?;
123 if let Ok(meta) = std::fs::metadata(target) {
124 let _ = std::fs::set_permissions(&tmp, meta.permissions());
126 }
127 std::fs::rename(&tmp, target)?;
128 self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
129 self.dirty = false;
130 Ok(())
131 }
132 pub fn len_bytes(&self) -> usize {
133 self.rope.len_bytes()
134 }
135 pub fn len_lines(&self) -> usize {
136 self.rope.len_lines()
137 }
138
139 pub fn last_content_line(&self) -> usize {
142 let mut l = self.len_lines().saturating_sub(1);
143 if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
144 l -= 1;
145 }
146 l
147 }
148
149 pub fn line_start(&self, line: usize) -> usize {
151 self.rope
152 .line_to_byte(line.min(self.len_lines().saturating_sub(1)))
153 }
154
155 pub fn line_end(&self, line: usize) -> usize {
157 let start = self.line_start(line);
158 let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
159 if line + 1 >= self.len_lines() {
160 end = self.len_bytes();
161 }
162 if end > start && self.byte(end - 1) == b'\n' {
164 end -= 1;
165 }
166 end
167 }
168
169 pub fn line_of(&self, offset: usize) -> usize {
170 self.rope.byte_to_line(offset.min(self.len_bytes()))
171 }
172
173 pub fn col_of(&self, offset: usize) -> usize {
175 offset - self.line_start(self.line_of(offset))
176 }
177
178 pub fn byte(&self, offset: usize) -> u8 {
179 self.rope
180 .byte(offset.min(self.len_bytes().saturating_sub(1)))
181 }
182
183 pub fn byte_at(&self, offset: usize) -> Option<u8> {
184 if offset < self.len_bytes() {
185 Some(self.rope.byte(offset))
186 } else {
187 None
188 }
189 }
190
191 pub fn is_boundary(&self, offset: usize) -> bool {
196 if offset == 0 || offset == self.len_bytes() {
197 return true;
198 }
199 if offset > self.len_bytes() {
200 return false;
201 }
202 match self.rope.try_byte_to_char(offset) {
203 Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == offset),
204 Err(_) => false,
205 }
206 }
207
208 pub fn clamp_boundary(&self, mut offset: usize) -> usize {
211 offset = offset.min(self.len_bytes());
212 while offset > 0 && !self.is_boundary(offset) {
213 offset -= 1;
214 }
215 offset
216 }
217
218 pub fn ceil_boundary(&self, mut offset: usize) -> usize {
223 offset = offset.min(self.len_bytes());
224 while offset < self.len_bytes() && !self.is_boundary(offset) {
225 offset += 1;
226 }
227 offset
228 }
229
230 pub fn slice_string(&self, range: Range) -> String {
233 let start = range.start.min(self.len_bytes());
234 let end = range.end.min(self.len_bytes());
235 self.rope.byte_slice(start..end.max(start)).to_string()
236 }
237
238 pub fn apply_history(&mut self, ops: Vec<Edit>) {
240 self.replaying = true;
241 for op in ops {
242 match op.kind {
243 EditKind::Insert => {
244 let at = self.clamp_boundary(op.at.min(self.len_bytes()));
245 self.rope.insert(self.rope.byte_to_char(at), &op.text);
246 }
247 EditKind::Delete => {
248 let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
251 let start = self.clamp_boundary(op.at.min(end));
252 if start < end {
253 self.rope
254 .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
255 }
256 }
257 }
258 }
259 self.replaying = false;
260 self.dirty = true;
261 self.epoch += 1;
262 }
263
264 pub fn replace_all(&mut self, text: &str) {
268 if self.readonly {
269 return;
270 }
271 self.replace_all_system(text);
272 }
273
274 pub fn replace_all_system(&mut self, text: &str) {
278 self.rope = Rope::from_str(text);
279 self.epoch += 1;
280 }
281
282 pub fn delete(&mut self, range: Range) -> String {
286 if self.readonly && !self.replaying {
287 return String::new();
288 }
289 let start = self.clamp_boundary(range.start.min(self.len_bytes()));
291 let end = self.clamp_boundary(range.end.min(self.len_bytes()));
292 if start >= end {
293 return String::new();
294 }
295 let text = self.rope.byte_slice(start..end).to_string();
296 let cstart = self.rope.byte_to_char(start);
298 let cend = self.rope.byte_to_char(end);
299 self.rope.remove(cstart..cend);
300 self.dirty = true;
301 self.epoch += 1;
302 if !self.replaying && !self.readonly {
303 self.history.record(
304 Edit {
305 at: range.start,
306 text: text.clone(),
307 kind: EditKind::Insert,
308 },
309 Edit {
310 at: range.start,
311 text: text.clone(),
312 kind: EditKind::Delete,
313 },
314 );
315 }
316 text
317 }
318
319 pub fn insert(&mut self, at: usize, text: &str) {
320 if self.readonly && !self.replaying {
321 return;
322 }
323 self.rope.insert(self.rope.byte_to_char(at), text);
324 self.dirty = true;
325 self.epoch += 1;
326 if !self.replaying && !self.readonly {
327 self.history.record(
328 Edit {
329 at,
330 text: text.into(),
331 kind: EditKind::Delete,
332 },
333 Edit {
334 at,
335 text: text.into(),
336 kind: EditKind::Insert,
337 },
338 );
339 }
340 }
341
342 pub fn line_text(&self, line: usize) -> String {
343 let start = self.line_start(line);
344 let end = self.line_end(line);
345 self.rope.byte_slice(start..end).to_string()
346 }
347}
348
349#[cfg(test)]
350mod safety_tests {
351 use super::*;
352
353 #[test]
354 fn save_refuses_external_change_unless_forced() {
355 let dir = tempfile::tempdir().unwrap();
356 let f = dir.path().join("f.txt");
357 std::fs::write(&f, "original\n").unwrap();
358 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
359 b.insert(0, "mine ");
360 std::thread::sleep(std::time::Duration::from_millis(5));
362 std::fs::write(&f, "theirs\n").unwrap();
363 let err = b.save(false).unwrap_err();
364 assert!(err.to_string().contains("changed on disk"));
365 assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
366 b.save(true).unwrap(); assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
368 assert!(!b.dirty);
369 }
370
371 #[test]
372 fn save_is_atomic_and_keeps_permissions() {
373 use std::os::unix::fs::PermissionsExt;
374 let dir = tempfile::tempdir().unwrap();
375 let f = dir.path().join("x.sh");
376 std::fs::write(&f, "#!/bin/sh\n").unwrap();
377 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
378 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
379 b.insert(b.len_bytes(), "echo hi\n");
380 b.save(false).unwrap();
381 assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
382 let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
383 assert_eq!(mode, 0o750, "permissions survive the swap");
384 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
386 }
387
388 #[test]
389 fn readonly_refuses_mutation_at_the_boundary() {
390 let mut b = Buffer::from_text("abc\n");
392 b.readonly = true;
393 b.insert(0, "nope");
394 let gone = b.delete(Range::charwise(0, 2));
395 assert_eq!(gone, "");
396 assert_eq!(b.rope.to_string(), "abc\n", "untouched");
397 b.replace_all_system("gen\n");
399 assert_eq!(b.rope.to_string(), "gen\n");
400 }
401}