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 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/// Extension trait for [`Buffer`] to provide extra functionalities.
19///
20/// Provides focused helpers for line fetching and text insertion at the current
21/// cursor position while surfacing Nvim errors via `notify_error`.
22#[cfg_attr(any(test, feature = "mockall"), mockall::automock)]
23pub trait BufferExt: Debug {
24    /// Fetch a single line from a [`Buffer`] by 0-based index.
25    ///
26    /// Returns a [`color_eyre::Result`] with the line as [`nvim_oxi::String`].
27    /// Errors if the line does not exist at `idx`.
28    ///
29    /// # Errors
30    /// - Fetching the line via `nvim_buf_get_lines` fails.
31    /// - The requested index is out of range (no line returned).
32    fn get_line(&self, idx: usize) -> color_eyre::Result<nvim_oxi::String>;
33
34    /// Retrieves a range of lines from the buffer.
35    ///
36    /// # Errors
37    /// - If `strict_indexing` is true and the range is out of bounds.
38    /// - If the Nvim API call to fetch lines fails.
39    ///
40    /// # Rationale
41    /// This is a thin wrapper around [`nvim_oxi::api::Buffer::get_lines`] to enable unit testing of the default trait
42    /// method [`BufferExt::get_text_between`].
43    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    /// Get text from a [`nvim_oxi::api::Buffer`].
50    ///
51    /// Retrieves text from the specified start position to end position, respecting the given boundary.
52    ///
53    /// # Errors
54    /// - If substring extraction fails due to invalid indices.
55    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    /// Retrieves the buffer type via the `buftype` option.
87    ///
88    /// Queries Nvim for the buffer type option and returns the value.
89    /// Errors are handled internally by notifying Nvim and converting to `None`.
90    ///
91    /// # Rationale
92    /// Errors are notified directly to Nvim because this is the behavior wanted in all cases.
93    fn get_buf_type(&self) -> Option<String>;
94
95    fn get_channel(&self) -> Option<u32>;
96
97    /// Inserts `text` at the current cursor position in the active buffer.
98    ///
99    /// Obtains the current [`CursorPosition`], converts the 1-based row to 0-based
100    /// for Nvim's `set_text` call, and inserts `text` without replacing existing
101    /// content (`start_col` == `end_col`). Errors are reported via `notify_error`.
102    /// Silently returns if cursor position cannot be fetched.
103    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    /// Retrieves the process ID associated with the buffer.
122    ///
123    /// # Errors
124    /// - If the buffer name cannot be retrieved.
125    /// - If the buffer is a terminal but the name format is invalid.
126    /// - If the Neovim `getpid` function call fails.
127    fn get_pid(&self) -> color_eyre::Result<String>;
128}
129
130/// Defines boundaries for text selection within lines.
131///
132/// # Rationale
133/// Used in [`BufferExt::get_text_between`] to specify how the start and end positions
134/// should be interpreted relative to line boundaries.
135#[derive(Default)]
136pub enum TextBoundary {
137    /// Exact column positions are used as-is.
138    #[default]
139    Exact,
140    /// Selection starts from the beginning of the line.
141    FromLineStart,
142    /// Selection ends at the specified line ending column.
143    ToLineEnd,
144    /// Selection spans from the start of the line to the end of the line.
145    FromLineStartToEnd,
146}
147
148impl TextBoundary {
149    /// Computes the starting column index for text selection based on the boundary type.
150    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    /// Computes the ending column index for text selection based on the boundary type.
161    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/// Represents the current cursor coordinates in the active [`Window`].
253///
254/// Row is 1-based (Nvim convention) and column is 0-based (byte index inside
255/// the line per Nvim API). These are kept verbatim to avoid off-by-one bugs.
256/// Call sites converting to Rust slice indices subtract 1 from `row` as needed.
257///
258/// # Assumptions
259/// - Constructed through [`CursorPosition::get_current`]; manual construction should respect coordinate conventions.
260///
261/// # Rationale
262/// Preserving raw Nvim values centralizes conversion logic at usage points
263/// (e.g. buffer line indexing) instead of embedding heuristics here.
264#[derive(Debug)]
265pub struct CursorPosition {
266    pub row: usize,
267    pub col: usize,
268}
269
270impl CursorPosition {
271    /// Obtains the current cursor position from the active [`Window`].
272    ///
273    /// Queries Nvim for the (row, col) of the active window cursor and returns a
274    /// [`CursorPosition`] reflecting those raw coordinates.
275    ///
276    /// # Assumptions
277    /// - Row is 1-based (Nvim convention); column is 0-based. Callers needing 0-based row for Rust indexing must
278    ///   subtract 1 explicitly.
279    /// - The active window is the intended source of truth for cursor location.
280    ///
281    /// # Rationale
282    /// Returning `Option` (instead of `Result`) simplifies common call sites that
283    /// treat absence as a soft failure (e.g. skipping an insertion). Detailed
284    /// error context is still surfaced to the user through `notify_error`.
285    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    /// Returns 1-based column index for rendering purposes.
299    ///
300    /// Converts the raw 0-based Nvim column stored in [`CursorPosition::col`] into a
301    /// human-friendly 1-based column suitable for statusline / UI output.
302    ///
303    /// # Assumptions
304    /// - [`CursorPosition::col`] is the unmodified 0-based byte offset provided by Nvim.
305    ///
306    /// # Rationale
307    /// Nvim exposes a 0-based column while rows are 1-based. Normalizing to 1-based for
308    /// display avoids mixed-base confusion in user-facing components (e.g. status line) and
309    /// clarifies intent at call sites.
310    ///
311    /// # Performance
312    /// Constant time. Uses `saturating_add` defensively (overflow is unrealistic given line length).
313    pub const fn adjusted_col(&self) -> usize {
314        self.col.saturating_add(1)
315    }
316}
317
318/// Creates a new listed, not scratch, buffer.
319///
320/// Errors are reported to Nvim via [`crate::notify::error`].
321pub 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
327/// Retrieves the alternate buffer or creates a new one if none exists.
328///
329/// The alternate buffer is the buffer previously visited, accessed via Nvim's "#" register.
330/// If no alternate buffer exists (bufnr("#") < 0), a new buffer is created.
331///
332/// # Errors
333/// - Retrieving the alternate buffer fails (notified via [`crate::notify::error`]).
334/// - Creating a new buffer fails (falls back to [`create`]).
335pub 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
348/// Sets the specified buffer as the current buffer in the active window.
349///
350/// # Errors
351/// - Setting the current buffer fails (notified via [`crate::notify::error`]).
352pub 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
361/// Opens a file in the editor and positions the cursor at the specified line and column.
362///
363/// # Errors
364/// - If execution of "edit" command via [`crate::common::exec_vim_cmd`] fails.
365/// - If setting the cursor position via [`Window::set_cursor`] fails.
366///
367/// # Rationale
368/// Executes two Neovim commands, one to open the file and one to set the cursor because it doesn't
369/// seems possible to execute a command line "edit +call\n cursor(LNUM, COL)".
370pub 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
376/// Replaces the text in the specified `selection` with the `replacement` lines.
377///
378/// Calls Nvim's `set_text` with the selection's line range and column positions,
379/// replacing the selected content with the provided lines.
380///
381/// Errors are reported via [`crate::notify::error`] with details about the selection
382/// boundaries and error.
383pub 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
402/// Retrieves the relative path of the given buffer from the current working directory.
403///
404/// Attempts to strip the current working directory prefix from the buffer's absolute path.
405/// If the buffer path does not start with the cwd, returns the absolute path as-is.
406///
407/// # Errors
408/// Errors (e.g., cannot get cwd or buffer name) are notified to Nvim but not propagated.
409pub 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(&current_buffer_path),
420    ))
421}
422
423/// Retrieves the absolute path of the specified buffer.
424///
425/// # Errors
426/// Errors are logged internally but do not propagate; the function returns [`None`] on failure.
427///
428/// # Assumptions
429/// Assumes that the buffer's name represents a valid path.
430pub 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}