1use std::fmt::Debug;
4use std::ops::RangeInclusive;
5use std::path::Path;
6use std::path::PathBuf;
7
8use nvim_oxi::Array;
9use nvim_oxi::api::Buffer;
10use nvim_oxi::api::SuperIterator;
11use nvim_oxi::api::Window;
12use nvim_oxi::api::opts::OptionOptsBuilder;
13use rootcause::prelude::ResultExt;
14use rootcause::report;
15
16use crate::visual_selection::Selection;
17
18#[cfg_attr(any(test, feature = "mockall"), mockall::automock)]
20pub trait BufferExt: Debug {
21 fn get_line(&self, idx: usize) -> rootcause::Result<nvim_oxi::String>;
26
27 fn get_lines(
33 &self,
34 line_range: RangeInclusive<usize>,
35 strict_indexing: bool,
36 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error>;
37
38 fn get_text_between(
43 &self,
44 start: (usize, usize),
45 end: (usize, usize),
46 boundary: TextBoundary,
47 ) -> rootcause::Result<String> {
48 let (start_lnum, start_col) = start;
49 let (end_lnum, end_col) = end;
50
51 let lines = self.get_lines(start_lnum..=end_lnum, true)?;
52 let last_line_idx = lines.len().saturating_sub(1);
53
54 let mut out = String::new();
55 for (line_idx, line) in lines.enumerate() {
56 let line = line.to_string_lossy();
58 let line_start_idx = boundary.get_line_start_idx(line_idx, start_col);
59 let line_end_idx = boundary.get_line_end_idx(&line, line_idx, last_line_idx, end_col);
60 let sub_line = line
61 .get(line_start_idx..line_end_idx)
62 .ok_or_else(|| report!("cannot extract substring from line"))
63 .attach_with(|| {
64 format!("line={line:?} idx={line_idx} start_idx={line_start_idx} end_idx={line_end_idx}")
65 })?;
66 out.push_str(sub_line);
67 if line_idx != last_line_idx {
68 out.push('\n');
69 }
70 }
71
72 Ok(out)
73 }
74
75 fn get_buf_type(&self) -> Option<String>;
77
78 fn get_channel(&self) -> Option<u32>;
79
80 fn set_text_at_cursor_pos(&mut self, text: &str);
82
83 fn is_terminal(&self) -> bool {
84 self.get_buf_type().is_some_and(|bt| bt == "terminal")
85 }
86
87 fn send_command(&self, cmd: &str) -> Option<()> {
88 let channel_id = self.get_channel()?;
89
90 nvim_oxi::api::chan_send(channel_id, cmd).inspect_err(|err|{
91 crate::notify::error(format!(
92 "error sending command to buffer | command={cmd:?} buffer={self:?} channel_id={channel_id} error={err:?}"
93 ));
94 }).ok()?;
95
96 Some(())
97 }
98
99 fn get_pid(&self) -> rootcause::Result<String>;
104}
105
106#[derive(Default)]
108pub enum TextBoundary {
109 #[default]
110 Exact,
111 FromLineStart,
112 ToLineEnd,
113 FromLineStartToEnd,
114}
115
116impl TextBoundary {
117 pub const fn get_line_start_idx(&self, line_idx: usize, start_col: usize) -> usize {
119 if line_idx != 0 {
120 return 0;
121 }
122 match self {
123 Self::FromLineStart | Self::FromLineStartToEnd => 0,
124 Self::Exact | Self::ToLineEnd => start_col,
125 }
126 }
127
128 pub fn get_line_end_idx(&self, line: &str, line_idx: usize, last_line_idx: usize, end_col: usize) -> usize {
130 let line_len = line.len();
131 if line_idx != last_line_idx {
132 return line_len;
133 }
134 match self {
135 Self::ToLineEnd | Self::FromLineStartToEnd => line_len,
136 Self::Exact | Self::FromLineStart => end_col.min(line_len),
137 }
138 }
139}
140
141impl BufferExt for Buffer {
142 fn get_line(&self, idx: usize) -> rootcause::Result<nvim_oxi::String> {
143 self.get_lines(idx..=idx, true)
144 .context("error getting buffer line at index")
145 .attach_with(|| format!("idx={idx} buffer={self:?}"))?
146 .next()
147 .ok_or_else(|| report!("buffer line missing"))
148 .attach_with(|| format!("idx={idx} buffer={self:#?}"))
149 }
150
151 fn get_lines(
152 &self,
153 line_range: RangeInclusive<usize>,
154 strict_indexing: bool,
155 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
156 self.get_lines(line_range, strict_indexing)
157 .map(|i| Box::new(i) as Box<dyn SuperIterator<nvim_oxi::String>>)
158 }
159
160 fn set_text_at_cursor_pos(&mut self, text: &str) {
161 let Some(cur_pos) = CursorPosition::get_current() else {
162 return;
163 };
164
165 let row = cur_pos.row.saturating_sub(1);
166 let line_range = row..=row;
167 let start_col = cur_pos.col;
168 let end_col = cur_pos.col;
169
170 if let Err(err) = self.set_text(line_range.clone(), start_col, end_col, vec![text]) {
171 crate::notify::error(format!(
172 "error setting text in buffer | text={text:?} buffer={self:?} line_range={line_range:?} start_col={start_col:?} end_col={end_col:?} error={err:#?}",
173 ));
174 }
175 }
176
177 fn get_buf_type(&self) -> Option<String> {
178 let opts = OptionOptsBuilder::default().buf(self.clone()).build();
180 nvim_oxi::api::get_option_value::<String>("buftype", &opts)
181 .inspect_err(|err| {
182 crate::notify::error(format!(
183 "error getting buftype of buffer | buffer={self:#?} error={err:?}"
184 ));
185 })
186 .ok()
187 }
188
189 fn get_channel(&self) -> Option<u32> {
190 let opts = OptionOptsBuilder::default().buf(self.clone()).build();
191 nvim_oxi::api::get_option_value::<u32>("channel", &opts)
192 .inspect_err(|err| {
193 crate::notify::error(format!(
194 "error getting channel of buffer | buffer={self:#?} error={err:?}"
195 ));
196 })
197 .ok()
198 }
199
200 fn get_pid(&self) -> rootcause::Result<String> {
201 let buf_name = self
202 .get_name()
203 .context("error getting name of buffer")
204 .attach_with(|| format!("buffer={self:#?}"))
205 .map(|s| s.to_string_lossy().into_owned())?;
206
207 if buf_name.starts_with("term://") {
208 let (_, pid_cmd) = buf_name
209 .rsplit_once("//")
210 .ok_or_else(|| report!("error getting pid and cmd from buffer name"))
211 .attach_with(|| format!("buffer={self:?} buffer_name={buf_name:?}"))?;
212 let (pid, _) = pid_cmd
213 .rsplit_once(':')
214 .ok_or_else(|| report!("error getting pid from buffer name"))
215 .attach_with(|| format!("buffer={self:?} buffer_name={buf_name:?}"))?;
216 return Ok(pid.to_owned());
217 }
218
219 let pid = nvim_oxi::api::call_function::<_, i32>("getpid", Array::new())
220 .context("error getting pid of buffer")
221 .attach_with(|| format!("buffer={self:#?}"))?;
222
223 Ok(pid.to_string())
224 }
225}
226
227#[derive(Debug)]
231pub struct CursorPosition {
232 pub row: usize,
233 pub col: usize,
234}
235
236impl CursorPosition {
237 pub fn get_current() -> Option<Self> {
239 let cur_win = Window::current();
240 cur_win
241 .get_cursor()
242 .map(|(row, col)| Self { row, col })
243 .inspect_err(|err| {
244 crate::notify::error(format!(
245 "error getting cursor from current window | window={cur_win:?} error={err:#?}"
246 ));
247 })
248 .ok()
249 }
250
251 pub const fn adjusted_col(&self) -> usize {
253 self.col.saturating_add(1)
254 }
255}
256
257pub fn create() -> Option<Buffer> {
259 nvim_oxi::api::create_buf(true, false)
260 .inspect_err(|err| crate::notify::error(format!("error creating buffer | error={err:?}")))
261 .ok()
262}
263
264pub fn get_alternate_or_new() -> Option<Buffer> {
266 let alt_buf_id = nvim_oxi::api::call_function::<_, i32>("bufnr", ("#",))
267 .inspect(|err| {
268 crate::notify::error(format!("error getting alternate buffer | error={err:?}"));
269 })
270 .ok()?;
271
272 if alt_buf_id < 0 {
273 return create();
274 }
275 Some(Buffer::from(alt_buf_id))
276}
277
278pub fn set_current(buf: &Buffer) -> Option<()> {
280 nvim_oxi::api::set_current_buf(buf)
281 .inspect_err(|err| {
282 crate::notify::error(format!("error setting current buffer | buffer={buf:?} error={err:?}"));
283 })
284 .ok()?;
285 Some(())
286}
287
288pub fn open<T: AsRef<Path>>(path: T, line: Option<usize>, col: Option<usize>) -> rootcause::Result<()> {
293 crate::common::exec_vim_cmd("edit", Some(&[path.as_ref().display().to_string()]))?;
294 Window::current().set_cursor(line.unwrap_or_default(), col.unwrap_or_default())?;
295 Ok(())
296}
297
298pub fn replace_text_and_notify_if_error<Line, Lines>(selection: &Selection, replacement: Lines)
300where
301 Lines: IntoIterator<Item = Line>,
302 Line: Into<nvim_oxi::String>,
303{
304 if let Err(err) = Buffer::from(selection.buf_id()).set_text(
305 selection.line_range(),
306 selection.start().col,
307 selection.end().col,
308 replacement,
309 ) {
310 crate::notify::error(format!(
311 "error setting lines of buffer | start={:#?} end={:#?} error={err:#?}",
312 selection.start(),
313 selection.end()
314 ));
315 }
316}
317
318pub fn get_relative_path_to_cwd(current_buffer: &Buffer) -> Option<PathBuf> {
320 let cwd = nvim_oxi::api::call_function::<_, String>("getcwd", Array::new())
321 .inspect_err(|err| {
322 crate::notify::error(format!("error getting cwd | error={err:#?}"));
323 })
324 .ok()?;
325
326 let current_buffer_path = get_absolute_path(Some(current_buffer))?.display().to_string();
327
328 Some(PathBuf::from(
329 current_buffer_path.strip_prefix(&cwd).unwrap_or(¤t_buffer_path),
330 ))
331}
332
333pub fn get_absolute_path(buffer: Option<&Buffer>) -> Option<PathBuf> {
335 let path = buffer?
336 .get_name()
337 .map(|s| s.to_string_lossy().into_owned())
338 .inspect_err(|err| {
339 crate::notify::error(format!(
340 "error getting buffer absolute path | buffer={buffer:#?} error={err:#?}"
341 ));
342 })
343 .ok();
344
345 if path.as_ref().is_some_and(String::is_empty) {
346 return None;
347 }
348
349 path.map(PathBuf::from)
350}
351
352pub fn get_current_line() -> Option<String> {
353 nvim_oxi::api::get_current_line()
354 .inspect_err(|err| crate::notify::error(format!("error getting current line | error={err}")))
355 .ok()
356}
357
358#[cfg(test)]
359mod tests {
360 use mockall::predicate::*;
361 use rstest::rstest;
362
363 use super::*;
364
365 #[test]
366 fn cursor_position_adjusted_col_when_zero_returns_one() {
367 let pos = CursorPosition { row: 1, col: 0 };
368 pretty_assertions::assert_eq!(pos.adjusted_col(), 1);
369 }
370
371 #[test]
372 fn cursor_position_adjusted_col_when_non_zero_increments_by_one() {
373 let pos = CursorPosition { row: 10, col: 7 };
374 pretty_assertions::assert_eq!(pos.adjusted_col(), 8);
375 }
376
377 #[test]
378 fn buffer_ext_get_text_between_single_line_exact() {
379 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
380 let buffer = TestBuffer { mock };
381
382 let result = buffer.get_text_between((0, 6), (0, 11), TextBoundary::Exact);
383
384 assert2::assert!(let Ok(value) = result);
385 pretty_assertions::assert_eq!(value, "world");
386 }
387
388 #[test]
389 fn buffer_ext_get_text_between_single_line_from_line_start() {
390 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
391 let buffer = TestBuffer { mock };
392
393 let result = buffer.get_text_between((0, 6), (0, 11), TextBoundary::FromLineStart);
394
395 assert2::assert!(let Ok(value) = result);
396 pretty_assertions::assert_eq!(value, "hello world");
397 }
398
399 #[test]
400 fn buffer_ext_get_text_between_single_line_to_line_end() {
401 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
402 let buffer = TestBuffer { mock };
403
404 let result = buffer.get_text_between((0, 0), (0, 5), TextBoundary::ToLineEnd);
405
406 assert2::assert!(let Ok(value) = result);
407 pretty_assertions::assert_eq!(value, "hello world");
408 }
409
410 #[test]
411 fn buffer_ext_get_text_between_single_line_from_start_to_end() {
412 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
413 let buffer = TestBuffer { mock };
414
415 let result = buffer.get_text_between((0, 6), (0, 5), TextBoundary::FromLineStartToEnd);
416
417 assert2::assert!(let Ok(value) = result);
418 pretty_assertions::assert_eq!(value, "hello world");
419 }
420
421 #[test]
422 fn buffer_ext_get_text_between_multiple_lines_exact() {
423 let mock = mock_buffer(
424 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
425 0,
426 2,
427 );
428 let buffer = TestBuffer { mock };
429
430 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::Exact);
431
432 assert2::assert!(let Ok(value) = result);
433 pretty_assertions::assert_eq!(value, "ine1\nline2\nlin");
434 }
435
436 #[test]
437 fn buffer_ext_get_text_between_multiple_lines_from_start_to_end() {
438 let mock = mock_buffer(
439 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
440 0,
441 2,
442 );
443 let buffer = TestBuffer { mock };
444
445 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::FromLineStartToEnd);
446
447 assert2::assert!(let Ok(value) = result);
448 pretty_assertions::assert_eq!(value, "line1\nline2\nline3");
449 }
450
451 #[test]
452 fn buffer_ext_get_text_between_multiple_lines_to_line_end() {
453 let mock = mock_buffer(
454 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
455 0,
456 2,
457 );
458 let buffer = TestBuffer { mock };
459
460 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::ToLineEnd);
461
462 assert2::assert!(let Ok(value) = result);
463 pretty_assertions::assert_eq!(value, "ine1\nline2\nline3");
464 }
465
466 #[test]
467 fn buffer_ext_get_text_between_error_out_of_bounds() {
468 let mock = mock_buffer(vec!["hello".to_string()], 0, 0);
469 let buffer = TestBuffer { mock };
470
471 let result = buffer.get_text_between((0, 10), (0, 15), TextBoundary::Exact);
472
473 assert2::assert!(let Err(err) = result);
474 assert_eq!(
475 err.format_current_context().to_string(),
476 "cannot extract substring from line"
477 );
478 }
479
480 #[rstest]
481 #[case::exact_non_zero_line_idx(TextBoundary::Exact, 1, 5, 0)]
482 #[case::to_line_end_non_zero_line_idx(TextBoundary::ToLineEnd, 1, 5, 0)]
483 #[case::from_line_start_non_zero_line_idx(TextBoundary::FromLineStart, 1, 5, 0)]
484 #[case::from_line_start_to_end_non_zero_line_idx(TextBoundary::FromLineStartToEnd, 1, 5, 0)]
485 #[case::exact_zero_line_idx(TextBoundary::Exact, 0, 5, 5)]
486 #[case::to_line_end_zero_line_idx(TextBoundary::ToLineEnd, 0, 5, 5)]
487 #[case::from_line_start_zero_line_idx(TextBoundary::FromLineStart, 0, 5, 0)]
488 #[case::from_line_start_to_end_zero_line_idx(TextBoundary::FromLineStartToEnd, 0, 5, 0)]
489 fn text_boundary_get_line_start_idx(
490 #[case] boundary: TextBoundary,
491 #[case] line_idx: usize,
492 #[case] start_col: usize,
493 #[case] expected: usize,
494 ) {
495 pretty_assertions::assert_eq!(boundary.get_line_start_idx(line_idx, start_col), expected);
496 }
497
498 #[rstest]
499 #[case::exact_line_idx_not_last(TextBoundary::Exact, "hello", 0, 1, 3, 5)]
500 #[case::exact_line_idx_is_last(TextBoundary::Exact, "hello", 1, 1, 3, 3)]
501 #[case::exact_end_col_greater_than_line_len(TextBoundary::Exact, "hi", 0, 0, 5, 2)]
502 #[case::from_line_start_line_idx_is_last(TextBoundary::FromLineStart, "hello", 1, 1, 3, 3)]
503 #[case::to_line_end_line_idx_is_last(TextBoundary::ToLineEnd, "hello", 1, 1, 3, 5)]
504 #[case::from_line_start_to_end_line_idx_is_last(TextBoundary::FromLineStartToEnd, "hello", 1, 1, 3, 5)]
505 fn text_boundary_get_line_end_idx(
506 #[case] boundary: TextBoundary,
507 #[case] line: &str,
508 #[case] line_idx: usize,
509 #[case] last_line_idx: usize,
510 #[case] end_col: usize,
511 #[case] expected: usize,
512 ) {
513 pretty_assertions::assert_eq!(
514 boundary.get_line_end_idx(line, line_idx, last_line_idx, end_col),
515 expected
516 );
517 }
518
519 #[allow(clippy::needless_collect)]
520 fn mock_buffer(lines: Vec<String>, start_line: usize, end_line: usize) -> MockBufferExt {
521 let mut mock = MockBufferExt::new();
522 mock.expect_get_lines()
523 .with(eq(start_line..=end_line), eq(true))
524 .returning(move |_, _| {
525 let lines: Vec<nvim_oxi::String> = lines.iter().map(|s| nvim_oxi::String::from(s.as_str())).collect();
526 Ok(Box::new(lines.into_iter()) as Box<dyn SuperIterator<nvim_oxi::String>>)
527 });
528 mock
529 }
530
531 #[derive(Debug)]
532 struct TestBuffer {
533 mock: MockBufferExt,
534 }
535
536 impl BufferExt for TestBuffer {
537 fn get_line(&self, _idx: usize) -> rootcause::Result<nvim_oxi::String> {
538 Ok("".into())
539 }
540
541 fn get_lines(
542 &self,
543 line_range: RangeInclusive<usize>,
544 strict_indexing: bool,
545 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
546 self.mock.get_lines(line_range, strict_indexing)
547 }
548
549 fn set_text_at_cursor_pos(&mut self, _text: &str) {}
550
551 fn get_buf_type(&self) -> Option<String> {
552 None
553 }
554
555 fn get_channel(&self) -> Option<u32> {
556 None
557 }
558
559 fn send_command(&self, _cmd: &str) -> Option<()> {
560 None
561 }
562
563 fn get_pid(&self) -> rootcause::Result<String> {
564 Ok("42".to_owned())
565 }
566 }
567}
568
569#[cfg(any(test, feature = "mockall"))]
570pub mod mock {
571 use nvim_oxi::api::SuperIterator;
572
573 use crate::buffer::BufferExt;
574
575 #[derive(Debug)]
576 pub struct MockBuffer {
577 pub lines: Vec<String>,
578 pub buf_type: String,
579 }
580
581 impl MockBuffer {
582 pub fn new(lines: Vec<String>) -> Self {
583 Self {
584 lines,
585 buf_type: "test".to_string(),
586 }
587 }
588
589 pub fn with_buf_type(lines: Vec<String>, buf_type: &str) -> Self {
590 Self {
591 lines,
592 buf_type: buf_type.to_string(),
593 }
594 }
595 }
596
597 impl BufferExt for MockBuffer {
598 fn get_line(&self, _idx: usize) -> rootcause::Result<nvim_oxi::String> {
599 Ok("".into())
600 }
601
602 #[allow(clippy::needless_collect)]
603 fn get_lines(
604 &self,
605 line_range: std::ops::RangeInclusive<usize>,
606 _strict_indexing: bool,
607 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
608 let start = *line_range.start();
609 let end = line_range.end().saturating_add(1);
610 let lines: Vec<nvim_oxi::String> = self
611 .lines
612 .get(start..end.min(self.lines.len()))
613 .unwrap_or(&[])
614 .iter()
615 .map(|s| nvim_oxi::String::from(s.as_str()))
616 .collect();
617 Ok(Box::new(lines.into_iter()) as Box<dyn SuperIterator<nvim_oxi::String>>)
618 }
619
620 fn set_text_at_cursor_pos(&mut self, _text: &str) {}
621
622 fn get_buf_type(&self) -> Option<String> {
623 Some(self.buf_type.clone())
624 }
625
626 fn get_channel(&self) -> Option<u32> {
627 None
628 }
629
630 fn send_command(&self, _cmd: &str) -> Option<()> {
631 None
632 }
633
634 fn get_pid(&self) -> rootcause::Result<String> {
635 Ok("42".to_owned())
636 }
637 }
638}