Skip to main content

perl_symbol/cursor/
mod.rs

1//! Cursor-oriented symbol extraction for Perl source text.
2//!
3//! This module focuses on a single responsibility: extracting symbol names
4//! and ranges around a cursor position.
5
6/// Symbol sigil categories used for cursor extraction.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CursorSymbolKind {
9    /// Scalar variable (`$foo`)
10    Scalar,
11    /// Array variable (`@foo`)
12    Array,
13    /// Hash variable (`%foo`)
14    Hash,
15    /// Subroutine reference (`&foo`)
16    Subroutine,
17}
18
19/// Extract a symbol and its kind from `source` at `position`.
20pub fn extract_symbol_from_source(
21    position: usize,
22    source: &str,
23) -> Option<(String, CursorSymbolKind)> {
24    let chars: Vec<char> = source.chars().collect();
25    if position >= chars.len() {
26        return None;
27    }
28
29    let (sigil, name_start) = if position > 0 {
30        match chars.get(position - 1) {
31            Some('$') => (Some(CursorSymbolKind::Scalar), position),
32            Some('@') => (Some(CursorSymbolKind::Array), position),
33            Some('%') => (Some(CursorSymbolKind::Hash), position),
34            Some('&') => (Some(CursorSymbolKind::Subroutine), position),
35            _ => (None, position),
36        }
37    } else {
38        (None, position)
39    };
40
41    let (sigil, name_start) = if sigil.is_none() && position < chars.len() {
42        match chars[position] {
43            '$' => (Some(CursorSymbolKind::Scalar), position + 1),
44            '@' => (Some(CursorSymbolKind::Array), position + 1),
45            '%' => (Some(CursorSymbolKind::Hash), position + 1),
46            '&' => (Some(CursorSymbolKind::Subroutine), position + 1),
47            _ => (sigil, name_start),
48        }
49    } else {
50        (sigil, name_start)
51    };
52
53    let mut end = name_start;
54    while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') {
55        end += 1;
56    }
57
58    if end > name_start {
59        let name: String = chars[name_start..end].iter().collect();
60        let kind = sigil.unwrap_or(CursorSymbolKind::Subroutine);
61        Some((name, kind))
62    } else {
63        None
64    }
65}
66
67/// Get symbol range at `position`, including a leading sigil when present.
68pub fn get_symbol_range_at_position(position: usize, source: &str) -> Option<(usize, usize)> {
69    let chars: Vec<char> = source.chars().collect();
70    if position >= chars.len() {
71        return None;
72    }
73
74    let mut start = position;
75    if start > 0 && matches!(chars[start - 1], '$' | '@' | '%' | '&') {
76        start -= 1;
77    }
78
79    let mut end = position;
80    while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') {
81        end += 1;
82    }
83
84    while start < position
85        && start < chars.len()
86        && (chars[start].is_alphanumeric() || chars[start] == '_')
87    {
88        start -= 1;
89    }
90
91    Some((start, end))
92}
93
94/// Return true when `byte` is a module/name character (`[A-Za-z0-9_:]`).
95#[inline]
96pub fn is_modchar(byte: u8) -> bool {
97    byte.is_ascii_alphanumeric() || byte == b':' || byte == b'_'
98}
99
100/// Convert a UTF-16 column index to a byte offset for a single line.
101#[inline]
102pub fn byte_offset_utf16(line_text: &str, col_utf16: usize) -> usize {
103    let mut units = 0;
104    for (i, ch) in line_text.char_indices() {
105        if units >= col_utf16 {
106            return i;
107        }
108        let ch_units = if ch as u32 >= 0x10000 { 2 } else { 1 };
109        units += ch_units;
110        if units > col_utf16 {
111            return i;
112        }
113    }
114    line_text.len()
115}
116
117/// Extract the module/symbol token under the cursor (UTF-16 aware).
118pub fn token_under_cursor(text: &str, line: usize, col_utf16: usize) -> Option<String> {
119    let line_text = text.lines().nth(line)?;
120    let byte_pos = byte_offset_utf16(line_text, col_utf16);
121    let bytes = line_text.as_bytes();
122
123    if bytes.is_empty() {
124        return None;
125    }
126
127    // Prefer the character at the cursor. If the cursor is positioned at the
128    // end of a token (or line), snap to the previous byte when that byte is
129    // part of an identifier/module token or sigil.
130    let anchor = if byte_pos < bytes.len() { byte_pos } else { bytes.len().saturating_sub(1) };
131
132    let cursor =
133        if is_modchar(bytes[anchor]) || matches!(bytes[anchor], b'$' | b'@' | b'%' | b'&' | b'*') {
134            anchor
135        } else if anchor > 0 && is_modchar(bytes[anchor - 1]) {
136            anchor - 1
137        } else {
138            return None;
139        };
140
141    let mut start = cursor;
142    let mut end = cursor;
143
144    while start > 0 && is_modchar(bytes[start - 1]) {
145        start -= 1;
146    }
147    if start > 0 && matches!(bytes[start - 1], b'$' | b'@' | b'%' | b'&' | b'*') {
148        start -= 1;
149    }
150
151    // When the cursor is directly on a sigil character, step `end` past it so
152    // the following identifier walk can collect the name (`$foo` → `$foo`, not empty).
153    if end < bytes.len() && matches!(bytes[end], b'$' | b'@' | b'%' | b'&' | b'*') {
154        end += 1;
155    }
156
157    while end < bytes.len() && is_modchar(bytes[end]) {
158        end += 1;
159    }
160
161    if end == start {
162        return None;
163    }
164
165    Some(line_text[start..end].to_string())
166}
167
168/// Check if a match at `pos..pos+word_len` is bounded by non-word chars.
169pub fn is_word_boundary(text: &[u8], pos: usize, word_len: usize) -> bool {
170    if pos > 0 && is_modchar(text[pos - 1]) {
171        return false;
172    }
173
174    let end_pos = pos + word_len;
175    if end_pos < text.len() && is_modchar(text[end_pos]) {
176        return false;
177    }
178
179    true
180}
181
182#[cfg(test)]
183mod tests {
184    use super::{
185        CursorSymbolKind, byte_offset_utf16, extract_symbol_from_source,
186        get_symbol_range_at_position, is_modchar, is_word_boundary, token_under_cursor,
187    };
188
189    #[test]
190    fn token_under_cursor_extracts_perl_module_token() {
191        let text = "use Demo::Worker;\n";
192        assert_eq!(token_under_cursor(text, 0, 8), Some("Demo::Worker".to_string()));
193    }
194
195    #[test]
196    fn token_under_cursor_supports_sigils() {
197        let text = "my $value = 1;\n";
198        assert_eq!(token_under_cursor(text, 0, 5), Some("$value".to_string()));
199    }
200
201    #[test]
202    fn token_under_cursor_supports_cursor_after_symbol() {
203        let text = "use Demo::Worker\n";
204        assert_eq!(token_under_cursor(text, 0, 16), Some("Demo::Worker".to_string()));
205    }
206
207    #[test]
208    fn token_under_cursor_supports_cursor_on_sigil() {
209        // Cursor directly ON the `$` sigil (col 3) must still extract `$value`.
210        let text = "my $value = 1;\n";
211        assert_eq!(token_under_cursor(text, 0, 3), Some("$value".to_string()));
212    }
213
214    #[test]
215    fn token_under_cursor_returns_none_on_punctuation() {
216        let text = "my $value = 1;\n";
217        assert_eq!(token_under_cursor(text, 0, 11), None);
218    }
219
220    #[test]
221    fn token_under_cursor_returns_none_for_out_of_range_line()
222    -> Result<(), Box<dyn std::error::Error>> {
223        let text = "my $value = 1;\n";
224        assert_eq!(token_under_cursor(text, 99, 0), None);
225        Ok(())
226    }
227
228    #[test]
229    fn token_under_cursor_returns_none_for_empty_line() -> Result<(), Box<dyn std::error::Error>> {
230        let text = "\n";
231        assert_eq!(token_under_cursor(text, 0, 0), None);
232        Ok(())
233    }
234
235    #[test]
236    fn token_under_cursor_handles_array_and_hash_sigils() -> Result<(), Box<dyn std::error::Error>>
237    {
238        let text = "push @items, %opts;\n";
239        assert_eq!(token_under_cursor(text, 0, 5), Some("@items".to_string()));
240        assert_eq!(token_under_cursor(text, 0, 13), Some("%opts".to_string()));
241        Ok(())
242    }
243
244    #[test]
245    fn utf16_col_to_byte_offset_handles_surrogate_pairs() {
246        let line = "A😀B";
247        assert_eq!(byte_offset_utf16(line, 0), 0);
248        assert_eq!(byte_offset_utf16(line, 1), 1);
249        assert_eq!(byte_offset_utf16(line, 2), 1);
250        assert_eq!(byte_offset_utf16(line, 3), 5);
251        assert_eq!(byte_offset_utf16(line, 4), 6);
252    }
253
254    #[test]
255    fn byte_offset_utf16_past_end_returns_len() -> Result<(), Box<dyn std::error::Error>> {
256        let line = "abc";
257        assert_eq!(byte_offset_utf16(line, 100), 3);
258        Ok(())
259    }
260
261    #[test]
262    fn byte_offset_utf16_empty_string_returns_zero() -> Result<(), Box<dyn std::error::Error>> {
263        assert_eq!(byte_offset_utf16("", 0), 0);
264        assert_eq!(byte_offset_utf16("", 5), 0);
265        Ok(())
266    }
267
268    #[test]
269    fn word_boundary_detects_embedded_word() {
270        let text = b"fooDemo::Workerbar";
271        assert!(!is_word_boundary(text, 3, "Demo::Worker".len()));
272        assert!(is_word_boundary(b" Demo::Worker ", 1, "Demo::Worker".len()));
273    }
274
275    #[test]
276    fn word_boundary_at_start_and_end_of_text() -> Result<(), Box<dyn std::error::Error>> {
277        // Match at position 0 — no preceding character, boundary is at start.
278        assert!(is_word_boundary(b"foo bar", 0, 3));
279        // Match at very end — end position equals text length.
280        assert!(is_word_boundary(b"hello foo", 6, 3));
281        Ok(())
282    }
283
284    #[test]
285    fn word_boundary_false_when_trailing_modchar() -> Result<(), Box<dyn std::error::Error>> {
286        // "foo" at pos 0 in "foobar" is not a boundary because 'b' follows.
287        assert!(!is_word_boundary(b"foobar", 0, 3));
288        Ok(())
289    }
290
291    // ── is_modchar ────────────────────────────────────────────────────────────
292
293    #[test]
294    fn is_modchar_accepts_alphanumeric_and_colon_and_underscore()
295    -> Result<(), Box<dyn std::error::Error>> {
296        assert!(is_modchar(b'a'));
297        assert!(is_modchar(b'Z'));
298        assert!(is_modchar(b'0'));
299        assert!(is_modchar(b'9'));
300        assert!(is_modchar(b'_'));
301        assert!(is_modchar(b':'));
302        Ok(())
303    }
304
305    #[test]
306    fn is_modchar_rejects_sigils_spaces_and_punctuation() -> Result<(), Box<dyn std::error::Error>>
307    {
308        assert!(!is_modchar(b'$'));
309        assert!(!is_modchar(b'@'));
310        assert!(!is_modchar(b'%'));
311        assert!(!is_modchar(b'&'));
312        assert!(!is_modchar(b' '));
313        assert!(!is_modchar(b'\n'));
314        assert!(!is_modchar(b'-'));
315        assert!(!is_modchar(b'.'));
316        assert!(!is_modchar(b'('));
317        assert!(!is_modchar(b')'));
318        Ok(())
319    }
320
321    // ── extract_symbol_from_source ────────────────────────────────────────────
322
323    #[test]
324    fn extract_symbol_recognizes_scalar_sigil_before_name() -> Result<(), Box<dyn std::error::Error>>
325    {
326        // Cursor on 'v' of "$value" — sigil is the preceding char.
327        let source = "$value";
328        let result = extract_symbol_from_source(1, source);
329        assert_eq!(result, Some(("value".to_string(), CursorSymbolKind::Scalar)));
330        Ok(())
331    }
332
333    #[test]
334    fn extract_symbol_recognizes_array_sigil_before_name() -> Result<(), Box<dyn std::error::Error>>
335    {
336        let source = "@items";
337        let result = extract_symbol_from_source(1, source);
338        assert_eq!(result, Some(("items".to_string(), CursorSymbolKind::Array)));
339        Ok(())
340    }
341
342    #[test]
343    fn extract_symbol_recognizes_hash_sigil_before_name() -> Result<(), Box<dyn std::error::Error>>
344    {
345        let source = "%opts";
346        let result = extract_symbol_from_source(1, source);
347        assert_eq!(result, Some(("opts".to_string(), CursorSymbolKind::Hash)));
348        Ok(())
349    }
350
351    #[test]
352    fn extract_symbol_recognizes_subroutine_sigil_before_name()
353    -> Result<(), Box<dyn std::error::Error>> {
354        let source = "&callback";
355        let result = extract_symbol_from_source(1, source);
356        assert_eq!(result, Some(("callback".to_string(), CursorSymbolKind::Subroutine)));
357        Ok(())
358    }
359
360    #[test]
361    fn extract_symbol_cursor_on_sigil_itself() -> Result<(), Box<dyn std::error::Error>> {
362        // Cursor at position 0, which is the '$' sigil character.
363        let source = "$foo";
364        let result = extract_symbol_from_source(0, source);
365        assert_eq!(result, Some(("foo".to_string(), CursorSymbolKind::Scalar)));
366        Ok(())
367    }
368
369    #[test]
370    fn extract_symbol_cursor_past_end_returns_none() -> Result<(), Box<dyn std::error::Error>> {
371        let source = "$foo";
372        assert_eq!(extract_symbol_from_source(10, source), None);
373        Ok(())
374    }
375
376    #[test]
377    fn extract_symbol_on_non_name_character_returns_none() -> Result<(), Box<dyn std::error::Error>>
378    {
379        // Space at position 2 — not alphanumeric/underscore, no sigil context.
380        let source = "my $x";
381        // Position 2 is ' ', no sigil before it and not alphanumeric.
382        assert_eq!(extract_symbol_from_source(2, source), None);
383        Ok(())
384    }
385
386    #[test]
387    fn extract_symbol_no_sigil_defaults_to_subroutine() -> Result<(), Box<dyn std::error::Error>> {
388        // A bare identifier with no sigil context defaults to Subroutine kind.
389        let source = "greet";
390        let result = extract_symbol_from_source(0, source);
391        assert_eq!(result, Some(("greet".to_string(), CursorSymbolKind::Subroutine)));
392        Ok(())
393    }
394
395    #[test]
396    fn extract_symbol_handles_underscore_and_digits_in_name()
397    -> Result<(), Box<dyn std::error::Error>> {
398        let source = "$my_var2";
399        let result = extract_symbol_from_source(1, source);
400        assert_eq!(result, Some(("my_var2".to_string(), CursorSymbolKind::Scalar)));
401        Ok(())
402    }
403
404    // ── get_symbol_range_at_position ──────────────────────────────────────────
405
406    #[test]
407    fn symbol_range_past_end_returns_none() -> Result<(), Box<dyn std::error::Error>> {
408        assert_eq!(get_symbol_range_at_position(100, "foo"), None);
409        Ok(())
410    }
411
412    #[test]
413    fn symbol_range_includes_preceding_sigil() -> Result<(), Box<dyn std::error::Error>> {
414        // Position 1 is 'f' in "$foo"; the range should include the '$' at 0.
415        let source = "$foo";
416        let range = get_symbol_range_at_position(1, source);
417        assert!(range.is_some());
418        let (start, end) = range.unwrap_or((0, 0));
419        assert_eq!(start, 0, "range must include the leading sigil");
420        assert_eq!(end, 4);
421        Ok(())
422    }
423
424    #[test]
425    fn symbol_range_on_bare_identifier_no_sigil() -> Result<(), Box<dyn std::error::Error>> {
426        let source = "greet";
427        let range = get_symbol_range_at_position(0, source);
428        assert!(range.is_some());
429        let (start, end) = range.unwrap_or((0, 0));
430        assert_eq!(end, 5);
431        // start ≤ 0 (could be 0 when no sigil precedes)
432        assert!(start <= 1);
433        Ok(())
434    }
435
436    // ── CursorSymbolKind derive traits ────────────────────────────────────────
437
438    #[test]
439    fn cursor_symbol_kind_derives_debug() -> Result<(), Box<dyn std::error::Error>> {
440        let formatted = format!("{:?}", CursorSymbolKind::Scalar);
441        assert_eq!(formatted, "Scalar");
442        Ok(())
443    }
444
445    #[test]
446    fn cursor_symbol_kind_derives_clone_and_copy() -> Result<(), Box<dyn std::error::Error>> {
447        fn assert_clone<T: Clone>() {}
448        fn assert_copy<T: Copy>() {}
449
450        assert_clone::<CursorSymbolKind>();
451        assert_copy::<CursorSymbolKind>();
452
453        let original = CursorSymbolKind::Array;
454        let copied: CursorSymbolKind = original;
455        assert_eq!(copied, CursorSymbolKind::Array);
456        Ok(())
457    }
458
459    #[test]
460    fn cursor_symbol_kind_derives_partial_eq() -> Result<(), Box<dyn std::error::Error>> {
461        assert_eq!(CursorSymbolKind::Scalar, CursorSymbolKind::Scalar);
462        assert_ne!(CursorSymbolKind::Scalar, CursorSymbolKind::Array);
463        assert_ne!(CursorSymbolKind::Hash, CursorSymbolKind::Subroutine);
464        Ok(())
465    }
466
467    #[test]
468    fn cursor_symbol_kind_all_variants_are_distinct() -> Result<(), Box<dyn std::error::Error>> {
469        let variants = [
470            CursorSymbolKind::Scalar,
471            CursorSymbolKind::Array,
472            CursorSymbolKind::Hash,
473            CursorSymbolKind::Subroutine,
474        ];
475        for (i, a) in variants.iter().enumerate() {
476            for (j, b) in variants.iter().enumerate() {
477                if i == j {
478                    assert_eq!(a, b);
479                } else {
480                    assert_ne!(a, b);
481                }
482            }
483        }
484        Ok(())
485    }
486}