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/// Extension trait for [`Buffer`].
19#[cfg_attr(any(test, feature = "mockall"), mockall::automock)]
20pub trait BufferExt: Debug {
21    /// Fetch a single line from a [`Buffer`] by 0-based index.
22    ///
23    /// # Errors
24    /// - Fetching the line fails or index is out of range.
25    fn get_line(&self, idx: usize) -> rootcause::Result<nvim_oxi::String>;
26
27    /// Retrieves a range of lines from the buffer.
28    ///
29    /// # Errors
30    /// - If `strict_indexing` is true and the range is out of bounds.
31    /// - If the Nvim API call to fetch lines fails.
32    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    /// Get text between start and end positions.
39    ///
40    /// # Errors
41    /// - Substring extraction fails due to invalid indices.
42    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            // Use to_string_lossy() which returns Cow<str> - borrows if valid UTF-8, allocates only if invalid
57            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    /// Retrieves the buffer type via the `buftype` option.
76    fn get_buf_type(&self) -> Option<String>;
77
78    fn get_channel(&self) -> Option<u32>;
79
80    /// Inserts `text` at the current cursor position.
81    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    /// Retrieves the process ID associated with the buffer.
100    ///
101    /// # Errors
102    /// - Buffer name retrieval or PID parsing fails.
103    fn get_pid(&self) -> rootcause::Result<String>;
104}
105
106/// Defines boundaries for text selection within lines.
107#[derive(Default)]
108pub enum TextBoundary {
109    #[default]
110    Exact,
111    FromLineStart,
112    ToLineEnd,
113    FromLineStartToEnd,
114}
115
116impl TextBoundary {
117    /// Computes the starting column index for text selection.
118    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    /// Computes the ending column index for text selection.
129    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        // Buffer clone is cheap (just an i32 handle copy)
179        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/// Represents the current cursor coordinates in the active [`Window`].
228///
229/// Row is 1-based (Nvim convention), column is 0-based (byte index).
230#[derive(Debug)]
231pub struct CursorPosition {
232    pub row: usize,
233    pub col: usize,
234}
235
236impl CursorPosition {
237    /// Obtains the current cursor position from the active [`Window`].
238    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    /// Returns 1-based column index for rendering purposes.
252    pub const fn adjusted_col(&self) -> usize {
253        self.col.saturating_add(1)
254    }
255}
256
257/// Creates a new listed buffer.
258pub 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
264/// Retrieves the alternate buffer or creates a new one if none exists.
265pub 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
278/// Sets the specified buffer as the current buffer.
279pub 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
288/// Opens a file and positions the cursor at the specified line and column.
289///
290/// # Errors
291/// - Edit command or cursor positioning fails.
292pub 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
298/// Replaces the text in the specified `selection` with the `replacement` lines.
299pub 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
318/// Retrieves the relative path of the buffer from the current working directory.
319pub 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(&current_buffer_path),
330    ))
331}
332
333/// Retrieves the absolute path of the specified buffer.
334pub 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}