1pub mod history;
5pub mod id;
6pub mod layout;
7pub mod selection;
8
9use history::{Edit, EditKind, History};
10use ropey::Rope;
11
12pub struct Buffer {
14 pub rope: Rope,
15 pub path: Option<String>,
16 pub dirty: bool,
17 pub epoch: u64,
19 pub readonly: bool,
21 pub name: Option<String>,
24 pub history: History,
27 pub replaying: bool,
29 disk_stamp: Option<std::time::SystemTime>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MotionShape {
38 Characterwise { inclusive: bool },
39 Linewise,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Range {
46 pub start: usize,
47 pub end: usize,
48 pub shape: MotionShape,
49}
50
51impl Range {
52 pub fn charwise(start: impl Into<id::ByteOffset>, end: impl Into<id::ByteOffset>) -> Self {
53 let (start, end) = (start.into().get(), end.into().get());
54 debug_assert!(start <= end);
55 Self {
56 start,
57 end,
58 shape: MotionShape::Characterwise { inclusive: false },
59 }
60 }
61 pub fn linewise(start: impl Into<id::ByteOffset>, end: impl Into<id::ByteOffset>) -> Self {
62 let (start, end) = (start.into().get(), end.into().get());
63 debug_assert!(start <= end);
64 Self {
65 start,
66 end,
67 shape: MotionShape::Linewise,
68 }
69 }
70 pub fn is_linewise(&self) -> bool {
71 matches!(self.shape, MotionShape::Linewise)
72 }
73 pub fn with_inclusive(mut self, inclusive: bool) -> Self {
75 if let MotionShape::Characterwise { inclusive: i } = &mut self.shape {
76 *i = inclusive;
77 }
78 self
79 }
80 pub fn inclusive(&self) -> bool {
81 matches!(self.shape, MotionShape::Characterwise { inclusive: true })
82 }
83 pub fn len(&self) -> usize {
85 self.end - self.start
86 }
87 pub fn is_empty(&self) -> bool {
88 self.start == self.end
89 }
90}
91
92impl Buffer {
93 pub fn from_text(text: &str) -> Self {
94 Self {
95 rope: Rope::from_str(text),
96 path: None,
97 dirty: false,
98 epoch: 0,
99 readonly: false,
100 name: None,
101 history: History::default(),
102 replaying: false,
103 disk_stamp: None,
104 }
105 }
106
107 pub fn open(path: &str) -> std::io::Result<Self> {
110 let text = match std::fs::read_to_string(path) {
111 Ok(t) => t,
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
113 Err(e) => return Err(e),
114 };
115 let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
116 Ok(Self {
117 rope: Rope::from_str(&text),
118 path: Some(path.to_string()),
119 dirty: false,
120 epoch: 0,
121 readonly: false,
122 name: None,
123 history: History::default(),
124 replaying: false,
125 disk_stamp,
126 })
127 }
128
129 pub fn save(&mut self, force: bool) -> std::io::Result<()> {
133 let Some(path) = self.path.clone() else {
134 return Err(std::io::Error::new(
137 std::io::ErrorKind::NotFound,
138 "no file name — :w {path} to name it",
139 ));
140 };
141 let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
142 if !force && current.is_some() && current != self.disk_stamp {
143 return Err(std::io::Error::new(
144 std::io::ErrorKind::PermissionDenied,
145 "file changed on disk — :w! to force",
146 ));
147 }
148 write_atomic(std::path::Path::new(&path), &self.rope.to_string())?;
149 self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
150 self.dirty = false;
151 Ok(())
152 }
153
154 pub fn save_as(&mut self, path: &str, force: bool) -> std::io::Result<()> {
159 let target = std::path::Path::new(path);
160 if !force && target.exists() {
161 return Err(std::io::Error::new(
162 std::io::ErrorKind::PermissionDenied,
163 "file exists — :w! to overwrite",
164 ));
165 }
166 write_atomic(target, &self.rope.to_string())?;
167 self.path = Some(path.to_string());
169 self.disk_stamp = std::fs::metadata(target).and_then(|m| m.modified()).ok();
170 self.dirty = false;
171 Ok(())
172 }
173 pub fn cell_col_of(&self, offset: impl Into<id::ByteOffset>) -> u16 {
178 let offset = offset.into().get();
179 if self.len_bytes() == 0 {
180 return 0;
181 }
182 let line = self.line_of(offset);
183 let (s, e) = (self.line_start(line), self.line_end(line));
184 let text = self.rope.byte_slice(s..e).to_string();
185 let col = offset.saturating_sub(s);
186 let layout = layout::LineLayout::build(text.trim_end_matches('\n'), 8);
187 layout.cell_at_byte(col.min(layout.len_bytes))
188 }
189
190 pub fn len_bytes(&self) -> usize {
191 self.rope.len_bytes()
192 }
193 pub fn len_lines(&self) -> usize {
194 self.rope.len_lines()
195 }
196
197 pub fn last_content_line(&self) -> usize {
200 let mut l = self.len_lines().saturating_sub(1);
201 if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
202 l -= 1;
203 }
204 l
205 }
206
207 pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
209 self.rope
210 .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
211 }
212
213 pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
215 let line = line.into().get();
216 let start = self.line_start(line);
217 let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
218 if line + 1 >= self.len_lines() {
219 end = self.len_bytes();
220 }
221 if end > start && self.byte(end - 1) == b'\n' {
223 end -= 1;
224 }
225 end
226 }
227
228 pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
229 self.rope
230 .byte_to_line(offset.into().get().min(self.len_bytes()))
231 }
232
233 pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
235 let offset = offset.into();
236 offset.get() - self.line_start(self.line_of(offset))
237 }
238 pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
243 if self.len_bytes() == 0 {
244 return 0;
245 }
246 self.rope
247 .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
248 }
249
250 pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
251 let off = offset.into().get();
252 if off < self.len_bytes() {
253 Some(self.rope.byte(off))
254 } else {
255 None
256 }
257 }
258
259 pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
264 let off = offset.into().get();
265 if off == 0 || off == self.len_bytes() {
266 return true;
267 }
268 if off > self.len_bytes() {
269 return false;
270 }
271 match self.rope.try_byte_to_char(off) {
272 Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
273 Err(_) => false,
274 }
275 }
276
277 pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
280 let mut offset = offset.into().get().min(self.len_bytes());
281 while offset > 0 && !self.is_boundary(offset) {
282 offset -= 1;
283 }
284 offset
285 }
286
287 pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
292 let mut offset = offset.into().get().min(self.len_bytes());
293 while offset < self.len_bytes() && !self.is_boundary(offset) {
294 offset += 1;
295 }
296 offset
297 }
298
299 pub fn slice_string(&self, range: Range) -> String {
302 let start = range.start.min(self.len_bytes());
303 let end = range.end.min(self.len_bytes());
304 self.rope.byte_slice(start..end.max(start)).to_string()
305 }
306
307 pub fn apply_history(&mut self, ops: Vec<Edit>) {
309 self.replaying = true;
310 for op in ops {
311 match op.kind {
312 EditKind::Insert => {
313 let at = self.clamp_boundary(op.at.min(self.len_bytes()));
314 self.rope.insert(self.rope.byte_to_char(at), &op.text);
315 }
316 EditKind::Delete => {
317 let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
320 let start = self.clamp_boundary(op.at.min(end));
321 if start < end {
322 self.rope
323 .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
324 }
325 }
326 }
327 }
328 self.replaying = false;
329 self.dirty = true;
330 self.epoch += 1;
331 }
332
333 pub fn replace_all(&mut self, text: &str) {
337 if self.readonly {
338 return;
339 }
340 self.replace_all_system(text);
341 }
342
343 pub fn replace_all_system(&mut self, text: &str) {
347 self.rope = Rope::from_str(text);
348 self.epoch += 1;
349 }
350
351 pub fn delete(&mut self, range: Range) -> String {
355 if self.readonly && !self.replaying {
356 return String::new();
357 }
358 let start = self.clamp_boundary(range.start.min(self.len_bytes()));
360 let end = self.clamp_boundary(range.end.min(self.len_bytes()));
361 if start >= end {
362 return String::new();
363 }
364 let text = self.rope.byte_slice(start..end).to_string();
365 let cstart = self.rope.byte_to_char(start);
367 let cend = self.rope.byte_to_char(end);
368 self.rope.remove(cstart..cend);
369 self.dirty = true;
370 self.epoch += 1;
371 if !self.replaying && !self.readonly {
372 self.history.record(
373 Edit {
374 at: range.start,
375 text: text.clone(),
376 kind: EditKind::Insert,
377 },
378 Edit {
379 at: range.start,
380 text: text.clone(),
381 kind: EditKind::Delete,
382 },
383 );
384 }
385 text
386 }
387
388 pub fn insert(&mut self, at: impl Into<id::ByteOffset>, text: &str) {
389 if self.readonly && !self.replaying {
390 return;
391 }
392 let at = self.clamp_boundary(at);
393 self.rope.insert(self.rope.byte_to_char(at), text);
394 self.dirty = true;
395 self.epoch += 1;
396 if !self.replaying && !self.readonly {
397 self.history.record(
398 Edit {
399 at,
400 text: text.into(),
401 kind: EditKind::Delete,
402 },
403 Edit {
404 at,
405 text: text.into(),
406 kind: EditKind::Insert,
407 },
408 );
409 }
410 }
411
412 pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
413 let line = line.into().get();
414 let start = self.line_start(line);
415 let end = self.line_end(line);
416 self.rope.byte_slice(start..end).to_string()
417 }
418}
419
420fn write_atomic(target: &std::path::Path, contents: &str) -> std::io::Result<()> {
423 let tmp = target.with_file_name(format!(
424 ".strop-tmp-{}-{}",
425 std::process::id(),
426 target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
427 ));
428 std::fs::write(&tmp, contents)?;
429 if let Ok(meta) = std::fs::metadata(target) {
430 let _ = std::fs::set_permissions(&tmp, meta.permissions());
432 }
433 std::fs::rename(&tmp, target)
434}
435
436#[cfg(test)]
437mod safety_tests {
438 use super::*;
439
440 #[test]
441 fn save_refuses_external_change_unless_forced() {
442 let dir = tempfile::tempdir().unwrap();
443 let f = dir.path().join("f.txt");
444 std::fs::write(&f, "original\n").unwrap();
445 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
446 b.insert(id::ByteOffset::new(0), "mine ");
447 std::thread::sleep(std::time::Duration::from_millis(5));
449 std::fs::write(&f, "theirs\n").unwrap();
450 let err = b.save(false).unwrap_err();
451 assert!(err.to_string().contains("changed on disk"));
452 assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
453 b.save(true).unwrap(); assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
455 assert!(!b.dirty);
456 }
457
458 #[test]
459 fn save_is_atomic_and_keeps_permissions() {
460 use std::os::unix::fs::PermissionsExt;
461 let dir = tempfile::tempdir().unwrap();
462 let f = dir.path().join("x.sh");
463 std::fs::write(&f, "#!/bin/sh\n").unwrap();
464 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
465 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
466 b.insert(id::ByteOffset::new(b.len_bytes()), "echo hi\n");
467 b.save(false).unwrap();
468 assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
469 let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
470 assert_eq!(mode, 0o750, "permissions survive the swap");
471 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
473 }
474
475 #[test]
476 fn readonly_refuses_mutation_at_the_boundary() {
477 let mut b = Buffer::from_text("abc\n");
479 b.readonly = true;
480 b.insert(id::ByteOffset::new(0), "nope");
481 let gone = b.delete(Range::charwise(0, 2));
482 assert_eq!(gone, "");
483 assert_eq!(b.rope.to_string(), "abc\n", "untouched");
484 b.replace_all_system("gen\n");
486 assert_eq!(b.rope.to_string(), "gen\n");
487 }
488}