1use 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
22pub fn get_lines(_: ()) -> Vec<String> {
44 get(()).map_or_else(Vec::new, |f| f.lines)
45}
46
47pub fn get_marked(_: ()) -> Option<Dictionary> {
51 let selection = get_from_visual_marks(())?;
52
53 Some(selection_to_dict(&selection))
54}
55
56pub 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
67pub 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
78pub 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
96pub 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 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 bounds.start.col = 0;
125 bounds.end.col = last_line.len();
126 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 if let Ok(line) = current_buffer.get_line(bounds.end().lnum)
141 && bounds.end().col < line.len()
142 {
143 bounds.incr_end_col(); }
145
146 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#[derive(Debug)]
233pub struct Selection {
234 bounds: SelectionBounds,
235 lines: Vec<String>,
236}
237
238impl Selection {
239 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#[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 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 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 pub const fn line_range(&self) -> Range<usize> {
309 self.start.lnum..self.end.lnum
310 }
311
312 pub const fn buf_id(&self) -> i32 {
314 self.buf_id
315 }
316
317 pub const fn start(&self) -> &Bound {
319 &self.start
320 }
321
322 pub const fn end(&self) -> &Bound {
324 &self.end
325 }
326
327 const fn incr_end_col(&mut self) {
329 self.end.col = self.end.col.saturating_add(1);
330 }
331}
332
333#[derive(Clone, Copy, Debug, Eq, PartialEq)]
335pub struct Bound {
336 pub lnum: usize,
338 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 pub const fn buf_id(&self) -> i32 {
354 self.bounds.buf_id()
355 }
356
357 pub const fn start(&self) -> &Bound {
359 self.bounds.start()
360 }
361
362 pub const fn end(&self) -> &Bound {
364 self.bounds.end()
365 }
366
367 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 pub const fn line_range(&self) -> Range<usize> {
378 self.bounds.line_range()
379 }
380}
381
382#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387pub struct Pos {
388 buf_id: i32,
389 lnum: usize,
391 col: usize,
393}
394
395impl Pos {
396 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
407impl<'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
418impl 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#[derive(Clone, Copy, Debug, Deserialize)]
435#[expect(dead_code, reason = "Unused fields are kept for completeness")]
436struct RawPos(i32, i64, i64, i64);
437
438impl 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
445impl Poppable for Pos {
447 unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
448 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
458fn 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}