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