Skip to main content

ytil_noxi/
buffer.rs

1//! Buffer extension utilities like line access, cursor‑based insertion, cursor position model, etc.
2
3use 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/// Extension trait for [`Buffer`].
93#[cfg_attr(any(test, feature = "mockall"), mockall::automock)]
94pub trait BufferExt: Debug {
95    /// Fetch a single line from a [`Buffer`] by 0-based index.
96    ///
97    /// # Errors
98    /// - Fetching the line fails or index is out of range.
99    fn get_line(&self, idx: usize) -> rootcause::Result<nvim_oxi::String>;
100
101    /// Retrieves a range of lines from the buffer.
102    ///
103    /// # Errors
104    /// - If `strict_indexing` is true and the range is out of bounds.
105    /// - If the Nvim API call to fetch lines fails.
106    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    /// Get text between start and end positions.
113    ///
114    /// # Errors
115    /// - Substring extraction fails due to invalid indices.
116    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            // Use to_string_lossy() which returns Cow<str> - borrows if valid UTF-8, allocates only if invalid
131            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    /// Retrieves the buffer type via the `buftype` option.
150    fn get_buf_type(&self) -> Option<String>;
151
152    fn get_channel(&self) -> Option<u32>;
153
154    /// Inserts `text` at the current cursor position.
155    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    /// Retrieves the process ID associated with the buffer.
174    ///
175    /// # Errors
176    /// - Buffer name retrieval or PID parsing fails.
177    fn get_pid(&self) -> rootcause::Result<String>;
178}
179
180/// Defines boundaries for text selection within lines.
181#[derive(Default)]
182pub enum TextBoundary {
183    #[default]
184    Exact,
185    FromLineStart,
186    ToLineEnd,
187    FromLineStartToEnd,
188}
189
190impl TextBoundary {
191    /// Computes the starting column index for text selection.
192    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    /// Computes the ending column index for text selection.
203    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        // Buffer clone is cheap (just an i32 handle copy)
253        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/// Represents the current cursor coordinates in the active [`Window`].
302///
303/// Row is 1-based (Nvim convention), column is 0-based (byte index).
304#[derive(Debug)]
305pub struct CursorPosition {
306    pub row: usize,
307    pub col: usize,
308}
309
310impl CursorPosition {
311    /// Obtains the current cursor position from the active [`Window`].
312    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    /// Returns 1-based column index for rendering purposes.
326    pub const fn adjusted_col(&self) -> usize {
327        self.col.saturating_add(1)
328    }
329}
330
331/// Creates a new listed buffer.
332pub 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
338/// Retrieves the alternate buffer or creates a new one if none exists.
339pub 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
352/// Sets the specified buffer as the current buffer.
353pub 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
362/// Opens a file and positions the cursor at the specified line and column.
363///
364/// # Errors
365/// - Edit command or cursor positioning fails.
366pub 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
372/// Replaces the text in the specified `selection` with the `replacement` lines.
373pub 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
392/// Retrieves the relative path of the buffer from the current working directory.
393pub 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(&current_buffer_path),
404    ))
405}
406
407/// Retrieves the absolute path of the specified buffer.
408pub 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}