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<std::path::PathBuf>,
19 pub dirty: bool,
20 pub epoch: u64,
22 pub readonly: bool,
24 pub name: Option<String>,
27 pub history: History,
30 pub replaying: bool,
32 disk_stamp: Option<std::time::SystemTime>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum MotionShape {
41 Characterwise { inclusive: bool },
42 Linewise,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Range {
49 pub start: usize,
50 pub end: usize,
51 pub shape: MotionShape,
52}
53
54impl Range {
55 pub fn charwise(start: impl Into<id::ByteOffset>, end: impl Into<id::ByteOffset>) -> Self {
56 let (start, end) = (start.into().get(), end.into().get());
57 debug_assert!(start <= end);
58 Self {
59 start,
60 end,
61 shape: MotionShape::Characterwise { inclusive: false },
62 }
63 }
64 pub fn linewise(start: impl Into<id::ByteOffset>, end: impl Into<id::ByteOffset>) -> Self {
65 let (start, end) = (start.into().get(), end.into().get());
66 debug_assert!(start <= end);
67 Self {
68 start,
69 end,
70 shape: MotionShape::Linewise,
71 }
72 }
73 pub fn is_linewise(&self) -> bool {
74 matches!(self.shape, MotionShape::Linewise)
75 }
76 pub fn with_inclusive(mut self, inclusive: bool) -> Self {
78 if let MotionShape::Characterwise { inclusive: i } = &mut self.shape {
79 *i = inclusive;
80 }
81 self
82 }
83 pub fn inclusive(&self) -> bool {
84 matches!(self.shape, MotionShape::Characterwise { inclusive: true })
85 }
86 pub fn len(&self) -> usize {
88 self.end - self.start
89 }
90 pub fn is_empty(&self) -> bool {
91 self.start == self.end
92 }
93}
94
95impl Buffer {
96 pub fn from_text(text: &str) -> Self {
97 Self {
98 rope: Rope::from_str(text),
99 path: None,
100 dirty: false,
101 epoch: 0,
102 readonly: false,
103 name: None,
104 history: History::default(),
105 replaying: false,
106 disk_stamp: None,
107 }
108 }
109
110 pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
113 let path = path.as_ref();
114 let text = match std::fs::read_to_string(path) {
115 Ok(t) => t,
116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
117 Err(e) => return Err(e),
118 };
119 let disk_stamp = std::fs::metadata(path).and_then(|m| m.modified()).ok();
120 Ok(Self {
121 rope: Rope::from_str(&text),
122 path: Some(path.to_path_buf()),
123 dirty: false,
124 epoch: 0,
125 readonly: false,
126 name: None,
127 history: History::default(),
128 replaying: false,
129 disk_stamp,
130 })
131 }
132
133 pub fn save(&mut self, force: bool) -> std::io::Result<()> {
137 let Some(path) = self.path.clone() else {
138 return Err(std::io::Error::new(
141 std::io::ErrorKind::NotFound,
142 "no file name — :w {path} to name it",
143 ));
144 };
145 let path = std::fs::canonicalize(&path).unwrap_or(path);
148 let current = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
149 if !force && current.is_some() && current != self.disk_stamp {
150 return Err(std::io::Error::new(
151 std::io::ErrorKind::PermissionDenied,
152 "file changed on disk — :w! to force",
153 ));
154 }
155 write_atomic(std::path::Path::new(&path), &self.rope.to_string())?;
156 self.disk_stamp = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
157 self.dirty = false;
158 Ok(())
159 }
160
161 pub fn save_as(&mut self, path: &str, force: bool) -> std::io::Result<()> {
166 let target = std::path::Path::new(path);
167 if !force && target.exists() {
168 return Err(std::io::Error::new(
169 std::io::ErrorKind::PermissionDenied,
170 "file exists — :w! to overwrite",
171 ));
172 }
173 write_atomic(target, &self.rope.to_string())?;
174 self.path = Some(std::path::PathBuf::from(path));
176 self.disk_stamp = std::fs::metadata(target).and_then(|m| m.modified()).ok();
177 self.dirty = false;
178 Ok(())
179 }
180 pub fn cell_col_of(&self, offset: impl Into<id::ByteOffset>) -> u16 {
185 self.cell_col_with_tab(offset, 8)
186 }
187
188 pub fn cell_col_with_tab(&self, offset: impl Into<id::ByteOffset>, tab: u16) -> u16 {
191 let offset = offset.into().get();
192 if self.len_bytes() == 0 {
193 return 0;
194 }
195 let line = self.line_of(offset);
196 let (s, e) = (self.line_start(line), self.line_end(line));
197 let text = self.rope.byte_slice(s..e).to_string();
198 let col = offset.saturating_sub(s);
199 let layout = layout::LineLayout::build(text.trim_end_matches('\n'), tab.max(1));
200 layout.cell_at_byte(col.min(layout.len_bytes))
201 }
202
203 pub fn len_bytes(&self) -> usize {
204 self.rope.len_bytes()
205 }
206 pub fn len_lines(&self) -> usize {
207 self.rope.len_lines()
208 }
209
210 pub fn last_content_line(&self) -> usize {
213 let mut l = self.len_lines().saturating_sub(1);
214 if self.len_bytes() > 0 && self.byte(self.len_bytes() - 1) == b'\n' && l > 0 {
215 l -= 1;
216 }
217 l
218 }
219
220 pub fn line_start(&self, line: impl Into<id::LineIndex>) -> usize {
222 self.rope
223 .line_to_byte(line.into().get().min(self.len_lines().saturating_sub(1)))
224 }
225
226 pub fn line_end(&self, line: impl Into<id::LineIndex>) -> usize {
228 let line = line.into().get();
229 let start = self.line_start(line);
230 let mut end = self.line_start((line + 1).min(self.len_lines().saturating_sub(1)));
231 if line + 1 >= self.len_lines() {
232 end = self.len_bytes();
233 }
234 if end > start && self.byte(end - 1) == b'\n' {
236 end -= 1;
237 }
238 end
239 }
240
241 pub fn line_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
242 self.rope
243 .byte_to_line(offset.into().get().min(self.len_bytes()))
244 }
245
246 pub fn col_of(&self, offset: impl Into<id::ByteOffset>) -> usize {
248 let offset = offset.into();
249 offset.get() - self.line_start(self.line_of(offset))
250 }
251 pub fn byte(&self, offset: impl Into<id::ByteOffset>) -> u8 {
256 if self.len_bytes() == 0 {
257 return 0;
258 }
259 self.rope
260 .byte(offset.into().get().min(self.len_bytes().saturating_sub(1)))
261 }
262
263 pub fn byte_at(&self, offset: impl Into<id::ByteOffset>) -> Option<u8> {
264 let off = offset.into().get();
265 if off < self.len_bytes() {
266 Some(self.rope.byte(off))
267 } else {
268 None
269 }
270 }
271
272 pub fn is_boundary(&self, offset: impl Into<id::ByteOffset>) -> bool {
277 let off = offset.into().get();
278 if off == 0 || off == self.len_bytes() {
279 return true;
280 }
281 if off > self.len_bytes() {
282 return false;
283 }
284 match self.rope.try_byte_to_char(off) {
285 Ok(c) => self.rope.try_char_to_byte(c).is_ok_and(|b| b == off),
286 Err(_) => false,
287 }
288 }
289
290 pub fn clamp_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
293 let mut offset = offset.into().get().min(self.len_bytes());
294 while offset > 0 && !self.is_boundary(offset) {
295 offset -= 1;
296 }
297 offset
298 }
299
300 pub fn ceil_boundary(&self, offset: impl Into<id::ByteOffset>) -> usize {
305 let mut offset = offset.into().get().min(self.len_bytes());
306 while offset < self.len_bytes() && !self.is_boundary(offset) {
307 offset += 1;
308 }
309 offset
310 }
311
312 pub fn slice_string(&self, range: Range) -> String {
315 let start = range.start.min(self.len_bytes());
316 let end = range.end.min(self.len_bytes());
317 self.rope.byte_slice(start..end.max(start)).to_string()
318 }
319
320 pub fn apply_history(&mut self, ops: Vec<Edit>) {
322 self.replaying = true;
323 for op in ops {
324 match op.kind {
325 EditKind::Insert => {
326 let at = self.clamp_boundary(op.at.min(self.len_bytes()));
327 self.rope.insert(self.rope.byte_to_char(at), &op.text);
328 }
329 EditKind::Delete => {
330 let end = self.clamp_boundary((op.at + op.text.len()).min(self.len_bytes()));
333 let start = self.clamp_boundary(op.at.min(end));
334 if start < end {
335 self.rope
336 .remove(self.rope.byte_to_char(start)..self.rope.byte_to_char(end));
337 }
338 }
339 }
340 }
341 self.replaying = false;
342 self.dirty = true;
343 self.epoch += 1;
344 }
345
346 pub fn replace_all(&mut self, text: &str) {
350 if self.readonly {
351 return;
352 }
353 self.replace_all_system(text);
354 }
355
356 pub fn replace_all_system(&mut self, text: &str) {
360 self.rope = Rope::from_str(text);
361 self.epoch += 1;
362 }
363
364 pub fn delete(&mut self, range: Range) -> String {
368 if self.readonly && !self.replaying {
369 return String::new();
370 }
371 let start = self.clamp_boundary(range.start.min(self.len_bytes()));
373 let end = self.clamp_boundary(range.end.min(self.len_bytes()));
374 if start >= end {
375 return String::new();
376 }
377 let text = self.rope.byte_slice(start..end).to_string();
378 let cstart = self.rope.byte_to_char(start);
380 let cend = self.rope.byte_to_char(end);
381 self.rope.remove(cstart..cend);
382 self.dirty = true;
383 self.epoch += 1;
384 if !self.replaying && !self.readonly {
385 self.history.record(
386 Edit {
387 at: range.start,
388 text: text.clone(),
389 kind: EditKind::Insert,
390 },
391 Edit {
392 at: range.start,
393 text: text.clone(),
394 kind: EditKind::Delete,
395 },
396 );
397 }
398 text
399 }
400
401 pub fn insert(&mut self, at: impl Into<id::ByteOffset>, text: &str) {
402 if self.readonly && !self.replaying {
403 return;
404 }
405 let at = self.clamp_boundary(at);
406 self.rope.insert(self.rope.byte_to_char(at), text);
407 self.dirty = true;
408 self.epoch += 1;
409 if !self.replaying && !self.readonly {
410 self.history.record(
411 Edit {
412 at,
413 text: text.into(),
414 kind: EditKind::Delete,
415 },
416 Edit {
417 at,
418 text: text.into(),
419 kind: EditKind::Insert,
420 },
421 );
422 }
423 }
424
425 pub fn line_text(&self, line: impl Into<id::LineIndex>) -> String {
426 let line = line.into().get();
427 let start = self.line_start(line);
428 let end = self.line_end(line);
429 self.rope.byte_slice(start..end).to_string()
430 }
431}
432
433fn write_atomic(target: &std::path::Path, contents: &str) -> std::io::Result<()> {
436 let tmp = target.with_file_name(format!(
437 ".strop-tmp-{}-{}",
438 std::process::id(),
439 target.file_name().and_then(|n| n.to_str()).unwrap_or("x")
440 ));
441 std::fs::write(&tmp, contents)?;
442 if let Ok(meta) = std::fs::metadata(target) {
443 let _ = std::fs::set_permissions(&tmp, meta.permissions());
445 }
446 std::fs::rename(&tmp, target)
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub struct InputEdit {
454 pub start_byte: usize,
455 pub old_end_byte: usize,
456 pub new_end_byte: usize,
457 pub start_point: (usize, usize),
458 pub old_end_point: (usize, usize),
459 pub new_end_point: (usize, usize),
460}
461
462impl Buffer {
463 pub fn point_of(&self, offset: usize) -> (usize, usize) {
465 let offset = offset.min(self.len_bytes());
466 (self.line_of(offset), self.col_of(offset))
467 }
468
469 fn point_extent(text: &str) -> (usize, usize) {
471 let lines = text.bytes().filter(|b| *b == b'\n').count();
472 let col = if lines == 0 {
473 text.len()
474 } else {
475 text.rsplit('\n').next().map(str::len).unwrap_or(0)
476 };
477 (lines, col)
478 }
479
480 pub fn input_edit_of(&self, op: &history::Edit) -> InputEdit {
483 let start_point = self.point_of(op.at);
484 let extent = Self::point_extent(&op.text);
485 match op.kind {
486 history::EditKind::Insert => InputEdit {
487 start_byte: op.at,
488 old_end_byte: op.at,
489 new_end_byte: op.at + op.text.len(),
490 start_point,
491 old_end_point: start_point,
492 new_end_point: if extent.0 == 0 {
495 (start_point.0, start_point.1 + extent.1)
496 } else {
497 (start_point.0 + extent.0, extent.1)
498 },
499 },
500 history::EditKind::Delete => InputEdit {
501 start_byte: op.at,
502 old_end_byte: op.at + op.text.len(),
503 new_end_byte: op.at,
504 start_point,
505 old_end_point: if extent.0 == 0 {
506 (start_point.0, start_point.1 + extent.1)
507 } else {
508 (start_point.0 + extent.0, extent.1)
509 },
510 new_end_point: start_point,
511 },
512 }
513 }
514}
515
516#[cfg(test)]
517mod safety_tests {
518 use super::*;
519
520 #[test]
521 fn save_refuses_external_change_unless_forced() {
522 let dir = tempfile::tempdir().unwrap();
523 let f = dir.path().join("f.txt");
524 std::fs::write(&f, "original\n").unwrap();
525 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
526 b.insert(id::ByteOffset::new(0), "mine ");
527 std::thread::sleep(std::time::Duration::from_millis(5));
529 std::fs::write(&f, "theirs\n").unwrap();
530 let err = b.save(false).unwrap_err();
531 assert!(err.to_string().contains("changed on disk"));
532 assert_eq!(std::fs::read_to_string(&f).unwrap(), "theirs\n");
533 b.save(true).unwrap(); assert_eq!(std::fs::read_to_string(&f).unwrap(), "mine original\n");
535 assert!(!b.dirty);
536 }
537
538 #[test]
539 fn save_is_atomic_and_keeps_permissions() {
540 use std::os::unix::fs::PermissionsExt;
541 let dir = tempfile::tempdir().unwrap();
542 let f = dir.path().join("x.sh");
543 std::fs::write(&f, "#!/bin/sh\n").unwrap();
544 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o750)).unwrap();
545 let mut b = Buffer::open(f.to_str().unwrap()).unwrap();
546 b.insert(id::ByteOffset::new(b.len_bytes()), "echo hi\n");
547 b.save(false).unwrap();
548 assert_eq!(std::fs::read_to_string(&f).unwrap(), "#!/bin/sh\necho hi\n");
549 let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
550 assert_eq!(mode, 0o750, "permissions survive the swap");
551 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
553 }
554
555 #[test]
556 fn readonly_refuses_mutation_at_the_boundary() {
557 let mut b = Buffer::from_text("abc\n");
559 b.readonly = true;
560 b.insert(id::ByteOffset::new(0), "nope");
561 let gone = b.delete(Range::charwise(0, 2));
562 assert_eq!(gone, "");
563 assert_eq!(b.rope.to_string(), "abc\n", "untouched");
564 b.replace_all_system("gen\n");
566 assert_eq!(b.rope.to_string(), "gen\n");
567 }
568 #[test]
569 fn non_utf8_filename_opens_and_roundtrips() {
570 use std::os::unix::ffi::OsStrExt;
573 let dir = tempfile::tempdir().unwrap();
574 let weird = dir
575 .path()
576 .join(std::ffi::OsStr::from_bytes(b"weird-\xff.rs"));
577 std::fs::write(&weird, "fn main() {}\n").unwrap();
578 let mut b = Buffer::open(&weird).unwrap();
579 assert_eq!(b.path.as_deref(), Some(weird.as_path()));
580 b.insert(0, "// x\n");
581 b.save(false).unwrap();
582 assert!(std::fs::read_to_string(&weird).unwrap().starts_with("// x"));
583 }
584}