1use std::fmt::Debug;
4use std::ops::RangeInclusive;
5use std::path::Path;
6use std::path::PathBuf;
7
8use color_eyre::eyre::Context;
9use color_eyre::eyre::eyre;
10use nvim_oxi::Array;
11use nvim_oxi::api::Buffer;
12use nvim_oxi::api::SuperIterator;
13use nvim_oxi::api::Window;
14use nvim_oxi::api::opts::OptionOptsBuilder;
15
16use crate::visual_selection::Selection;
17
18#[cfg_attr(any(test, feature = "mockall"), mockall::automock)]
23pub trait BufferExt: Debug {
24 fn get_line(&self, idx: usize) -> color_eyre::Result<nvim_oxi::String>;
33
34 fn get_lines(
44 &self,
45 line_range: RangeInclusive<usize>,
46 strict_indexing: bool,
47 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error>;
48
49 fn get_text_between(
56 &self,
57 start: (usize, usize),
58 end: (usize, usize),
59 boundary: TextBoundary,
60 ) -> color_eyre::Result<String> {
61 let (start_lnum, start_col) = start;
62 let (end_lnum, end_col) = end;
63
64 let lines = self.get_lines(start_lnum..=end_lnum, true)?;
65 let last_line_idx = lines.len().saturating_sub(1);
66
67 let mut out = String::new();
68 for (line_idx, line) in lines.enumerate() {
69 let line = line.to_string();
70 let line_start_idx = boundary.get_line_start_idx(line_idx, start_col);
71 let line_end_idx = boundary.get_line_end_idx(&line, line_idx, last_line_idx, end_col);
72 let sub_line = line.get(line_start_idx..line_end_idx).ok_or_else(|| {
73 eyre!(
74 "cannot extract substring from line | line={line:?} idx={line_idx} start_idx={line_start_idx} end_idx={line_end_idx}"
75 )
76 })?;
77 out.push_str(sub_line);
78 if line_idx != last_line_idx {
79 out.push_str("/n");
80 }
81 }
82
83 Ok(out)
84 }
85
86 fn get_buf_type(&self) -> Option<String>;
94
95 fn get_channel(&self) -> Option<u32>;
96
97 fn set_text_at_cursor_pos(&mut self, text: &str);
104
105 fn is_terminal(&self) -> bool {
106 self.get_buf_type().is_some_and(|bt| bt == "terminal")
107 }
108
109 fn send_command(&self, cmd: &str) -> Option<()> {
110 let channel_id = self.get_channel()?;
111
112 nvim_oxi::api::chan_send(channel_id, cmd).inspect_err(|err|{
113 crate::notify::error(format!(
114 "error sending command to buffer | command={cmd:?} buffer={self:?} channel_id={channel_id} error={err:?}"
115 ));
116 }).ok()?;
117
118 Some(())
119 }
120
121 fn get_pid(&self) -> color_eyre::Result<String>;
128}
129
130#[derive(Default)]
136pub enum TextBoundary {
137 #[default]
139 Exact,
140 FromLineStart,
142 ToLineEnd,
144 FromLineStartToEnd,
146}
147
148impl TextBoundary {
149 pub const fn get_line_start_idx(&self, line_idx: usize, start_col: usize) -> usize {
151 if line_idx != 0 {
152 return 0;
153 }
154 match self {
155 Self::FromLineStart | Self::FromLineStartToEnd => 0,
156 Self::Exact | Self::ToLineEnd => start_col,
157 }
158 }
159
160 pub fn get_line_end_idx(&self, line: &str, line_idx: usize, last_line_idx: usize, end_col: usize) -> usize {
162 let line_len = line.len();
163 if line_idx != last_line_idx {
164 return line_len;
165 }
166 match self {
167 Self::ToLineEnd | Self::FromLineStartToEnd => line_len,
168 Self::Exact | Self::FromLineStart => end_col.min(line_len),
169 }
170 }
171}
172
173impl BufferExt for Buffer {
174 fn get_line(&self, idx: usize) -> color_eyre::Result<nvim_oxi::String> {
175 self.get_lines(idx..=idx, true)
176 .wrap_err_with(|| format!("error getting buffer line at index | idx={idx} buffer={self:?}"))?
177 .next()
178 .ok_or_else(|| eyre!("buffer line missing | idx={idx} buffer={self:#?}"))
179 }
180
181 fn get_lines(
182 &self,
183 line_range: RangeInclusive<usize>,
184 strict_indexing: bool,
185 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
186 self.get_lines(line_range, strict_indexing)
187 .map(|i| Box::new(i) as Box<dyn SuperIterator<nvim_oxi::String>>)
188 }
189
190 fn set_text_at_cursor_pos(&mut self, text: &str) {
191 let Some(cur_pos) = CursorPosition::get_current() else {
192 return;
193 };
194
195 let row = cur_pos.row.saturating_sub(1);
196 let line_range = row..=row;
197 let start_col = cur_pos.col;
198 let end_col = cur_pos.col;
199
200 if let Err(err) = self.set_text(line_range.clone(), start_col, end_col, vec![text]) {
201 crate::notify::error(format!(
202 "error setting text in buffer | text={text:?} buffer={self:?} line_range={line_range:?} start_col={start_col:?} end_col={end_col:?} error={err:#?}",
203 ));
204 }
205 }
206
207 fn get_buf_type(&self) -> Option<String> {
208 let opts = OptionOptsBuilder::default().buf(self.clone()).build();
209 nvim_oxi::api::get_option_value::<String>("buftype", &opts)
210 .inspect_err(|err| {
211 crate::notify::error(format!(
212 "error getting buftype of buffer | buffer={self:#?} error={err:?}"
213 ));
214 })
215 .ok()
216 }
217
218 fn get_channel(&self) -> Option<u32> {
219 let opts = OptionOptsBuilder::default().buf(self.clone()).build();
220 nvim_oxi::api::get_option_value::<u32>("channel", &opts)
221 .inspect_err(|err| {
222 crate::notify::error(format!(
223 "error getting channel of buffer | buffer={self:#?} error={err:?}"
224 ));
225 })
226 .ok()
227 }
228
229 fn get_pid(&self) -> color_eyre::Result<String> {
230 let buf_name = self
231 .get_name()
232 .wrap_err_with(|| eyre!("error getting name of buffer | buffer={self:#?}"))
233 .map(|s| s.to_string_lossy().to_string())?;
234
235 if buf_name.starts_with("term://") {
236 let (_, pid_cmd) = buf_name.rsplit_once("//").ok_or_else(|| {
237 eyre!("error getting pid and cmd from buffer name | buffer={self:?} buffer_name={buf_name:?}")
238 })?;
239 let (pid, _) = pid_cmd
240 .rsplit_once(':')
241 .ok_or_else(|| eyre!("error getting pid from buffer name| buffer={self:?} buffer_name={buf_name:?}"))?;
242 return Ok(pid.to_owned());
243 }
244
245 let pid = nvim_oxi::api::call_function::<_, i32>("getpid", Array::new())
246 .wrap_err_with(|| eyre!("error getting pid of buffer | buffer={self:#?}"))?;
247
248 Ok(pid.to_string())
249 }
250}
251
252#[derive(Debug)]
265pub struct CursorPosition {
266 pub row: usize,
267 pub col: usize,
268}
269
270impl CursorPosition {
271 pub fn get_current() -> Option<Self> {
286 let cur_win = Window::current();
287 cur_win
288 .get_cursor()
289 .map(|(row, col)| Self { row, col })
290 .inspect_err(|err| {
291 crate::notify::error(format!(
292 "error getting cursor from current window | window={cur_win:?} error={err:#?}"
293 ));
294 })
295 .ok()
296 }
297
298 pub const fn adjusted_col(&self) -> usize {
314 self.col.saturating_add(1)
315 }
316}
317
318pub fn create() -> Option<Buffer> {
322 nvim_oxi::api::create_buf(true, false)
323 .inspect_err(|err| crate::notify::error(format!("error creating buffer | error={err:?}")))
324 .ok()
325}
326
327pub fn get_alternate_or_new() -> Option<Buffer> {
336 let alt_buf_id = nvim_oxi::api::call_function::<_, i32>("bufnr", ("#",))
337 .inspect(|err| {
338 crate::notify::error(format!("error getting alternate buffer | error={err:?}"));
339 })
340 .ok()?;
341
342 if alt_buf_id < 0 {
343 return create();
344 }
345 Some(Buffer::from(alt_buf_id))
346}
347
348pub fn set_current(buf: &Buffer) -> Option<()> {
353 nvim_oxi::api::set_current_buf(buf)
354 .inspect_err(|err| {
355 crate::notify::error(format!("error setting current buffer | buffer={buf:?} error={err:?}"));
356 })
357 .ok()?;
358 Some(())
359}
360
361pub fn open<T: AsRef<Path>>(path: T, line: Option<usize>, col: Option<usize>) -> color_eyre::Result<()> {
371 crate::common::exec_vim_cmd("edit", Some(&[path.as_ref().display().to_string()]))?;
372 Window::current().set_cursor(line.unwrap_or_default(), col.unwrap_or_default())?;
373 Ok(())
374}
375
376pub fn replace_text_and_notify_if_error<Line, Lines>(selection: &Selection, replacement: Lines)
384where
385 Lines: IntoIterator<Item = Line>,
386 Line: Into<nvim_oxi::String>,
387{
388 if let Err(err) = Buffer::from(selection.buf_id()).set_text(
389 selection.line_range(),
390 selection.start().col,
391 selection.end().col,
392 replacement,
393 ) {
394 crate::notify::error(format!(
395 "error setting lines of buffer | start={:#?} end={:#?} error={err:#?}",
396 selection.start(),
397 selection.end()
398 ));
399 }
400}
401
402pub fn get_relative_path_to_cwd(current_buffer: &Buffer) -> Option<PathBuf> {
410 let cwd = nvim_oxi::api::call_function::<_, String>("getcwd", Array::new())
411 .inspect_err(|err| {
412 crate::notify::error(format!("error getting cwd | error={err:#?}"));
413 })
414 .ok()?;
415
416 let current_buffer_path = get_absolute_path(Some(current_buffer))?.display().to_string();
417
418 Some(PathBuf::from(
419 current_buffer_path.strip_prefix(&cwd).unwrap_or(¤t_buffer_path),
420 ))
421}
422
423pub fn get_absolute_path(buffer: Option<&Buffer>) -> Option<PathBuf> {
431 let path = buffer?
432 .get_name()
433 .map(|s| s.to_string_lossy().to_string())
434 .inspect_err(|err| {
435 crate::notify::error(format!(
436 "error getting buffer absolute path | buffer={buffer:#?} error={err:#?}"
437 ));
438 })
439 .ok();
440
441 if path.as_ref().is_some_and(String::is_empty) {
442 return None;
443 }
444
445 path.map(PathBuf::from)
446}
447
448pub fn get_current_line() -> Option<String> {
449 nvim_oxi::api::get_current_line()
450 .inspect_err(|err| crate::notify::error(format!("error getting current line | error={err}")))
451 .ok()
452}
453
454#[cfg(test)]
455mod tests {
456 use mockall::predicate::*;
457 use rstest::rstest;
458
459 use super::*;
460
461 #[test]
462 fn cursor_position_adjusted_col_when_zero_returns_one() {
463 let pos = CursorPosition { row: 1, col: 0 };
464 pretty_assertions::assert_eq!(pos.adjusted_col(), 1);
465 }
466
467 #[test]
468 fn cursor_position_adjusted_col_when_non_zero_increments_by_one() {
469 let pos = CursorPosition { row: 10, col: 7 };
470 pretty_assertions::assert_eq!(pos.adjusted_col(), 8);
471 }
472
473 #[test]
474 fn buffer_ext_get_text_between_single_line_exact() {
475 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
476 let buffer = TestBuffer { mock };
477
478 let result = buffer.get_text_between((0, 6), (0, 11), TextBoundary::Exact);
479
480 assert2::let_assert!(Ok(value) = result);
481 pretty_assertions::assert_eq!(value, "world");
482 }
483
484 #[test]
485 fn buffer_ext_get_text_between_single_line_from_line_start() {
486 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
487 let buffer = TestBuffer { mock };
488
489 let result = buffer.get_text_between((0, 6), (0, 11), TextBoundary::FromLineStart);
490
491 assert2::let_assert!(Ok(value) = result);
492 pretty_assertions::assert_eq!(value, "hello world");
493 }
494
495 #[test]
496 fn buffer_ext_get_text_between_single_line_to_line_end() {
497 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
498 let buffer = TestBuffer { mock };
499
500 let result = buffer.get_text_between((0, 0), (0, 5), TextBoundary::ToLineEnd);
501
502 assert2::let_assert!(Ok(value) = result);
503 pretty_assertions::assert_eq!(value, "hello world");
504 }
505
506 #[test]
507 fn buffer_ext_get_text_between_single_line_from_start_to_end() {
508 let mock = mock_buffer(vec!["hello world".to_string()], 0, 0);
509 let buffer = TestBuffer { mock };
510
511 let result = buffer.get_text_between((0, 6), (0, 5), TextBoundary::FromLineStartToEnd);
512
513 assert2::let_assert!(Ok(value) = result);
514 pretty_assertions::assert_eq!(value, "hello world");
515 }
516
517 #[test]
518 fn buffer_ext_get_text_between_multiple_lines_exact() {
519 let mock = mock_buffer(
520 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
521 0,
522 2,
523 );
524 let buffer = TestBuffer { mock };
525
526 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::Exact);
527
528 assert2::let_assert!(Ok(value) = result);
529 pretty_assertions::assert_eq!(value, "ine1/nline2/nlin");
530 }
531
532 #[test]
533 fn buffer_ext_get_text_between_multiple_lines_from_start_to_end() {
534 let mock = mock_buffer(
535 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
536 0,
537 2,
538 );
539 let buffer = TestBuffer { mock };
540
541 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::FromLineStartToEnd);
542
543 assert2::let_assert!(Ok(value) = result);
544 pretty_assertions::assert_eq!(value, "line1/nline2/nline3");
545 }
546
547 #[test]
548 fn buffer_ext_get_text_between_multiple_lines_to_line_end() {
549 let mock = mock_buffer(
550 vec!["line1".to_string(), "line2".to_string(), "line3".to_string()],
551 0,
552 2,
553 );
554 let buffer = TestBuffer { mock };
555
556 let result = buffer.get_text_between((0, 1), (2, 3), TextBoundary::ToLineEnd);
557
558 assert2::let_assert!(Ok(value) = result);
559 pretty_assertions::assert_eq!(value, "ine1/nline2/nline3");
560 }
561
562 #[test]
563 fn buffer_ext_get_text_between_error_out_of_bounds() {
564 let mock = mock_buffer(vec!["hello".to_string()], 0, 0);
565 let buffer = TestBuffer { mock };
566
567 let result = buffer.get_text_between((0, 10), (0, 15), TextBoundary::Exact);
568
569 assert2::let_assert!(Err(err) = result);
570 pretty_assertions::assert_eq!(
571 err.to_string(),
572 r#"cannot extract substring from line | line="hello" idx=0 start_idx=10 end_idx=5"#
573 );
574 }
575
576 #[rstest]
577 #[case::exact_non_zero_line_idx(TextBoundary::Exact, 1, 5, 0)]
578 #[case::to_line_end_non_zero_line_idx(TextBoundary::ToLineEnd, 1, 5, 0)]
579 #[case::from_line_start_non_zero_line_idx(TextBoundary::FromLineStart, 1, 5, 0)]
580 #[case::from_line_start_to_end_non_zero_line_idx(TextBoundary::FromLineStartToEnd, 1, 5, 0)]
581 #[case::exact_zero_line_idx(TextBoundary::Exact, 0, 5, 5)]
582 #[case::to_line_end_zero_line_idx(TextBoundary::ToLineEnd, 0, 5, 5)]
583 #[case::from_line_start_zero_line_idx(TextBoundary::FromLineStart, 0, 5, 0)]
584 #[case::from_line_start_to_end_zero_line_idx(TextBoundary::FromLineStartToEnd, 0, 5, 0)]
585 fn text_boundary_get_line_start_idx(
586 #[case] boundary: TextBoundary,
587 #[case] line_idx: usize,
588 #[case] start_col: usize,
589 #[case] expected: usize,
590 ) {
591 pretty_assertions::assert_eq!(boundary.get_line_start_idx(line_idx, start_col), expected);
592 }
593
594 #[rstest]
595 #[case::exact_line_idx_not_last(TextBoundary::Exact, "hello", 0, 1, 3, 5)]
596 #[case::exact_line_idx_is_last(TextBoundary::Exact, "hello", 1, 1, 3, 3)]
597 #[case::exact_end_col_greater_than_line_len(TextBoundary::Exact, "hi", 0, 0, 5, 2)]
598 #[case::from_line_start_line_idx_is_last(TextBoundary::FromLineStart, "hello", 1, 1, 3, 3)]
599 #[case::to_line_end_line_idx_is_last(TextBoundary::ToLineEnd, "hello", 1, 1, 3, 5)]
600 #[case::from_line_start_to_end_line_idx_is_last(TextBoundary::FromLineStartToEnd, "hello", 1, 1, 3, 5)]
601 fn text_boundary_get_line_end_idx(
602 #[case] boundary: TextBoundary,
603 #[case] line: &str,
604 #[case] line_idx: usize,
605 #[case] last_line_idx: usize,
606 #[case] end_col: usize,
607 #[case] expected: usize,
608 ) {
609 pretty_assertions::assert_eq!(
610 boundary.get_line_end_idx(line, line_idx, last_line_idx, end_col),
611 expected
612 );
613 }
614
615 #[allow(clippy::needless_collect)]
616 fn mock_buffer(lines: Vec<String>, start_line: usize, end_line: usize) -> MockBufferExt {
617 let mut mock = MockBufferExt::new();
618 mock.expect_get_lines()
619 .with(eq(start_line..=end_line), eq(true))
620 .returning(move |_, _| {
621 let lines: Vec<nvim_oxi::String> = lines.iter().map(|s| nvim_oxi::String::from(s.as_str())).collect();
622 Ok(Box::new(lines.into_iter()) as Box<dyn SuperIterator<nvim_oxi::String>>)
623 });
624 mock
625 }
626
627 #[derive(Debug)]
628 struct TestBuffer {
629 mock: MockBufferExt,
630 }
631
632 impl BufferExt for TestBuffer {
633 fn get_line(&self, _idx: usize) -> color_eyre::Result<nvim_oxi::String> {
634 Ok("".into())
635 }
636
637 fn get_lines(
638 &self,
639 line_range: RangeInclusive<usize>,
640 strict_indexing: bool,
641 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
642 self.mock.get_lines(line_range, strict_indexing)
643 }
644
645 fn set_text_at_cursor_pos(&mut self, _text: &str) {}
646
647 fn get_buf_type(&self) -> Option<String> {
648 None
649 }
650
651 fn get_channel(&self) -> Option<u32> {
652 None
653 }
654
655 fn send_command(&self, _cmd: &str) -> Option<()> {
656 None
657 }
658
659 fn get_pid(&self) -> color_eyre::Result<String> {
660 Ok("42".to_owned())
661 }
662 }
663}
664
665#[cfg(any(test, feature = "mockall"))]
666pub mod mock {
667 use nvim_oxi::api::SuperIterator;
668
669 use crate::buffer::BufferExt;
670
671 #[derive(Debug)]
672 pub struct MockBuffer {
673 pub lines: Vec<String>,
674 pub buf_type: String,
675 }
676
677 impl MockBuffer {
678 pub fn new(lines: Vec<String>) -> Self {
679 Self {
680 lines,
681 buf_type: "test".to_string(),
682 }
683 }
684
685 pub fn with_buf_type(lines: Vec<String>, buf_type: &str) -> Self {
686 Self {
687 lines,
688 buf_type: buf_type.to_string(),
689 }
690 }
691 }
692
693 impl BufferExt for MockBuffer {
694 fn get_line(&self, _idx: usize) -> color_eyre::Result<nvim_oxi::String> {
695 Ok("".into())
696 }
697
698 #[allow(clippy::needless_collect)]
699 fn get_lines(
700 &self,
701 line_range: std::ops::RangeInclusive<usize>,
702 _strict_indexing: bool,
703 ) -> Result<Box<dyn SuperIterator<nvim_oxi::String>>, nvim_oxi::api::Error> {
704 let start = *line_range.start();
705 let end = line_range.end().saturating_add(1);
706 let lines: Vec<nvim_oxi::String> = self
707 .lines
708 .get(start..end.min(self.lines.len()))
709 .unwrap_or(&[])
710 .iter()
711 .map(|s| nvim_oxi::String::from(s.as_str()))
712 .collect();
713 Ok(Box::new(lines.into_iter()) as Box<dyn SuperIterator<nvim_oxi::String>>)
714 }
715
716 fn set_text_at_cursor_pos(&mut self, _text: &str) {}
717
718 fn get_buf_type(&self) -> Option<String> {
719 Some(self.buf_type.clone())
720 }
721
722 fn get_channel(&self) -> Option<u32> {
723 None
724 }
725
726 fn send_command(&self, _cmd: &str) -> Option<()> {
727 None
728 }
729
730 fn get_pid(&self) -> color_eyre::Result<String> {
731 Ok("42".to_owned())
732 }
733 }
734}