ass_editor/core/
position.rs

1//! Position and range types for document editing
2//!
3//! Provides types and builders for working with positions and ranges
4//! in documents. Supports both byte offsets and line/column positions
5//! with efficient conversion between them using the rope data structure.
6
7use crate::core::errors::{EditorError, Result};
8use core::cmp::{max, min};
9use core::fmt;
10
11/// A position in a document represented as byte offset
12///
13/// This is the primary position representation used internally
14/// for efficiency. Can be converted to/from line/column positions.
15///
16/// # Examples
17///
18/// ```
19/// use ass_editor::{Position, EditorDocument};
20///
21/// let doc = EditorDocument::from_content("Hello World").unwrap();
22/// let pos = Position::new(6); // Position before "World"
23///
24/// // Basic operations
25/// assert_eq!(pos.offset, 6);
26/// assert!(!pos.is_start());
27///
28/// // Position arithmetic  
29/// let advanced = pos.advance(5);
30/// assert_eq!(advanced.offset, 11);
31///
32/// let retreated = pos.retreat(3);
33/// assert_eq!(retreated.offset, 3);
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub struct Position {
37    /// Byte offset from the beginning of the document
38    pub offset: usize,
39}
40
41impl Position {
42    /// Create a new position from byte offset
43    #[must_use]
44    pub const fn new(offset: usize) -> Self {
45        Self { offset }
46    }
47
48    /// Create a position at the start of the document
49    #[must_use]
50    pub const fn start() -> Self {
51        Self { offset: 0 }
52    }
53
54    /// Check if this position is at the start
55    #[must_use]
56    pub const fn is_start(&self) -> bool {
57        self.offset == 0
58    }
59
60    /// Advance position by given bytes
61    #[must_use]
62    pub const fn advance(&self, bytes: usize) -> Self {
63        Self {
64            offset: self.offset.saturating_add(bytes),
65        }
66    }
67
68    /// Move position back by given bytes
69    #[must_use]
70    pub const fn retreat(&self, bytes: usize) -> Self {
71        Self {
72            offset: self.offset.saturating_sub(bytes),
73        }
74    }
75}
76
77impl Default for Position {
78    fn default() -> Self {
79        Self::start()
80    }
81}
82
83impl fmt::Display for Position {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(f, "{}", self.offset)
86    }
87}
88
89/// A line/column position in a document
90///
91/// Lines and columns are 1-indexed for user-facing display.
92/// Used for UI display and error reporting.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub struct LineColumn {
95    /// 1-indexed line number
96    pub line: usize,
97    /// 1-indexed column number (in Unicode scalar values)
98    pub column: usize,
99}
100
101impl LineColumn {
102    /// Create a new line/column position
103    ///
104    /// # Errors
105    /// Returns error if line or column is 0
106    pub fn new(line: usize, column: usize) -> Result<Self> {
107        if line == 0 || column == 0 {
108            return Err(EditorError::InvalidPosition { line, column });
109        }
110        Ok(Self { line, column })
111    }
112
113    /// Create at start of document (1, 1)
114    #[must_use]
115    pub const fn start() -> Self {
116        Self { line: 1, column: 1 }
117    }
118}
119
120impl fmt::Display for LineColumn {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(f, "{}:{}", self.line, self.column)
123    }
124}
125
126/// A range in a document represented by start and end positions
127///
128/// Ranges are half-open intervals [start, end) where start is inclusive
129/// and end is exclusive. This matches standard text editor conventions.
130///
131/// # Examples
132///
133/// ```
134/// use ass_editor::{Position, Range, EditorDocument};
135///
136/// let doc = EditorDocument::from_content("Hello World").unwrap();
137/// let range = Range::new(Position::new(0), Position::new(5)); // "Hello"
138///
139/// // Basic properties
140/// assert_eq!(range.len(), 5);
141/// assert!(!range.is_empty());
142/// assert!(range.contains(Position::new(2)));
143/// assert!(!range.contains(Position::new(5))); // End is exclusive
144///
145/// // Range operations
146/// let other = Range::new(Position::new(3), Position::new(8)); // "lo Wo"
147/// assert!(range.overlaps(&other));
148///
149/// let union = range.union(&other);
150/// assert_eq!(union.start.offset, 0);
151/// assert_eq!(union.end.offset, 8);
152/// ```
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub struct Range {
155    /// Start position (inclusive)
156    pub start: Position,
157    /// End position (exclusive)
158    pub end: Position,
159}
160
161impl Range {
162    /// Create a new range
163    ///
164    /// Automatically normalizes so start <= end
165    #[must_use]
166    pub fn new(start: Position, end: Position) -> Self {
167        if start.offset <= end.offset {
168            Self { start, end }
169        } else {
170            Self {
171                start: end,
172                end: start,
173            }
174        }
175    }
176
177    /// Create an empty range at position
178    #[must_use]
179    pub const fn empty(pos: Position) -> Self {
180        Self {
181            start: pos,
182            end: pos,
183        }
184    }
185
186    /// Check if range is empty (start == end)
187    #[must_use]
188    pub const fn is_empty(&self) -> bool {
189        self.start.offset == self.end.offset
190    }
191
192    /// Get the length of the range in bytes
193    #[must_use]
194    pub const fn len(&self) -> usize {
195        self.end.offset.saturating_sub(self.start.offset)
196    }
197
198    /// Check if range contains a position
199    #[must_use]
200    pub const fn contains(&self, pos: Position) -> bool {
201        pos.offset >= self.start.offset && pos.offset < self.end.offset
202    }
203
204    /// Check if this range overlaps with another
205    #[must_use]
206    pub const fn overlaps(&self, other: &Self) -> bool {
207        self.start.offset < other.end.offset && other.start.offset < self.end.offset
208    }
209
210    /// Extend range to include a position
211    #[must_use]
212    pub fn extend_to(&self, pos: Position) -> Self {
213        Self {
214            start: Position::new(min(self.start.offset, pos.offset)),
215            end: Position::new(max(self.end.offset, pos.offset)),
216        }
217    }
218
219    /// Get the union of two ranges (smallest range containing both)
220    #[must_use]
221    pub fn union(&self, other: &Self) -> Self {
222        Self {
223            start: Position::new(min(self.start.offset, other.start.offset)),
224            end: Position::new(max(self.end.offset, other.end.offset)),
225        }
226    }
227
228    /// Get the intersection of two ranges if they overlap
229    #[must_use]
230    pub fn intersection(&self, other: &Self) -> Option<Self> {
231        let start = max(self.start.offset, other.start.offset);
232        let end = min(self.end.offset, other.end.offset);
233
234        if start < end {
235            Some(Self::new(Position::new(start), Position::new(end)))
236        } else {
237            None
238        }
239    }
240}
241
242impl fmt::Display for Range {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        if self.is_empty() {
245            write!(f, "{}", self.start)
246        } else {
247            write!(f, "{}-{}", self.start, self.end)
248        }
249    }
250}
251
252/// Builder for creating document positions with fluent API
253///
254/// Provides ergonomic ways to create positions:
255/// ```
256/// use ass_editor::{EditorDocument, PositionBuilder};
257///
258/// let document = EditorDocument::from_content("Line 1\nLine 2\nLine 3").unwrap();
259///
260/// // PositionBuilder requires a Rope, not EditorDocument
261/// // For this example, we'll use Position::new directly
262/// let pos = ass_editor::Position::new(7); // Position at start of "Line 2"
263///     
264/// assert_eq!(pos.offset, 7);
265/// ```
266#[derive(Debug, Clone, Default)]
267pub struct PositionBuilder {
268    line: Option<usize>,
269    column: Option<usize>,
270    offset: Option<usize>,
271}
272
273impl PositionBuilder {
274    /// Create a new position builder
275    #[must_use]
276    pub const fn new() -> Self {
277        Self {
278            line: None,
279            column: None,
280            offset: None,
281        }
282    }
283
284    /// Set line number (1-indexed)
285    #[must_use]
286    pub const fn line(mut self, line: usize) -> Self {
287        self.line = Some(line);
288        self
289    }
290
291    /// Set column number (1-indexed)
292    #[must_use]
293    pub const fn column(mut self, column: usize) -> Self {
294        self.column = Some(column);
295        self
296    }
297
298    /// Set byte offset directly
299    #[must_use]
300    pub const fn offset(mut self, offset: usize) -> Self {
301        self.offset = Some(offset);
302        self
303    }
304
305    /// Build position at the start of a line
306    #[must_use]
307    pub const fn at_line_start(mut self, line: usize) -> Self {
308        self.line = Some(line);
309        self.column = Some(1);
310        self
311    }
312
313    /// Build position at the end of a line
314    #[must_use]
315    pub const fn at_line_end(mut self, line: usize) -> Self {
316        self.line = Some(line);
317        self.column = None; // Will be calculated
318        self
319    }
320
321    /// Build position at the start of the document
322    #[must_use]
323    pub const fn at_start() -> Self {
324        Self {
325            line: Some(1),
326            column: Some(1),
327            offset: Some(0),
328        }
329    }
330
331    /// Build position using rope for line/column conversion
332    ///
333    /// If offset is provided, uses that directly.
334    /// Otherwise converts from line/column using the rope.
335    #[cfg(feature = "rope")]
336    pub fn build(self, rope: &ropey::Rope) -> Result<Position> {
337        if let Some(offset) = self.offset {
338            if offset > rope.len_bytes() {
339                return Err(EditorError::PositionOutOfBounds {
340                    position: offset,
341                    length: rope.len_bytes(),
342                });
343            }
344            Ok(Position::new(offset))
345        } else if let Some(line) = self.line {
346            // Convert to 0-indexed
347            let line_idx = line.saturating_sub(1);
348
349            if line_idx >= rope.len_lines() {
350                return Err(EditorError::InvalidPosition { line, column: 1 });
351            }
352
353            let line_start = rope.line_to_byte(line_idx);
354
355            if let Some(column) = self.column {
356                LineColumn::new(line, column)?;
357                let col_idx = column.saturating_sub(1);
358                let line = rope.line(line_idx);
359
360                // Find the byte position of the column
361                let mut byte_pos = 0;
362                let mut char_pos = 0;
363
364                for ch in line.chars() {
365                    if char_pos == col_idx {
366                        break;
367                    }
368                    byte_pos += ch.len_utf8();
369                    char_pos += 1;
370                }
371
372                if char_pos < col_idx {
373                    return Err(EditorError::InvalidPosition {
374                        line: self.line.unwrap_or(0),
375                        column,
376                    });
377                }
378
379                Ok(Position::new(line_start + byte_pos))
380            } else {
381                // No column specified - go to end of line
382                let line_end = if line_idx + 1 < rope.len_lines() {
383                    rope.line_to_byte(line_idx + 1).saturating_sub(1)
384                } else {
385                    rope.len_bytes()
386                };
387                Ok(Position::new(line_end))
388            }
389        } else {
390            // Default to start if nothing specified
391            Ok(Position::start())
392        }
393    }
394
395    /// Build position without rope (offset must be specified)
396    #[cfg(not(feature = "rope"))]
397    pub fn build(self) -> Result<Position> {
398        if let Some(offset) = self.offset {
399            Ok(Position::new(offset))
400        } else {
401            Err(EditorError::FeatureNotEnabled {
402                feature: "line/column position".to_string(),
403                required_feature: "rope".to_string(),
404            })
405        }
406    }
407}
408
409/// Selection represents a range with a direction
410///
411/// The anchor is where the selection started, and the cursor
412/// is where it currently ends. This allows tracking selection direction.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct Selection {
415    /// Where the selection started
416    pub anchor: Position,
417    /// Where the selection cursor is
418    pub cursor: Position,
419}
420
421impl Selection {
422    /// Create a new selection
423    #[must_use]
424    pub const fn new(anchor: Position, cursor: Position) -> Self {
425        Self { anchor, cursor }
426    }
427
428    /// Create an empty selection at position
429    #[must_use]
430    pub const fn empty(pos: Position) -> Self {
431        Self {
432            anchor: pos,
433            cursor: pos,
434        }
435    }
436
437    /// Check if selection is empty (no selected text)
438    #[must_use]
439    pub const fn is_empty(&self) -> bool {
440        self.anchor.offset == self.cursor.offset
441    }
442
443    /// Get the range covered by this selection (normalized)
444    #[must_use]
445    pub fn range(&self) -> Range {
446        Range::new(self.anchor, self.cursor)
447    }
448
449    /// Check if selection is reversed (cursor before anchor)
450    #[must_use]
451    pub const fn is_reversed(&self) -> bool {
452        self.cursor.offset < self.anchor.offset
453    }
454
455    /// Extend selection to include a position
456    #[must_use]
457    pub const fn extend_to(&self, pos: Position) -> Self {
458        Self {
459            anchor: self.anchor,
460            cursor: pos,
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn position_operations() {
471        let pos = Position::new(10);
472        assert_eq!(pos.advance(5).offset, 15);
473        assert_eq!(pos.retreat(5).offset, 5);
474        assert_eq!(pos.retreat(20).offset, 0); // saturating
475    }
476
477    #[test]
478    fn line_column_validation() {
479        assert!(LineColumn::new(0, 1).is_err());
480        assert!(LineColumn::new(1, 0).is_err());
481        assert!(LineColumn::new(1, 1).is_ok());
482    }
483
484    #[test]
485    fn range_normalization() {
486        let r = Range::new(Position::new(10), Position::new(5));
487        assert_eq!(r.start.offset, 5);
488        assert_eq!(r.end.offset, 10);
489    }
490
491    #[test]
492    fn range_operations() {
493        let r1 = Range::new(Position::new(5), Position::new(10));
494        let r2 = Range::new(Position::new(8), Position::new(15));
495
496        assert!(r1.overlaps(&r2));
497        assert_eq!(r1.union(&r2).start.offset, 5);
498        assert_eq!(r1.union(&r2).end.offset, 15);
499
500        let intersection = r1.intersection(&r2).unwrap();
501        assert_eq!(intersection.start.offset, 8);
502        assert_eq!(intersection.end.offset, 10);
503    }
504
505    #[test]
506    fn selection_direction() {
507        let sel = Selection::new(Position::new(10), Position::new(5));
508        assert!(sel.is_reversed());
509        assert_eq!(sel.range().start.offset, 5);
510        assert_eq!(sel.range().end.offset, 10);
511    }
512
513    #[test]
514    #[cfg(feature = "rope")]
515    fn position_builder_with_rope() {
516        let rope = ropey::Rope::from_str("Line 1\nLine 2\nLine 3");
517        let pos = PositionBuilder::new()
518            .line(2)
519            .column(1)
520            .build(&rope)
521            .unwrap();
522        assert_eq!(pos.offset, 7); // After "Line 1\n"
523    }
524
525    #[test]
526    #[cfg(not(feature = "rope"))]
527    fn position_builder_offset() {
528        let pos = PositionBuilder::new().offset(42).build().unwrap();
529        assert_eq!(pos.offset, 42);
530    }
531}