1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CursorSymbolKind {
9 Scalar,
11 Array,
13 Hash,
15 Subroutine,
17}
18
19pub 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
67pub 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#[inline]
96pub fn is_modchar(byte: u8) -> bool {
97 byte.is_ascii_alphanumeric() || byte == b':' || byte == b'_'
98}
99
100#[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
117pub 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 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 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
168pub 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 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 assert!(is_word_boundary(b"foo bar", 0, 3));
279 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 assert!(!is_word_boundary(b"foobar", 0, 3));
288 Ok(())
289 }
290
291 #[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 #[test]
324 fn extract_symbol_recognizes_scalar_sigil_before_name() -> Result<(), Box<dyn std::error::Error>>
325 {
326 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 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 let source = "my $x";
381 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 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 #[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 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 assert!(start <= 1);
433 Ok(())
434 }
435
436 #[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}