Skip to main content

par_term/
smart_selection.rs

1//! Smart selection module for pattern-based text selection.
2//!
3//! This module provides intelligent double-click selection based on regex patterns.
4//! When the user double-clicks, the system first tries smart selection rules (sorted
5//! by precision, highest first). If a pattern matches at the cursor position, that
6//! text is selected. Otherwise, it falls back to word boundary selection.
7
8use crate::config::SmartSelectionRule;
9use regex::Regex;
10
11/// Compiled smart selection rules with cached regex patterns
12pub struct SmartSelectionMatcher {
13    /// Compiled rules sorted by precision (highest first)
14    rules: Vec<CompiledRule>,
15}
16
17struct CompiledRule {
18    name: String,
19    regex: Regex,
20    precision: f64,
21}
22
23impl SmartSelectionMatcher {
24    /// Create a new matcher from a list of smart selection rules
25    pub fn new(rules: &[SmartSelectionRule]) -> Self {
26        let mut compiled: Vec<CompiledRule> = rules
27            .iter()
28            .filter(|r| r.enabled)
29            .filter_map(|r| match Regex::new(&r.regex) {
30                Ok(regex) => Some(CompiledRule {
31                    name: r.name.clone(),
32                    regex,
33                    precision: r.precision.value(),
34                }),
35                Err(e) => {
36                    log::warn!(
37                        "Failed to compile smart selection regex '{}': {}",
38                        r.name,
39                        e
40                    );
41                    None
42                }
43            })
44            .collect();
45
46        // Sort by precision descending (highest first)
47        compiled.sort_by(|a, b| {
48            b.precision
49                .partial_cmp(&a.precision)
50                .unwrap_or(std::cmp::Ordering::Equal)
51        });
52
53        Self { rules: compiled }
54    }
55
56    /// Try to find a pattern match at the given character position in the line.
57    ///
58    /// Returns the start and end column indices (inclusive) if a match is found,
59    /// or None if no pattern matches at this position.
60    ///
61    /// # Arguments
62    /// * `line` - The full text of the line
63    /// * `col` - The column position (character index) where the cursor is
64    pub fn find_match_at(&self, line: &str, col: usize) -> Option<(usize, usize)> {
65        // Convert col to byte offset for regex matching
66        let byte_offset = char_to_byte_offset(line, col)?;
67
68        for rule in &self.rules {
69            // Find all matches in the line
70            for mat in rule.regex.find_iter(line) {
71                let match_start_byte = mat.start();
72                let match_end_byte = mat.end();
73
74                // Check if the cursor position is within this match
75                if byte_offset >= match_start_byte && byte_offset < match_end_byte {
76                    // Convert byte offsets back to character offsets
77                    let start_col = byte_to_char_offset(line, match_start_byte)?;
78                    let end_col = byte_to_char_offset(line, match_end_byte)?.saturating_sub(1);
79
80                    log::trace!(
81                        "smart_selection: rule '{}' matched col {} → [{}, {}]",
82                        rule.name,
83                        col,
84                        start_col,
85                        end_col,
86                    );
87                    return Some((start_col, end_col));
88                }
89            }
90        }
91
92        None
93    }
94}
95
96/// Convert a character offset to a byte offset in a UTF-8 string
97fn char_to_byte_offset(s: &str, char_offset: usize) -> Option<usize> {
98    s.char_indices()
99        .nth(char_offset)
100        .map(|(byte_idx, _)| byte_idx)
101        .or_else(|| {
102            // If char_offset is at or past the end, return the string length
103            if char_offset >= s.chars().count() {
104                Some(s.len())
105            } else {
106                None
107            }
108        })
109}
110
111/// Convert a byte offset to a character offset in a UTF-8 string
112fn byte_to_char_offset(s: &str, byte_offset: usize) -> Option<usize> {
113    if byte_offset > s.len() {
114        return None;
115    }
116    Some(s[..byte_offset].chars().count())
117}
118
119/// Check if a character should be considered part of a word.
120///
121/// A character is part of a word if:
122/// - It is alphanumeric (a-z, A-Z, 0-9)
123/// - It is in the user-defined word_characters set
124///
125/// Note: Unlike some terminals, underscore is NOT hardcoded as a word character.
126/// It is included in the default word_characters setting (`/-+\~_.`) but can be
127/// removed by the user for full control over word selection behavior.
128pub fn is_word_char(ch: char, word_characters: &str) -> bool {
129    ch.is_alphanumeric() || is_combining_mark(ch) || word_characters.contains(ch)
130}
131
132/// Whether `ch` combines with the preceding character rather than standing alone.
133///
134/// Decomposed text puts these after the base letter, so treating them as
135/// non-word characters splits `café` (typed as `cafe` + U+0301) after `cafe`
136/// while the precomposed spelling selects correctly.
137fn is_combining_mark(ch: char) -> bool {
138    matches!(ch as u32,
139        0x0300..=0x036F      // combining diacritical marks
140        | 0x1AB0..=0x1AFF    // extended
141        | 0x1DC0..=0x1DFF    // supplement
142        | 0x20D0..=0x20F0    // for symbols
143        | 0xFE20..=0xFE2F    // half marks
144        | 0x200D             // zero-width joiner
145        | 0xFE0E | 0xFE0F    // variation selectors
146        | 0x1F3FB..=0x1F3FF  // emoji skin-tone modifiers
147    )
148}
149
150/// Find word boundaries at the given position using configurable word characters.
151///
152/// Returns (start_col, end_col) as inclusive indices.
153pub fn find_word_boundaries(line: &str, col: usize, word_characters: &str) -> (usize, usize) {
154    let chars: Vec<char> = line.chars().collect();
155
156    if chars.is_empty() || col >= chars.len() {
157        return (col, col);
158    }
159
160    let mut start_col = col;
161    let mut end_col = col;
162
163    // Expand left
164    while start_col > 0 && is_word_char(chars[start_col - 1], word_characters) {
165        start_col -= 1;
166    }
167
168    // Make sure the clicked position is a word character, otherwise return single char
169    if !is_word_char(chars[col], word_characters) {
170        return (col, col);
171    }
172
173    // Expand right
174    while end_col < chars.len() - 1 && is_word_char(chars[end_col + 1], word_characters) {
175        end_col += 1;
176    }
177
178    (start_col, end_col)
179}
180
181/// Cache for compiled smart selection matchers to avoid recompilation
182pub struct SmartSelectionCache {
183    /// Cached matcher (recreated when rules change)
184    matcher: Option<SmartSelectionMatcher>,
185    /// Hash of the rules used to create the cached matcher
186    rules_hash: u64,
187}
188
189impl Default for SmartSelectionCache {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl SmartSelectionCache {
196    pub fn new() -> Self {
197        Self {
198            matcher: None,
199            rules_hash: 0,
200        }
201    }
202
203    /// Get or create a matcher for the given rules
204    pub fn get_matcher(&mut self, rules: &[SmartSelectionRule]) -> &SmartSelectionMatcher {
205        let hash = hash_rules(rules);
206
207        if self.rules_hash != hash || self.matcher.is_none() {
208            self.matcher = Some(SmartSelectionMatcher::new(rules));
209            self.rules_hash = hash;
210        }
211
212        self.matcher
213            .as_ref()
214            .expect("matcher was just set to Some above if it was None")
215    }
216}
217
218/// Simple hash for rules to detect changes
219fn hash_rules(rules: &[SmartSelectionRule]) -> u64 {
220    use std::collections::hash_map::DefaultHasher;
221    use std::hash::{Hash, Hasher};
222
223    let mut hasher = DefaultHasher::new();
224    for rule in rules {
225        rule.name.hash(&mut hasher);
226        rule.regex.hash(&mut hasher);
227        rule.enabled.hash(&mut hasher);
228        // Use precision ordinal for hashing
229        std::mem::discriminant(&rule.precision).hash(&mut hasher);
230    }
231    hasher.finish()
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::config::{SmartSelectionPrecision, SmartSelectionRule};
238
239    fn test_rules() -> Vec<SmartSelectionRule> {
240        vec![
241            SmartSelectionRule::new(
242                "HTTP URL",
243                r"https?://[^\s]+",
244                SmartSelectionPrecision::VeryHigh,
245            ),
246            SmartSelectionRule::new(
247                "Email",
248                r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
249                SmartSelectionPrecision::High,
250            ),
251            SmartSelectionRule::new(
252                "File path",
253                r"~?/?(?:[a-zA-Z0-9._-]+/)+[a-zA-Z0-9._-]+/?",
254                SmartSelectionPrecision::Normal,
255            ),
256        ]
257    }
258
259    #[test]
260    fn test_find_url_match() {
261        let matcher = SmartSelectionMatcher::new(&test_rules());
262        let line = "Check out https://example.com/path for more info";
263
264        // Click on 'h' in https
265        let result = matcher.find_match_at(line, 10);
266        assert_eq!(result, Some((10, 33)));
267
268        // Click on 'e' in example
269        let result = matcher.find_match_at(line, 18);
270        assert_eq!(result, Some((10, 33)));
271
272        // Click on 'C' in Check (not in URL)
273        let result = matcher.find_match_at(line, 0);
274        assert_eq!(result, None);
275    }
276
277    #[test]
278    fn test_find_email_match() {
279        let matcher = SmartSelectionMatcher::new(&test_rules());
280        let line = "Contact user@example.com for help";
281
282        // Click on 'u' in user
283        let result = matcher.find_match_at(line, 8);
284        assert_eq!(result, Some((8, 23)));
285
286        // Click on '@'
287        let result = matcher.find_match_at(line, 12);
288        assert_eq!(result, Some((8, 23)));
289    }
290
291    #[test]
292    fn test_find_path_match() {
293        let matcher = SmartSelectionMatcher::new(&test_rules());
294        let line = "Edit ~/Documents/file.txt and save";
295
296        // Click on 'D' in Documents
297        let result = matcher.find_match_at(line, 7);
298        assert_eq!(result, Some((5, 24)));
299    }
300
301    #[test]
302    fn test_word_boundaries_default() {
303        let line = "hello_world test-case foo.bar";
304        let word_chars = "/-+\\~_.";
305
306        // Click on 'w' in world
307        let (start, end) = find_word_boundaries(line, 6, word_chars);
308        assert_eq!(
309            &line.chars().collect::<Vec<_>>()[start..=end]
310                .iter()
311                .collect::<String>(),
312            "hello_world"
313        );
314
315        // Click on 't' in test
316        let (start, end) = find_word_boundaries(line, 12, word_chars);
317        assert_eq!(
318            &line.chars().collect::<Vec<_>>()[start..=end]
319                .iter()
320                .collect::<String>(),
321            "test-case"
322        );
323    }
324
325    #[test]
326    fn test_word_boundaries_empty_config() {
327        let line = "hello_world test-case";
328        let word_chars = "";
329
330        // With empty word_chars, only alphanumeric characters are word chars
331        // underscore is NOT hardcoded - it must be in word_characters to be included
332        // Click on 'w' in world - should stop at underscore
333        let (start, end) = find_word_boundaries(line, 6, word_chars);
334        assert_eq!(
335            &line.chars().collect::<Vec<_>>()[start..=end]
336                .iter()
337                .collect::<String>(),
338            "world"
339        );
340
341        // Click on 'h' in hello - should stop at underscore
342        let (start, end) = find_word_boundaries(line, 0, word_chars);
343        assert_eq!(
344            &line.chars().collect::<Vec<_>>()[start..=end]
345                .iter()
346                .collect::<String>(),
347            "hello"
348        );
349
350        // Click on 't' in test - should stop at hyphen
351        let (start, end) = find_word_boundaries(line, 12, word_chars);
352        assert_eq!(
353            &line.chars().collect::<Vec<_>>()[start..=end]
354                .iter()
355                .collect::<String>(),
356            "test"
357        );
358    }
359
360    #[test]
361    fn test_is_word_char() {
362        let word_chars = "/-+\\~_.";
363
364        assert!(is_word_char('a', word_chars));
365        assert!(is_word_char('Z', word_chars));
366        assert!(is_word_char('5', word_chars));
367        assert!(is_word_char('_', word_chars));
368        assert!(is_word_char('-', word_chars));
369        assert!(is_word_char('/', word_chars));
370        assert!(is_word_char('.', word_chars));
371
372        assert!(!is_word_char(' ', word_chars));
373        assert!(!is_word_char('@', word_chars));
374        assert!(!is_word_char('!', word_chars));
375    }
376
377    #[test]
378    fn test_unicode_handling() {
379        let matcher = SmartSelectionMatcher::new(&test_rules());
380        let line = "日本語 https://example.com 中文";
381
382        // The URL starts at character position 4 (after "日本語 ")
383        // Click on URL after unicode - verify the URL starts at position 4
384        let result = matcher.find_match_at(line, 4);
385        // The URL "https://example.com" is 19 characters (4+19-1 = 22 for inclusive end)
386        assert_eq!(result, Some((4, 22)));
387    }
388
389    #[test]
390    fn test_disabled_rule() {
391        let mut rules = test_rules();
392        rules[0].enabled = false; // Disable URL rule
393
394        let matcher = SmartSelectionMatcher::new(&rules);
395        let line = "Check out https://example.com for more info";
396
397        // URL rule is disabled, so no match
398        let result = matcher.find_match_at(line, 10);
399        assert_eq!(result, None);
400    }
401
402    #[test]
403    fn test_precision_ordering() {
404        // Create rules where a lower precision rule would match a broader pattern
405        let rules = vec![
406            SmartSelectionRule::new("Whitespace-bounded", r"\S+", SmartSelectionPrecision::Low),
407            SmartSelectionRule::new(
408                "Email",
409                r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
410                SmartSelectionPrecision::High,
411            ),
412        ];
413
414        let matcher = SmartSelectionMatcher::new(&rules);
415        let line = "Contact user@example.com for help";
416
417        // Should match email (higher precision) not the whole word
418        let result = matcher.find_match_at(line, 12);
419        assert_eq!(result, Some((8, 23)));
420    }
421}