Skip to main content

lattice_protocol/
position.rs

1//! Logical positions and ranges within a buffer.
2//!
3//! Per §5.6.4: core, plugins, and the dispatcher deal exclusively in *logical*
4//! positions (line, byte). The renderer translates to visual positions when
5//! drawing. Plugins never see pixels.
6//!
7//! Byte offsets, not chars or UTF-16 code units: the rope and tree-sitter both
8//! index by byte, so the hot path never converts. Protocol peers that count
9//! differently (LSP's UTF-16 `character`) convert at their own boundary.
10
11use serde::{Deserialize, Serialize};
12
13/// A logical cursor position: zero-based line, zero-based byte offset within
14/// that line. UTF-8 byte offsets, not codepoint indices.
15///
16/// Ordering is lexicographic — by line, then byte — so `<` means "earlier in
17/// the buffer". Nothing here validates a position against a buffer: a byte
18/// past the end of the line, or inside a multi-byte character, is
19/// representable, and it is the consumer's job to clamp or reject it
20/// ([`ProtocolError::PositionOutOfBounds`](crate::ProtocolError::PositionOutOfBounds)).
21///
22/// # Examples
23///
24/// ```
25/// use lattice_protocol::Position;
26///
27/// // "héllo": `é` is two UTF-8 bytes, so the `l` after it is at byte 3.
28/// let l = Position::new(0, 3);
29/// assert!(Position::ZERO < l);
30/// assert!(Position::new(0, 99) < Position::new(1, 0)); // line wins
31/// ```
32#[derive(
33    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
34)]
35pub struct Position {
36    /// Zero-based line index.
37    pub line: u32,
38    /// Zero-based UTF-8 byte offset from the start of `line` (tree-sitter's
39    /// `Point.column` convention). A value equal to the line's byte length is
40    /// the end-of-line position.
41    pub byte: u32,
42}
43
44impl Position {
45    /// The start of the buffer: line 0, byte 0.
46    pub const ZERO: Position = Position { line: 0, byte: 0 };
47
48    /// A position at `line`, `byte` (both zero-based; `byte` in UTF-8 bytes).
49    pub const fn new(line: u32, byte: u32) -> Self {
50        Self { line, byte }
51    }
52}
53
54/// A half-open `[start, end)` range expressed as two `Position`s.
55///
56/// The vim-grammar `Range` (line ranges, marks, patterns, `:%`, `Selection`,
57/// custom) lives in `lattice-grammar`; this is the protocol-level structural
58/// range used by edits and decorations.
59///
60/// `start` is included, `end` is not, so `start == end` is a zero-width range
61/// (an insertion point). The constructors do not order or validate the
62/// endpoints; a well-formed range has `start <= end`, and consumers reject an
63/// inverted one ([`ProtocolError::InvalidRange`](crate::ProtocolError::InvalidRange)).
64///
65/// # Examples
66///
67/// ```
68/// use lattice_protocol::{Position, Range};
69///
70/// // The first three bytes of line 0 — "abc" in "abcdef".
71/// let abc = Range::new(Position::new(0, 0), Position::new(0, 3));
72/// assert!(!abc.is_empty());
73///
74/// // A range may span lines; `end` is exclusive.
75/// let two_lines = Range::new(Position::new(1, 4), Position::new(2, 0));
76/// assert_eq!(two_lines.end.line, 2);
77///
78/// // An insertion point.
79/// assert!(Range::empty(Position::new(3, 1)).is_empty());
80/// ```
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Range {
83    /// First position inside the range (inclusive).
84    pub start: Position,
85    /// First position past the range (exclusive).
86    pub end: Position,
87}
88
89impl Range {
90    /// The range `[start, end)`. Endpoints are stored as given.
91    pub const fn new(start: Position, end: Position) -> Self {
92        Self { start, end }
93    }
94
95    /// A zero-width range at `at` — where an insert goes.
96    pub const fn empty(at: Position) -> Self {
97        Self { start: at, end: at }
98    }
99
100    /// `true` when `start == end`: the range covers no bytes.
101    pub fn is_empty(&self) -> bool {
102        self.start == self.end
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    #![allow(clippy::unwrap_used, clippy::panic)]
109    use super::*;
110
111    #[test]
112    fn position_zero_is_origin() {
113        assert_eq!(Position::ZERO, Position::new(0, 0));
114        assert_eq!(Position::ZERO.line, 0);
115        assert_eq!(Position::ZERO.byte, 0);
116    }
117
118    #[test]
119    fn position_constructor_sets_fields() {
120        let p = Position::new(7, 3);
121        assert_eq!(p.line, 7);
122        assert_eq!(p.byte, 3);
123    }
124
125    #[test]
126    fn position_orders_lexicographically_by_line_then_byte() {
127        assert!(Position::new(0, 5) < Position::new(1, 0));
128        assert!(Position::new(2, 1) < Position::new(2, 2));
129        assert_eq!(Position::new(3, 4), Position::new(3, 4));
130    }
131
132    #[test]
133    fn range_new_keeps_endpoints() {
134        let a = Position::new(1, 0);
135        let b = Position::new(2, 5);
136        let r = Range::new(a, b);
137        assert_eq!(r.start, a);
138        assert_eq!(r.end, b);
139    }
140
141    #[test]
142    fn range_empty_is_a_zero_width_at_position() {
143        let p = Position::new(4, 2);
144        let r = Range::empty(p);
145        assert_eq!(r.start, p);
146        assert_eq!(r.end, p);
147        assert!(r.is_empty());
148    }
149
150    #[test]
151    fn non_empty_range_is_not_empty() {
152        let r = Range::new(Position::new(0, 0), Position::new(0, 1));
153        assert!(!r.is_empty());
154    }
155
156    #[test]
157    fn ranges_are_serializable() {
158        let r = Range::new(Position::new(1, 2), Position::new(3, 4));
159        let json = serde_json::to_string(&r).unwrap();
160        let back: Range = serde_json::from_str(&json).unwrap();
161        assert_eq!(back, r);
162    }
163}