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}