Skip to main content

ytil_noxi/
visual_selection.rs

1//! Visual selection extraction helpers.
2
3use std::ops::Range;
4
5use nvim_oxi::Array;
6use nvim_oxi::Dictionary;
7use nvim_oxi::Object;
8use nvim_oxi::api::Buffer;
9use nvim_oxi::api::SuperIterator;
10use nvim_oxi::api::opts::GetTextOpts;
11use nvim_oxi::conversion::FromObject;
12use nvim_oxi::lua::Poppable;
13use nvim_oxi::lua::ffi::State;
14use rootcause::prelude::ResultExt;
15use rootcause::report;
16use serde::Deserialize;
17use serde::Deserializer;
18
19use crate::buffer::BufferExt;
20use crate::dict;
21
22/// Extract selected text lines from the current [`Buffer`] using the active Visual range.
23///
24/// The range endpoints are derived from the current cursor position (`.`) and the Visual
25/// start mark (`'v`). This means the function is intended to be invoked while still in
26/// Visual mode; if Visual mode has already been exited the mark `'v` may refer to a
27/// previous selection and yield stale or unexpected text.
28///
29/// Mode handling:
30/// - Linewise (`V`): returns every full line covered by the selection (columns ignored).
31/// - Characterwise (`v`): returns a slice spanning from the start (inclusive) to the end (inclusive) by internally
32///   converting the end column to an exclusive bound.
33/// - Blockwise (CTRL-V): currently treated like a plain characterwise span; rectangular shape is not preserved.
34///
35/// On any Nvim API error (fetching marks, lines, or text) a notification is emitted and an
36/// empty [`Vec`] is returned.
37///
38/// # Caveats
39/// - Relies on the live Visual selection; does not fall back to `'<` / `'>` marks.
40/// - Blockwise selections lose their column rectangle shape.
41/// - Returned columns for multi-byte UTF-8 characters depend on byte indices exposed by `getpos()`; no grapheme-aware
42///   adjustment is performed.
43pub fn get_lines(_: ()) -> Vec<String> {
44    get(()).map_or_else(Vec::new, |f| f.lines)
45}
46
47/// Extract the last Visual selection using persisted `'<` / `'>` marks.
48///
49/// This is meant for integrations that leave Visual mode before acting on the selected text.
50pub fn get_marked(_: ()) -> Option<Dictionary> {
51    let selection = get_from_visual_marks(())?;
52
53    Some(selection_to_dict(&selection))
54}
55
56/// Extract the persisted Visual selection if it matches the given Ex range; otherwise return the full line range.
57///
58/// The returned dictionary is intentionally format-agnostic: it contains selected lines, 0-based buffer coordinates,
59/// and the command prefix needed by Lua integrations.
60pub fn get_for_ex_range((line1, line2): (usize, usize)) -> Option<Dictionary> {
61    get_from_visual_marks(())
62        .filter(|selection| selection.matches_ex_range(line1, line2))
63        .or_else(|| get_line_range_selection(line1, line2))
64        .map(|selection| selection_to_dict(&selection))
65}
66
67/// Return the command-line range prefix for a persisted Visual selection.
68pub fn get_visual_range_command_prefix(_: ()) -> Option<String> {
69    let bounds = SelectionBounds::from_visual_marks()
70        .inspect_err(|err| {
71            crate::notify::error(format!("error creating visual selection bounds | error={err:#?}"));
72        })
73        .ok()?;
74
75    Some(visual_range_command_prefix(&bounds))
76}
77
78/// Return an owned [`Selection`] for the active Visual range.
79///
80/// On any Nvim API error (fetching marks, lines, or text) a notification is emitted and [`None`] is returned.
81///
82/// # Errors
83/// - Return [`None`] if retrieving either mark fails.
84/// - Return [`None`] if the two marks reference different buffers.
85/// - Return [`None`] if getting lines or text fails.
86pub fn get(_: ()) -> Option<Selection> {
87    let mut bounds = SelectionBounds::new()
88        .inspect_err(|err| {
89            crate::notify::error(format!("error creating selection bounds | error={err:#?}"));
90        })
91        .ok()?;
92
93    get_selection(&mut bounds, nvim_oxi::api::get_mode().mode == "V")
94}
95
96/// Return an owned [`Selection`] for the persisted Visual marks.
97///
98/// On any Nvim API error (fetching marks, lines, or text) a notification is emitted and [`None`] is returned.
99pub fn get_from_visual_marks(_: ()) -> Option<Selection> {
100    let mut bounds = SelectionBounds::from_visual_marks()
101        .inspect_err(|err| {
102            crate::notify::error(format!("error creating visual selection bounds | error={err:#?}"));
103        })
104        .ok()?;
105
106    get_selection(&mut bounds, last_visual_mode().as_deref() == Some("V"))
107}
108
109fn get_selection(bounds: &mut SelectionBounds, is_linewise: bool) -> Option<Selection> {
110    let current_buffer = Buffer::from(bounds.buf_id());
111
112    // Handle linewise mode: grab full lines
113    if is_linewise {
114        let end_lnum = bounds.end().lnum;
115        let last_line = current_buffer
116            .get_line(end_lnum)
117            .inspect_err(|err| {
118                crate::notify::error(format!(
119                    "error getting selection last line | end_lnum={end_lnum} buffer={current_buffer:#?} error={err:#?}",
120                ));
121            })
122            .ok()?;
123        // Adjust bounds to start at column 0 and end at the last line's length
124        bounds.start.col = 0;
125        bounds.end.col = last_line.len();
126        // end.lnum inclusive for lines range
127        let lines = current_buffer
128            .get_lines(bounds.start().lnum..=bounds.end().lnum, false)
129            .inspect_err(|err| {
130                crate::notify::error(format!(
131                    "error getting lines | buffer={current_buffer:#?} error={err:#?}"
132                ));
133            })
134            .ok()?;
135        return Some(Selection::new(bounds.clone(), lines));
136    }
137
138    // Charwise mode:
139    // Clamp end.col to line length, then make exclusive by +1 (if not already at end).
140    if let Ok(line) = current_buffer.get_line(bounds.end().lnum)
141        && bounds.end().col < line.len()
142    {
143        bounds.incr_end_col(); // make exclusive
144    }
145
146    // For multi-line charwise selection rely on `nvim_buf_get_text` with an exclusive end.
147    let lines = current_buffer
148        .get_text(
149            bounds.line_range(),
150            bounds.start().col,
151            bounds.end().col,
152            &GetTextOpts::default(),
153        )
154        .inspect_err(|err| {
155            crate::notify::error(format!(
156                "error getting text | buffer={current_buffer:#?} bounds={bounds:#?} error={err:#?}"
157            ));
158        })
159        .ok()?;
160
161    Some(Selection::new(bounds.clone(), lines))
162}
163
164fn last_visual_mode() -> Option<String> {
165    nvim_oxi::api::call_function::<_, String>("visualmode", Array::new())
166        .inspect_err(|err| {
167            crate::notify::error(format!("error getting last visual mode | error={err:#?}"));
168        })
169        .ok()
170}
171
172fn selection_to_dict(selection: &Selection) -> Dictionary {
173    dict! {
174        "lines": selection.lines().iter().map(String::as_str).collect::<Array>(),
175        "start": bound_to_array(selection.start()),
176        "end": bound_to_array(selection.end()),
177        "command_prefix": visual_range_command_prefix(&selection.bounds),
178    }
179}
180
181fn bound_to_array(bound: &Bound) -> Array {
182    Array::from_iter([usize_to_i64(bound.lnum), usize_to_i64(bound.col)])
183}
184
185fn usize_to_i64(value: usize) -> i64 {
186    i64::try_from(value).unwrap_or(i64::MAX)
187}
188
189fn visual_range_command_prefix(_bounds: &SelectionBounds) -> String {
190    "'<,'>".to_owned()
191}
192
193fn get_line_range_selection(line1: usize, line2: usize) -> Option<Selection> {
194    if line2 < line1 {
195        return None;
196    }
197    let start_lnum = line1.checked_sub(1)?;
198    let end_lnum = line2.checked_sub(1)?;
199    let current_buffer = Buffer::current();
200    let last_line = current_buffer
201        .get_line(end_lnum)
202        .inspect_err(|err| {
203            crate::notify::error(format!(
204                "error getting range last line | end_lnum={end_lnum} buffer={current_buffer:#?} error={err:#?}",
205            ));
206        })
207        .ok()?;
208    let selected_lines = current_buffer
209        .get_lines(start_lnum..=end_lnum, true)
210        .inspect_err(|err| {
211            crate::notify::error(format!(
212                "error getting range lines | buffer={current_buffer:#?} error={err:#?}"
213            ));
214        })
215        .ok()?;
216    let bounds = SelectionBounds {
217        buf_id: current_buffer.handle(),
218        start: Bound {
219            lnum: start_lnum,
220            col: 0,
221        },
222        end: Bound {
223            lnum: end_lnum,
224            col: last_line.len(),
225        },
226    };
227
228    Some(Selection::new(bounds, selected_lines))
229}
230
231/// Owned selection content plus bounds.
232#[derive(Debug)]
233pub struct Selection {
234    bounds: SelectionBounds,
235    lines: Vec<String>,
236}
237
238impl Selection {
239    /// Create a new [`Selection`] from bounds and raw line objects.
240    pub fn new(bounds: SelectionBounds, lines: impl SuperIterator<nvim_oxi::String>) -> Self {
241        Self {
242            bounds,
243            lines: lines.into_iter().map(|line| line.to_string()).collect(),
244        }
245    }
246}
247
248/// Start / end bounds plus owning buffer id for a Visual selection.
249#[derive(Clone, Debug)]
250pub struct SelectionBounds {
251    #[cfg(feature = "testing")]
252    pub buf_id: i32,
253    #[cfg(feature = "testing")]
254    pub start: Bound,
255    #[cfg(feature = "testing")]
256    pub end: Bound,
257    #[cfg(not(feature = "testing"))]
258    buf_id: i32,
259    #[cfg(not(feature = "testing"))]
260    start: Bound,
261    #[cfg(not(feature = "testing"))]
262    end: Bound,
263}
264
265impl SelectionBounds {
266    /// Builds selection bounds from the current cursor (`.`) and visual start (`v`) marks.
267    ///
268    /// Retrieves positions using Nvim's `getpos()` function and normalizes them to 0-based indices.
269    /// The start and end are sorted to ensure start is before end.
270    ///
271    /// # Errors
272    /// - Fails if retrieving either mark fails.
273    /// - Fails if the two marks reference different buffers.
274    pub fn new() -> rootcause::Result<Self> {
275        let cursor_pos = get_pos(".")?;
276        let visual_pos = get_pos("v")?;
277
278        Self::from_positions(cursor_pos, visual_pos)
279    }
280
281    /// Builds selection bounds from the persisted Visual selection marks.
282    ///
283    /// # Errors
284    /// - Fails if retrieving either mark fails.
285    /// - Fails if the two marks reference different buffers.
286    pub fn from_visual_marks() -> rootcause::Result<Self> {
287        let start_pos = get_pos("'<")?;
288        let end_pos = get_pos("'>")?;
289
290        Self::from_positions(start_pos, end_pos)
291    }
292
293    fn from_positions(first: Pos, second: Pos) -> rootcause::Result<Self> {
294        let (start, end) = first.sort(second);
295
296        if start.buf_id != end.buf_id {
297            Err(report!("mismatched buffer ids")).attach_with(|| format!("start={start:#?} end={end:#?}"))?;
298        }
299
300        Ok(Self {
301            buf_id: start.buf_id,
302            start: Bound::from(start),
303            end: Bound::from(end),
304        })
305    }
306
307    /// Range of starting (inclusive) to ending (exclusive) line indices.
308    pub const fn line_range(&self) -> Range<usize> {
309        self.start.lnum..self.end.lnum
310    }
311
312    /// Owning buffer id.
313    pub const fn buf_id(&self) -> i32 {
314        self.buf_id
315    }
316
317    /// Start bound.
318    pub const fn start(&self) -> &Bound {
319        &self.start
320    }
321
322    /// End bound (exclusive line, exclusive column for charwise mode after adjustment).
323    pub const fn end(&self) -> &Bound {
324        &self.end
325    }
326
327    /// Increment end column (making it exclusive for charwise selections).
328    const fn incr_end_col(&mut self) {
329        self.end.col = self.end.col.saturating_add(1);
330    }
331}
332
333/// Single position (line, column) inside a buffer.
334#[derive(Clone, Copy, Debug, Eq, PartialEq)]
335pub struct Bound {
336    /// 0-based line number.
337    pub lnum: usize,
338    /// 0-based byte column.
339    pub col: usize,
340}
341
342impl From<Pos> for Bound {
343    fn from(value: Pos) -> Self {
344        Self {
345            lnum: value.lnum,
346            col: value.col,
347        }
348    }
349}
350
351impl Selection {
352    /// Buffer id containing the selection.
353    pub const fn buf_id(&self) -> i32 {
354        self.bounds.buf_id()
355    }
356
357    /// Start bound of the selection.
358    pub const fn start(&self) -> &Bound {
359        self.bounds.start()
360    }
361
362    /// End bound of the selection.
363    pub const fn end(&self) -> &Bound {
364        self.bounds.end()
365    }
366
367    /// Collected selected lines.
368    pub fn lines(&self) -> &[String] {
369        &self.lines
370    }
371
372    fn matches_ex_range(&self, line1: usize, line2: usize) -> bool {
373        self.start().lnum.checked_add(1) == Some(line1) && self.end().lnum.checked_add(1) == Some(line2)
374    }
375
376    /// Range of starting (inclusive) to ending (exclusive) line indices.
377    pub const fn line_range(&self) -> Range<usize> {
378        self.bounds.line_range()
379    }
380}
381
382/// Normalized, 0-based indexed output of Nvim `getpos()`.
383///
384/// Built from internal `RawPos` (private). Represents a single position inside a buffer using
385/// zero-based (line, column) indices.
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387pub struct Pos {
388    buf_id: i32,
389    /// 0-based line index.
390    lnum: usize,
391    /// 0-based byte column within the line.
392    col: usize,
393}
394
395impl Pos {
396    /// Return `(self, other)` sorted by position, swapping if needed so the first
397    /// has the lower (line, column) tuple (columns compared only when on the same line).
398    pub const fn sort(self, other: Self) -> (Self, Self) {
399        if self.lnum > other.lnum || (self.lnum == other.lnum && self.col > other.col) {
400            (other, self)
401        } else {
402            (self, other)
403        }
404    }
405}
406
407/// Custom [`Deserialize`] from Lua tuple produced by `getpos()` (via internal `RawPos`).
408impl<'de> Deserialize<'de> for Pos {
409    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
410    where
411        D: Deserializer<'de>,
412    {
413        let t = RawPos::deserialize(deserializer)?;
414        Ok(Self::from(t))
415    }
416}
417
418/// Convert internal `RawPos` to [`Pos`] by switching to 0-based indexing from Lua 1-based.
419impl From<RawPos> for Pos {
420    fn from(raw: RawPos) -> Self {
421        fn to_0_based_usize(v: i64) -> usize {
422            usize::try_from(v.saturating_sub(1)).unwrap_or_default()
423        }
424
425        Self {
426            buf_id: raw.0,
427            lnum: to_0_based_usize(raw.1),
428            col: to_0_based_usize(raw.2),
429        }
430    }
431}
432
433/// Raw `getpos()` tuple: (`bufnum`, `lnum`, `col`, `off`).
434#[derive(Clone, Copy, Debug, Deserialize)]
435#[expect(dead_code, reason = "Unused fields are kept for completeness")]
436struct RawPos(i32, i64, i64, i64);
437
438/// Implementation of [`FromObject`] for [`Pos`].
439impl FromObject for Pos {
440    fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
441        Self::deserialize(nvim_oxi::serde::Deserializer::new(obj)).map_err(Into::into)
442    }
443}
444
445/// Implementation of [`Poppable`] for [`Pos`].
446impl Poppable for Pos {
447    unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
448        // SAFETY: The caller (nvim-oxi framework) guarantees that:
449        // 1. `lstate` is a valid pointer to an initialized Lua state
450        // 2. The Lua stack has at least one value to pop
451        unsafe {
452            let obj = Object::pop(lstate)?;
453            Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
454        }
455    }
456}
457
458/// Calls Nvim's `getpos()` function for the supplied mark identifier and returns a normalized [`Pos`].
459///
460/// On success, converts the raw 1-based tuple into a 0-based [`Pos`].
461/// On failure, emits an error notification via [`crate::notify::error`] and wraps the error with
462/// additional context using [`rootcause`].
463///
464/// # Errors
465/// - Calling `getpos()` fails.
466/// - Deserializing the returned tuple into [`Pos`] fails.
467fn get_pos(mark: &str) -> rootcause::Result<Pos> {
468    Ok(
469        nvim_oxi::api::call_function::<_, Pos>("getpos", Array::from_iter([mark]))
470            .inspect_err(|err| {
471                crate::notify::error(format!("error getting pos | mark={mark:?} error={err:#?}"));
472            })
473            .context("error getting position")
474            .attach_with(|| format!("mark={mark:?}"))?,
475    )
476}
477
478#[cfg(test)]
479mod tests {
480    use rstest::rstest;
481
482    use super::*;
483
484    #[rstest]
485    #[case::self_has_lower_line(pos(0, 5), pos(1, 0), pos(0, 5), pos(1, 0))]
486    #[case::self_has_higher_line(pos(2, 0), pos(1, 5), pos(1, 5), pos(2, 0))]
487    #[case::same_line_self_lower_col(pos(1, 0), pos(1, 5), pos(1, 0), pos(1, 5))]
488    #[case::same_line_self_higher_col(pos(1, 10), pos(1, 5), pos(1, 5), pos(1, 10))]
489    #[case::positions_identical(pos(1, 5), pos(1, 5), pos(1, 5), pos(1, 5))]
490    fn test_pos_sort_returns_expected_order(
491        #[case] self_pos: Pos,
492        #[case] other_pos: Pos,
493        #[case] expected_first: Pos,
494        #[case] expected_second: Pos,
495    ) {
496        let (first, second) = self_pos.sort(other_pos);
497        pretty_assertions::assert_eq!(first, expected_first);
498        pretty_assertions::assert_eq!(second, expected_second);
499    }
500
501    fn pos(lnum: usize, col: usize) -> Pos {
502        Pos { buf_id: 1, lnum, col }
503    }
504
505    #[test]
506    fn test_selection_bounds_from_positions_normalizes_reversed_coordinates() {
507        let result = SelectionBounds::from_positions(pos(4, 10), pos(2, 3));
508
509        assert2::assert!(let Ok(bounds) = result);
510        pretty_assertions::assert_eq!(*bounds.start(), Bound { lnum: 2, col: 3 });
511        pretty_assertions::assert_eq!(*bounds.end(), Bound { lnum: 4, col: 10 });
512    }
513
514    #[test]
515    fn test_selection_lines_returns_raw_selected_lines() {
516        let result = SelectionBounds::from_positions(pos(1, 2), pos(2, 8));
517
518        assert2::assert!(let Ok(bounds) = result);
519        let selection = Selection::new(
520            bounds,
521            vec![nvim_oxi::String::from("{\"b\":2}"), nvim_oxi::String::from("x")].into_iter(),
522        );
523
524        pretty_assertions::assert_eq!(selection.lines(), &["{\"b\":2}".to_string(), "x".to_string()]);
525    }
526
527    #[test]
528    fn test_visual_range_command_prefix_returns_visual_range() {
529        let result = SelectionBounds::from_positions(pos(1, 0), pos(3, 4));
530
531        assert2::assert!(let Ok(bounds) = result);
532        pretty_assertions::assert_eq!(visual_range_command_prefix(&bounds), "'<,'>");
533    }
534}