Skip to main content

lattice_grammar/
range.rs

1//! The vim grammar `Range` -- the dispatcher's range arg.
2//!
3//! Distinct from `lattice_protocol::position::Range` (which is a structural
4//! `[start, end)` byte range used by edits and decorations). The grammar
5//! `Range` carries vim's ex-syntax range forms: `:1,5`, `:%`, `:'<,'>`,
6//! `:.,+10`, `Selection` (active visual region), plugin-supplied custom
7//! ranges.
8
9use serde::{Deserialize, Serialize};
10
11use crate::registry::RangeId;
12
13/// A vim range argument: *which lines* an ex-command or operator covers,
14/// before it is resolved against a document.
15///
16/// Symbolic on purpose: `%` or `'<,'>` means different lines in different
17/// buffers and at different times, so the value is carried unresolved (in a
18/// [`crate::CommandInvocation`], a recorded macro, a plugin call) and each
19/// consumer resolves it at apply time.
20///
21/// Resolution coverage is uneven today. The dispatcher's operator path
22/// resolves `CurrentLine`, `Whole` and `Selection` and rejects `Span` /
23/// `Custom` with [`crate::CommandError::InvalidArgs`]; `:narrow` resolves
24/// every form (patterns fall back to the cursor line, `Custom` to the
25/// cursor line). No parser in the tree produces `Span` or `Custom` yet.
26///
27/// # Examples
28///
29/// ```
30/// use lattice_grammar::{Range, RangeBound};
31///
32/// // `:.,+10` -- from the cursor line to ten lines below it.
33/// let r = Range::Span {
34///     start: RangeBound::CurrentLine,
35///     end: RangeBound::Offset {
36///         base: Box::new(RangeBound::CurrentLine),
37///         delta: 10,
38///     },
39/// };
40/// assert!(matches!(r, Range::Span { .. }));
41///
42/// // `:'<,'>` -- the last Visual selection, via its marks.
43/// let visual = Range::Span {
44///     start: RangeBound::Mark('<'),
45///     end: RangeBound::Mark('>'),
46/// };
47/// assert_ne!(visual, Range::Selection); // same lines, different form
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub enum Range {
51    /// `:1,5`, `:'<,'>`, `:.,+10`, etc. Both ends inclusive; a consumer
52    /// that resolves `start` below `end` swaps them (vim asks, lattice
53    /// swaps silently).
54    Span {
55        /// First line of the range.
56        start: RangeBound,
57        /// Last line of the range (inclusive).
58        end: RangeBound,
59    },
60    /// `:.` -- the cursor's line.
61    CurrentLine,
62    /// `:%` -- every line of the buffer.
63    Whole,
64    /// The current Visual / active region (the default range while Visual
65    /// is active). Unlike the other forms it keeps Visual's shape: the
66    /// dispatcher resolves it charwise (head-inclusive), linewise or
67    /// blockwise according to the selection's mode.
68    Selection,
69    /// Plugin-registered custom range (e.g., a git-hunk-range plugin),
70    /// identified by its registry id.
71    Custom(RangeId),
72}
73
74/// One end of a [`Range::Span`].
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub enum RangeBound {
77    /// Absolute line number, **0-based** (the user's `:3` is `Line(2)`);
78    /// clamped to the last line on resolution.
79    Line(u32),
80    /// A mark's line (`'a`, `'<`, `'>`, etc.). `<` / `>` resolve to the last
81    /// Visual selection's first / last line; an unset mark resolves to the
82    /// cursor line.
83    Mark(char),
84    /// `.` -- the cursor's line.
85    CurrentLine,
86    /// `$` -- the buffer's last line.
87    LastLine,
88    /// Pattern-relative (`/foo/`, `?bar?`): the pattern text, without
89    /// delimiters. Not searched yet -- resolves to the cursor line.
90    Pattern(String),
91    /// Offset from another bound (`+1`, `-3`, `.+5`); the result is
92    /// clamped to the buffer.
93    Offset {
94        /// The bound the offset is relative to.
95        base: Box<RangeBound>,
96        /// Signed line delta added to `base`'s line.
97        delta: i32,
98    },
99}
100
101/// The inclusive whole lines an operator's byte span covers, given its start
102/// and end `(line, byte)` in either order.
103///
104/// A span ending at byte 0 of a later line is half-open: nothing on that line
105/// is covered, so the last covered line is the one before. That's the shape a
106/// forward exclusive motion leaves (`}`, `G`), and vim agrees:
107/// `:h exclusive-linewise`, "the end is moved to the end of the previous line".
108///
109/// Known gap: a BACKWARD exclusive motion (`k` from column 0) also ends at byte
110/// 0 of the cursor's own line, and from the span alone that can't be told apart,
111/// so the cursor's line is dropped. Lattice has no linewise operator targets
112/// yet (`dk` is charwise too); threading the cursor through is the fix when it
113/// matters.
114///
115/// Shared by the narrow operator (`zn`) and the fold operator (`zf`), which is
116/// why it lives here rather than in either.
117///
118/// Lines are 0-based; the returned `(first, last)` is inclusive and ordered.
119///
120/// # Examples
121///
122/// ```
123/// use lattice_grammar::range::span_to_whole_lines;
124///
125/// // Ends mid-line on line 3: lines 0..=3 are covered.
126/// assert_eq!(span_to_whole_lines(0, 0, 3, 5), (0, 3));
127/// // Ends at byte 0 of line 3 (a forward exclusive motion like `}`):
128/// // line 3 is not covered.
129/// assert_eq!(span_to_whole_lines(0, 0, 3, 0), (0, 2));
130/// // Reversed input is ordered.
131/// assert_eq!(span_to_whole_lines(4, 2, 1, 7), (1, 4));
132/// ```
133pub fn span_to_whole_lines(
134    start_line: u32,
135    start_byte: u32,
136    end_line: u32,
137    end_byte: u32,
138) -> (u32, u32) {
139    let ((lo_line, _lo_byte), (hi_line, hi_byte)) = if start_line <= end_line {
140        ((start_line, start_byte), (end_line, end_byte))
141    } else {
142        ((end_line, end_byte), (start_line, start_byte))
143    };
144    let mut end = hi_line;
145    if hi_byte == 0 && end > lo_line {
146        end -= 1;
147    }
148    (lo_line, end)
149}
150
151#[cfg(test)]
152mod tests {
153    #![allow(clippy::unwrap_used, clippy::panic)]
154    use super::*;
155
156    #[test]
157    fn whole_range_renders_distinct_variant() {
158        assert_ne!(Range::Whole, Range::CurrentLine);
159        assert_ne!(Range::Whole, Range::Selection);
160    }
161
162    #[test]
163    fn span_constructed_from_bounds() {
164        let r = Range::Span {
165            start: RangeBound::Line(0),
166            end: RangeBound::Line(4),
167        };
168        match r {
169            Range::Span { start, end } => {
170                assert_eq!(start, RangeBound::Line(0));
171                assert_eq!(end, RangeBound::Line(4));
172            }
173            _ => panic!("expected Span"),
174        }
175    }
176
177    #[test]
178    fn offset_bounds_compose() {
179        let off = RangeBound::Offset {
180            base: Box::new(RangeBound::CurrentLine),
181            delta: 5,
182        };
183        match off {
184            RangeBound::Offset { base, delta } => {
185                assert_eq!(*base, RangeBound::CurrentLine);
186                assert_eq!(delta, 5);
187            }
188            _ => panic!("expected Offset"),
189        }
190    }
191
192    #[test]
193    fn span_to_whole_lines_mid_line_end_is_inclusive() {
194        // `j`-like: next line, end mid-line → both lines covered.
195        assert_eq!(span_to_whole_lines(0, 0, 3, 5), (0, 3));
196    }
197
198    #[test]
199    fn span_to_whole_lines_half_open_end_at_col0_drops_trailing_line() {
200        // Forward exclusive motions end at column 0 of the line AFTER the
201        // last content line → the last covered line is the previous one.
202        assert_eq!(span_to_whole_lines(0, 0, 3, 0), (0, 2));
203    }
204
205    #[test]
206    fn span_to_whole_lines_single_line() {
207        assert_eq!(span_to_whole_lines(2, 0, 2, 4), (2, 2));
208    }
209
210    #[test]
211    fn span_to_whole_lines_reversed_is_ordered() {
212        // A backward span (end before start) is ordered first. This also pins
213        // the documented `k`-from-column-0 gap: line 5 is dropped.
214        assert_eq!(span_to_whole_lines(5, 0, 2, 0), (2, 4));
215    }
216}