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