ytil_noxi/
visual_selection.rs

1//! Visual selection extraction helpers.
2
3use std::ops::Range;
4
5use color_eyre::eyre::Context as _;
6use color_eyre::eyre::bail;
7use nvim_oxi::Array;
8use nvim_oxi::Object;
9use nvim_oxi::api::Buffer;
10use nvim_oxi::api::SuperIterator;
11use nvim_oxi::api::opts::GetTextOpts;
12use nvim_oxi::conversion::FromObject;
13use nvim_oxi::lua::Poppable;
14use nvim_oxi::lua::ffi::State;
15use serde::Deserialize;
16use serde::Deserializer;
17
18use crate::buffer::BufferExt;
19
20/// Extract selected text lines from the current [`Buffer`] using the active Visual range.
21///
22/// The range endpoints are derived from the current cursor position (`.`) and the Visual
23/// start mark (`'v`). This means the function is intended to be invoked while still in
24/// Visual mode; if Visual mode has already been exited the mark `'v` may refer to a
25/// previous selection and yield stale or unexpected text.
26///
27/// Mode handling:
28/// - Linewise (`V`): returns every full line covered by the selection (columns ignored).
29/// - Characterwise (`v`): returns a slice spanning from the start (inclusive) to the end (inclusive) by internally
30///   converting the end column to an exclusive bound.
31/// - Blockwise (CTRL-V): currently treated like a plain characterwise span; rectangular shape is not preserved.
32///
33/// On any Nvim API error (fetching marks, lines, or text) a notification is emitted and an
34/// empty [`Vec`] is returned. The resulting lines are also passed through [`nvim_oxi::dbg!`]
35/// (producing debug output) before being returned.
36///
37/// # Caveats
38/// - Relies on the live Visual selection; does not fall back to `'<` / `'>` marks.
39/// - Blockwise selections lose their column rectangle shape.
40/// - Returned columns for multi-byte UTF-8 characters depend on byte indices exposed by `getpos()`; no grapheme-aware
41///   adjustment is performed.
42pub fn get_lines(_: ()) -> Vec<String> {
43    get(()).map_or_else(Vec::new, |f| f.lines)
44}
45
46/// Return an owned [`Selection`] for the active Visual range.
47///
48/// On any Nvim API error (fetching marks, lines, or text) a notification is emitted and [`None`] is returned.
49///
50/// # Errors
51/// - Return [`None`] if retrieving either mark fails.
52/// - Return [`None`] if the two marks reference different buffers.
53/// - Return [`None`] if getting lines or text fails.
54pub fn get(_: ()) -> Option<Selection> {
55    let Ok(mut bounds) = SelectionBounds::new().inspect_err(|err| {
56        crate::notify::error(format!("error creating selection bounds | error={err:#?}"));
57    }) else {
58        return None;
59    };
60
61    let current_buffer = Buffer::from(bounds.buf_id());
62
63    // Handle linewise mode: grab full lines
64    if nvim_oxi::api::get_mode().mode == "V" {
65        let end_lnum = bounds.end().lnum;
66        let Ok(last_line) = current_buffer.get_line(end_lnum).inspect_err(|err| {
67            crate::notify::error(format!(
68                "error getting selection last line | end_lnum={end_lnum} buffer={current_buffer:#?} error={err:#?}",
69            ));
70        }) else {
71            return None;
72        };
73        // Adjust bounds to start at column 0 and end at the last line's length
74        bounds.start.col = 0;
75        bounds.end.col = last_line.len();
76        // end.lnum inclusive for lines range
77        let Ok(lines) = current_buffer
78            .get_lines(bounds.start().lnum..=bounds.end().lnum, false)
79            .inspect_err(|err| {
80                crate::notify::error(format!(
81                    "error getting lines | buffer={current_buffer:#?} error={err:#?}"
82                ));
83            })
84        else {
85            return None;
86        };
87        return Some(Selection::new(bounds, lines));
88    }
89
90    // Charwise mode:
91    // Clamp end.col to line length, then make exclusive by +1 (if not already at end).
92    if let Ok(line) = current_buffer.get_line(bounds.end().lnum)
93        && bounds.end().col < line.len()
94    {
95        bounds.incr_end_col(); // make exclusive
96    }
97
98    // For multi-line charwise selection rely on `nvim_buf_get_text` with an exclusive end.
99    let Ok(lines) = current_buffer
100        .get_text(
101            bounds.line_range(),
102            bounds.start().col,
103            bounds.end().col,
104            &GetTextOpts::default(),
105        )
106        .inspect_err(|err| {
107            crate::notify::error(format!(
108                "error getting text | buffer={current_buffer:#?} bounds={bounds:#?} error={err:#?}"
109            ));
110        })
111    else {
112        return None;
113    };
114
115    Some(Selection::new(bounds, lines))
116}
117
118/// Owned selection content plus bounds.
119#[derive(Debug)]
120pub struct Selection {
121    bounds: SelectionBounds,
122    lines: Vec<String>,
123}
124
125impl Selection {
126    /// Create a new [`Selection`] from bounds and raw line objects.
127    pub fn new(bounds: SelectionBounds, lines: impl SuperIterator<nvim_oxi::String>) -> Self {
128        Self {
129            bounds,
130            lines: lines.into_iter().map(|line| line.to_string()).collect(),
131        }
132    }
133}
134
135/// Start / end bounds plus owning buffer id for a Visual selection.
136#[derive(Clone, Debug)]
137pub struct SelectionBounds {
138    #[cfg(feature = "testing")]
139    pub buf_id: i32,
140    #[cfg(feature = "testing")]
141    pub start: Bound,
142    #[cfg(feature = "testing")]
143    pub end: Bound,
144    #[cfg(not(feature = "testing"))]
145    buf_id: i32,
146    #[cfg(not(feature = "testing"))]
147    start: Bound,
148    #[cfg(not(feature = "testing"))]
149    end: Bound,
150}
151
152impl SelectionBounds {
153    /// Builds selection bounds from the current cursor (`.`) and visual start (`v`) marks.
154    ///
155    /// Retrieves positions using Nvim's `getpos()` function and normalizes them to 0-based indices.
156    /// The start and end are sorted to ensure start is before end.
157    ///
158    /// # Errors
159    /// - Fails if retrieving either mark fails.
160    /// - Fails if the two marks reference different buffers.
161    pub fn new() -> color_eyre::Result<Self> {
162        let cursor_pos = get_pos(".")?;
163        let visual_pos = get_pos("v")?;
164
165        let (start, end) = cursor_pos.sort(visual_pos);
166
167        if start.buf_id != end.buf_id {
168            bail!("mismatched buffer ids | start={start:#?} end={end:#?}")
169        }
170
171        Ok(Self {
172            buf_id: start.buf_id,
173            start: Bound::from(start),
174            end: Bound::from(end),
175        })
176    }
177
178    /// Range of starting (inclusive) to ending (exclusive) line indices.
179    pub const fn line_range(&self) -> Range<usize> {
180        self.start.lnum..self.end.lnum
181    }
182
183    /// Owning buffer id.
184    pub const fn buf_id(&self) -> i32 {
185        self.buf_id
186    }
187
188    /// Start bound.
189    pub const fn start(&self) -> &Bound {
190        &self.start
191    }
192
193    /// End bound (exclusive line, exclusive column for charwise mode after adjustment).
194    pub const fn end(&self) -> &Bound {
195        &self.end
196    }
197
198    /// Increment end column (making it exclusive for charwise selections).
199    const fn incr_end_col(&mut self) {
200        self.end.col = self.end.col.saturating_add(1);
201    }
202}
203
204/// Single position (line, column) inside a buffer.
205#[derive(Clone, Copy, Debug)]
206pub struct Bound {
207    /// 0-based line number.
208    pub lnum: usize,
209    /// 0-based byte column.
210    pub col: usize,
211}
212
213impl From<Pos> for Bound {
214    fn from(value: Pos) -> Self {
215        Self {
216            lnum: value.lnum,
217            col: value.col,
218        }
219    }
220}
221
222impl Selection {
223    /// Buffer id containing the selection.
224    pub const fn buf_id(&self) -> i32 {
225        self.bounds.buf_id()
226    }
227
228    /// Start bound of the selection.
229    pub const fn start(&self) -> &Bound {
230        self.bounds.start()
231    }
232
233    /// End bound of the selection.
234    pub const fn end(&self) -> &Bound {
235        self.bounds.end()
236    }
237
238    /// Collected selected lines.
239    pub fn lines(&self) -> &[String] {
240        &self.lines
241    }
242
243    /// Range of starting (inclusive) to ending (exclusive) line indices.
244    pub const fn line_range(&self) -> Range<usize> {
245        self.bounds.line_range()
246    }
247}
248
249/// Normalized, 0-based indexed output of Nvim `getpos()`.
250///
251/// Built from internal `RawPos` (private). Represents a single position inside a buffer using
252/// zero-based (line, column) indices.
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub struct Pos {
255    buf_id: i32,
256    /// 0-based line index.
257    lnum: usize,
258    /// 0-based byte column within the line.
259    col: usize,
260}
261
262impl Pos {
263    /// Return `(self, other)` sorted by position, swapping if needed so the first
264    /// has the lower (line, column) tuple (columns compared only when on the same line).
265    pub const fn sort(self, other: Self) -> (Self, Self) {
266        if self.lnum > other.lnum || (self.lnum == other.lnum && self.col > other.col) {
267            (other, self)
268        } else {
269            (self, other)
270        }
271    }
272}
273
274/// Custom [`Deserialize`] from Lua tuple produced by `getpos()` (via internal `RawPos`).
275impl<'de> Deserialize<'de> for Pos {
276    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
277    where
278        D: Deserializer<'de>,
279    {
280        let t = RawPos::deserialize(deserializer)?;
281        Ok(Self::from(t))
282    }
283}
284
285/// Convert internal `RawPos` to [`Pos`] by switching to 0-based indexing from Lua 1-based.
286impl From<RawPos> for Pos {
287    fn from(raw: RawPos) -> Self {
288        fn to_0_based_usize(v: i64) -> usize {
289            usize::try_from(v.saturating_sub(1)).unwrap_or_default()
290        }
291
292        Self {
293            buf_id: raw.0,
294            lnum: to_0_based_usize(raw.1),
295            col: to_0_based_usize(raw.2),
296        }
297    }
298}
299
300/// Raw `getpos()` tuple: (`bufnum`, `lnum`, `col`, `off`).
301#[derive(Clone, Copy, Debug, Deserialize)]
302#[expect(dead_code, reason = "Unused fields are kept for completeness")]
303struct RawPos(i32, i64, i64, i64);
304
305/// Implementation of [`FromObject`] for [`Pos`].
306impl FromObject for Pos {
307    fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
308        Self::deserialize(nvim_oxi::serde::Deserializer::new(obj)).map_err(Into::into)
309    }
310}
311
312/// Implementation of [`Poppable`] for [`Pos`].
313impl Poppable for Pos {
314    unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
315        unsafe {
316            let obj = Object::pop(lstate)?;
317            Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
318        }
319    }
320}
321
322/// Calls Nvim's `getpos()` function for the supplied mark identifier and returns a normalized [`Pos`].
323///
324/// On success, converts the raw 1-based tuple into a 0-based [`Pos`].
325/// On failure, emits an error notification via [`crate::notify::error`] and wraps the error with
326/// additional context using [`color_eyre::eyre`].
327///
328/// # Errors
329/// - Calling `getpos()` fails.
330/// - Deserializing the returned tuple into [`Pos`] fails.
331fn get_pos(mark: &str) -> color_eyre::Result<Pos> {
332    nvim_oxi::api::call_function::<_, Pos>("getpos", Array::from_iter([mark]))
333        .inspect_err(|err| {
334            crate::notify::error(format!("error getting pos | mark={mark:?} error={err:#?}"));
335        })
336        .wrap_err_with(|| format!("error getting position | mark={mark:?}"))
337}
338
339#[cfg(test)]
340mod tests {
341    use rstest::rstest;
342
343    use super::*;
344
345    #[rstest]
346    #[case::self_has_lower_line(pos(0, 5), pos(1, 0), pos(0, 5), pos(1, 0))]
347    #[case::self_has_higher_line(pos(2, 0), pos(1, 5), pos(1, 5), pos(2, 0))]
348    #[case::same_line_self_lower_col(pos(1, 0), pos(1, 5), pos(1, 0), pos(1, 5))]
349    #[case::same_line_self_higher_col(pos(1, 10), pos(1, 5), pos(1, 5), pos(1, 10))]
350    #[case::positions_identical(pos(1, 5), pos(1, 5), pos(1, 5), pos(1, 5))]
351    fn pos_sort_returns_expected_order(
352        #[case] self_pos: Pos,
353        #[case] other_pos: Pos,
354        #[case] expected_first: Pos,
355        #[case] expected_second: Pos,
356    ) {
357        let (first, second) = self_pos.sort(other_pos);
358        pretty_assertions::assert_eq!(first, expected_first);
359        pretty_assertions::assert_eq!(second, expected_second);
360    }
361
362    fn pos(lnum: usize, col: usize) -> Pos {
363        Pos { buf_id: 1, lnum, col }
364    }
365}