Skip to main content

tess/
marks.rs

1//! Pure helpers for marks (`m<x>` / `'<x>`) and previous-position swap (`^X^X`).
2//!
3//! Marks are session-local: a HashMap<char, (usize, usize)> owned by `app::run`.
4//! Previous-position is a single `Option<(usize, usize)>` slot also owned by `app::run`.
5
6use std::collections::HashMap;
7
8/// Result of a mark jump or previous-position swap. The caller (app
9/// dispatch) handles `OtherFile` by switching files before applying
10/// the line jump.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MarkTarget {
13    SameFile { line: usize },
14    OtherFile { file_index: usize, line: usize },
15}
16
17/// True iff `c` is a valid mark name (ASCII lowercase letter or ASCII digit).
18pub fn is_valid_mark_name(c: char) -> bool {
19    c.is_ascii_alphanumeric() && (c.is_ascii_lowercase() || c.is_ascii_digit())
20}
21
22/// Set mark `name` to `(file_index, top_line)`. Silently no-ops on invalid mark names.
23pub fn mark_set(
24    marks: &mut HashMap<char, (usize, usize)>,
25    name: char,
26    file_index: usize,
27    top_line: usize,
28) {
29    if is_valid_mark_name(name) {
30        marks.insert(name, (file_index, top_line));
31    }
32}
33
34/// Jump to mark `name`. Returns a `MarkTarget` describing the destination,
35/// or `None` if the mark is unknown / name is invalid. On a successful jump,
36/// records `(current_file_index, current_top)` into `previous_position`.
37///
38/// Note: clamping to line_count is the caller's responsibility, since the
39/// destination file's line_count isn't known until after a potential file switch.
40pub fn mark_jump(
41    marks: &HashMap<char, (usize, usize)>,
42    name: char,
43    current_file_index: usize,
44    previous_position: &mut Option<(usize, usize)>,
45    current_top: usize,
46) -> Option<MarkTarget> {
47    if !is_valid_mark_name(name) {
48        return None;
49    }
50    let (target_file, target_line) = *marks.get(&name)?;
51    *previous_position = Some((current_file_index, current_top));
52    if target_file == current_file_index {
53        Some(MarkTarget::SameFile { line: target_line })
54    } else {
55        Some(MarkTarget::OtherFile {
56            file_index: target_file,
57            line: target_line,
58        })
59    }
60}
61
62/// Swap current position with the previous-position slot. Returns the previous
63/// position as a `MarkTarget`, or `None` if no previous position has been recorded.
64pub fn jump_previous(
65    previous_position: &mut Option<(usize, usize)>,
66    current_file_index: usize,
67    current_top: usize,
68) -> Option<MarkTarget> {
69    let (prev_file, prev_line) = previous_position.take()?;
70    *previous_position = Some((current_file_index, current_top));
71    if prev_file == current_file_index {
72        Some(MarkTarget::SameFile { line: prev_line })
73    } else {
74        Some(MarkTarget::OtherFile {
75            file_index: prev_file,
76            line: prev_line,
77        })
78    }
79}
80
81/// Helper for big-jump dispatch sites: record the current position as the
82/// previous position before performing a discontinuous move.
83pub fn update_prev_position(
84    previous_position: &mut Option<(usize, usize)>,
85    current_file_index: usize,
86    current_top: usize,
87) {
88    *previous_position = Some((current_file_index, current_top));
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn is_valid_mark_name_accepts_lowercase_letters() {
97        for c in 'a'..='z' {
98            assert!(is_valid_mark_name(c), "{c} should be valid");
99        }
100    }
101
102    #[test]
103    fn is_valid_mark_name_accepts_digits() {
104        for c in '0'..='9' {
105            assert!(is_valid_mark_name(c), "{c} should be valid");
106        }
107    }
108
109    #[test]
110    fn is_valid_mark_name_rejects_uppercase_and_punctuation() {
111        assert!(!is_valid_mark_name('A'));
112        assert!(!is_valid_mark_name('Z'));
113        assert!(!is_valid_mark_name('!'));
114        assert!(!is_valid_mark_name(' '));
115        assert!(!is_valid_mark_name('\''));
116    }
117
118    #[test]
119    fn mark_set_records_top_line() {
120        let mut marks = HashMap::new();
121        mark_set(&mut marks, 'a', 0, 42);
122        assert_eq!(marks.get(&'a'), Some(&(0, 42)));
123    }
124
125    #[test]
126    fn mark_set_invalid_name_is_noop() {
127        let mut marks = HashMap::new();
128        mark_set(&mut marks, '!', 0, 42);
129        mark_set(&mut marks, 'A', 0, 42);
130        assert!(marks.is_empty());
131    }
132
133    #[test]
134    fn mark_set_overwrites_silently() {
135        let mut marks = HashMap::new();
136        mark_set(&mut marks, 'a', 0, 10);
137        mark_set(&mut marks, 'a', 0, 20);
138        assert_eq!(marks.get(&'a'), Some(&(0, 20)));
139    }
140
141    #[test]
142    fn mark_jump_known_mark_returns_value_and_updates_prev() {
143        let mut marks = HashMap::new();
144        marks.insert('a', (0, 50));
145        let mut prev = None;
146        let result = mark_jump(&marks, 'a', 0, &mut prev, 100);
147        assert_eq!(result, Some(MarkTarget::SameFile { line: 50 }));
148        assert_eq!(prev, Some((0, 100)));
149    }
150
151    #[test]
152    fn mark_jump_unknown_mark_returns_none_no_prev_update() {
153        let marks = HashMap::new();
154        let mut prev = None;
155        let result = mark_jump(&marks, 'q', 0, &mut prev, 100);
156        assert_eq!(result, None);
157        assert_eq!(prev, None);
158    }
159
160    #[test]
161    fn mark_jump_invalid_name_returns_none() {
162        let mut marks = HashMap::new();
163        marks.insert('!', (0, 50));
164        let mut prev = None;
165        let result = mark_jump(&marks, '!', 0, &mut prev, 100);
166        assert_eq!(result, None);
167    }
168
169    #[test]
170    fn jump_previous_first_call_returns_none() {
171        let mut prev = None;
172        let result = jump_previous(&mut prev, 0, 50);
173        assert_eq!(result, None);
174        assert_eq!(prev, None);
175    }
176
177    #[test]
178    fn jump_previous_swaps_and_keeps_history() {
179        let mut prev = Some((0, 10));
180        let result = jump_previous(&mut prev, 0, 50);
181        assert_eq!(result, Some(MarkTarget::SameFile { line: 10 }));
182        assert_eq!(prev, Some((0, 50)));
183    }
184
185    #[test]
186    fn jump_previous_repeated_oscillates() {
187        let mut prev = Some((0, 10));
188        let r1 = jump_previous(&mut prev, 0, 50);
189        assert_eq!(r1, Some(MarkTarget::SameFile { line: 10 }));
190        let r2 = jump_previous(&mut prev, 0, 10);
191        assert_eq!(r2, Some(MarkTarget::SameFile { line: 50 }));
192    }
193
194    #[test]
195    fn update_prev_position_overwrites_slot() {
196        let mut prev = Some((0, 7));
197        update_prev_position(&mut prev, 0, 42);
198        assert_eq!(prev, Some((0, 42)));
199    }
200
201    #[test]
202    fn mark_jump_returns_other_file_target_when_set_in_different_file() {
203        let mut marks = HashMap::new();
204        marks.insert('a', (1, 200));
205        let mut prev = None;
206        let result = mark_jump(&marks, 'a', 0, &mut prev, 50);
207        assert_eq!(result, Some(MarkTarget::OtherFile { file_index: 1, line: 200 }));
208        assert_eq!(prev, Some((0, 50)));
209    }
210
211    #[test]
212    fn jump_previous_returns_other_file_when_previous_was_in_another_file() {
213        let mut prev = Some((1, 75));
214        let result = jump_previous(&mut prev, 0, 25);
215        assert_eq!(result, Some(MarkTarget::OtherFile { file_index: 1, line: 75 }));
216        assert_eq!(prev, Some((0, 25)));
217    }
218}