1pub mod command_timeout;
7pub mod uri;
8
9pub use command_timeout::run_command_with_timeout;
10
11use std::io;
12use std::path::Path;
13
14use lsp_types::Position;
15use perl_module::reference::extract_module_reference as extract_module_reference_at_cursor;
16use perl_module::reference::extract_module_reference_extended as extract_module_reference_extended_at_cursor;
17
18pub use perl_parser::util::{code_slice, find_data_marker_byte_lexed};
20pub use perl_symbol::cursor::{
21 byte_offset_utf16, is_modchar, is_word_boundary, token_under_cursor,
22};
23
24#[inline]
31pub fn first_char(s: &str) -> Option<char> {
32 s.chars().next()
33}
34
35#[inline]
38pub fn nth_char(s: &str, n: usize) -> Option<char> {
39 s.chars().nth(n)
40}
41
42#[inline]
45pub fn first_char_string(s: &str) -> Option<String> {
46 s.chars().next().map(|c| c.to_string())
47}
48
49#[inline]
52pub fn first_char_is<F: FnOnce(char) -> bool>(s: &str, predicate: F) -> bool {
53 s.chars().next().is_some_and(predicate)
54}
55
56#[inline]
59pub fn nth_char_is<F: FnOnce(char) -> bool>(s: &str, n: usize, predicate: F) -> bool {
60 s.chars().nth(n).is_some_and(predicate)
61}
62
63pub fn escape_markdown_text(text: &str) -> String {
79 let mut result = String::with_capacity(text.len());
80 for ch in text.chars() {
81 match ch {
82 '`' | '#' | '*' | '_' | '[' | ']' | '\\' | '|' | '-' => {
83 result.push('\\');
84 result.push(ch);
85 }
86 _ => result.push(ch),
87 }
88 }
89 result
90}
91
92pub fn decode_text_bytes(bytes: &[u8]) -> String {
101 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
102 if let Ok(utf8) = std::str::from_utf8(&bytes[3..]) {
103 return utf8.to_string();
104 }
105 }
106
107 if bytes.starts_with(&[0xFF, 0xFE]) {
108 if let Some(decoded) = decode_utf16_lossy(&bytes[2..], true) {
109 return decoded;
110 }
111 }
113
114 if bytes.starts_with(&[0xFE, 0xFF]) {
115 if let Some(decoded) = decode_utf16_lossy(&bytes[2..], false) {
116 return decoded;
117 }
118 }
120
121 match std::str::from_utf8(bytes) {
122 Ok(utf8) => utf8.to_string(),
123 Err(_) => bytes.iter().map(|byte| char::from(*byte)).collect(),
124 }
125}
126
127pub fn read_text_file_with_encoding(path: &Path) -> io::Result<String> {
129 std::fs::read(path).map(|bytes| decode_text_bytes(&bytes))
130}
131
132fn decode_utf16_lossy(bytes: &[u8], little_endian: bool) -> Option<String> {
139 if !bytes.len().is_multiple_of(2) {
140 return None;
141 }
142 let mut words = Vec::with_capacity(bytes.len() / 2);
143 for pair in bytes.chunks_exact(2) {
144 let word = if little_endian {
145 u16::from_le_bytes([pair[0], pair[1]])
146 } else {
147 u16::from_be_bytes([pair[0], pair[1]])
148 };
149 words.push(word);
150 }
151 Some(String::from_utf16_lossy(&words))
152}
153
154pub fn byte_to_utf16_col(line_text: &str, byte_pos: usize) -> usize {
160 let mut units = 0;
161 for (i, ch) in line_text.char_indices() {
162 if i >= byte_pos {
163 break;
164 }
165 units += if ch as u32 >= 0x10000 { 2 } else { 1 };
167 }
168 units
169}
170
171pub fn byte_to_line_col(source: &str, offset: usize) -> (u32, u32) {
173 let mut line = 0;
174 let mut col = 0;
175
176 for (i, ch) in source.char_indices() {
177 if i >= offset {
178 break;
179 }
180 if ch == '\n' {
181 line += 1;
182 col = 0;
183 } else {
184 col += 1;
185 }
186 }
187
188 (line, col)
189}
190
191pub fn find_matching_paren(s: &str, open_at: usize) -> Option<usize> {
193 let mut i = open_at;
195 let mut depth_par = 0i32;
196 let mut in_s = false;
197 let mut in_d = false;
198 while i < s.len() {
199 let b = s.as_bytes()[i];
200 let prev_backslash = i > 0 && s.as_bytes()[i - 1] == b'\\';
201 if in_s {
202 if b == b'\'' && !prev_backslash {
203 in_s = false;
204 }
205 } else if in_d {
206 if b == b'"' && !prev_backslash {
207 in_d = false;
208 }
209 } else {
210 match b {
211 b'\'' => in_s = true,
212 b'"' => in_d = true,
213 b'(' => depth_par += 1,
214 b')' => {
215 depth_par -= 1;
216 if depth_par == 0 {
217 return Some(i);
218 }
219 }
220 _ => {}
221 }
222 }
223 i += 1;
224 }
225 None
226}
227
228pub fn slice_until_stmt_end(src: &str, from: usize) -> usize {
230 let mut i = from;
231 let mut depth_par = 0i32;
232 let mut depth_brk = 0i32;
233 let mut depth_brc = 0i32;
234 let mut in_s = false;
235 let mut in_d = false;
236 while i < src.len() {
237 let b = src.as_bytes()[i];
238 let esc = i > 0 && src.as_bytes()[i - 1] == b'\\';
239 if in_s {
240 if b == b'\'' && !esc {
241 in_s = false;
242 }
243 } else if in_d {
244 if b == b'"' && !esc {
245 in_d = false;
246 }
247 } else {
248 match b {
249 b'\'' => in_s = true,
250 b'"' => in_d = true,
251 b'(' => depth_par += 1,
252 b')' => depth_par -= 1,
253 b'[' => depth_brk += 1,
254 b']' => depth_brk -= 1,
255 b'{' => depth_brc += 1,
256 b'}' => depth_brc -= 1,
257 b';' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => return i,
258 _ => {}
259 }
260 }
261 i += 1;
262 }
263 src.len()
264}
265
266pub fn arg_starts_top_level(src: &str) -> Vec<usize> {
268 let mut v = Vec::new();
269 let mut i = 0usize;
270 while i < src.len() && src.as_bytes()[i].is_ascii_whitespace() {
271 i += 1;
272 }
273 if i < src.len() {
274 v.push(i);
275 }
276 let mut j = i;
277 let mut depth_par = 0i32;
278 let mut depth_brk = 0i32;
279 let mut depth_brc = 0i32;
280 let mut in_s = false;
281 let mut in_d = false;
282 while j < src.len() {
283 let b = src.as_bytes()[j];
284 let esc = j > 0 && src.as_bytes()[j - 1] == b'\\';
285 if in_s {
286 if b == b'\'' && !esc {
287 in_s = false;
288 }
289 } else if in_d {
290 if b == b'"' && !esc {
291 in_d = false;
292 }
293 } else {
294 match b {
295 b'\'' => in_s = true,
296 b'"' => in_d = true,
297 b'(' => depth_par += 1,
298 b')' => depth_par -= 1,
299 b'[' => depth_brk += 1,
300 b']' => depth_brk -= 1,
301 b'{' => depth_brc += 1,
302 b'}' => depth_brc -= 1,
303 b',' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => {
304 let mut k = j + 1;
305 while k < src.len() && src.as_bytes()[k].is_ascii_whitespace() {
306 k += 1;
307 }
308 if k < src.len() {
309 v.push(k);
310 }
311 }
312 _ => {}
313 }
314 }
315 j += 1;
316 }
317 v
318}
319
320pub fn anchor_arg_start(body: &str, rel: usize) -> usize {
325 let s = &body[rel..];
326 let mut i = 0usize;
327 while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
328 i += 1;
329 }
330 if s[i..].starts_with("my ") || s[i..].starts_with("our ") {
332 let mut j = i + 3; while j < s.len() && s.as_bytes()[j].is_ascii_whitespace() {
334 j += 1;
335 }
336 return rel + j;
337 }
338 rel + i
340}
341
342pub fn smart_arg_anchor(body: &str, rel: usize) -> usize {
344 let s = &body[rel..];
345 let mut i = 0usize;
346 while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
347 i += 1;
348 }
349
350 for kw in ["my ", "our "] {
352 if s[i..].starts_with(kw) {
353 i += kw.len();
354 while i < s.len() && s.as_bytes()[i].is_ascii_whitespace() {
355 i += 1;
356 }
357 break;
358 }
359 }
360
361 let b = s.as_bytes().get(i).copied().unwrap_or(b' ');
364 if matches!(b, b'$' | b'@' | b'%' | b'&' | b'{' | b'[') || b.is_ascii_alphabetic() || b == b'_'
365 {
366 return rel + i;
367 }
368 rel + i
369}
370
371pub fn arg_starts_in_call_body(body: &str) -> Vec<usize> {
373 let mut starts = Vec::new();
375 let mut i = 0usize;
376 let mut depth_par = 0i32;
377 let mut depth_brk = 0i32;
378 let mut depth_brc = 0i32;
379 let mut in_s = false;
380 let mut in_d = false;
381 while i < body.len() && body.as_bytes()[i].is_ascii_whitespace() {
383 i += 1;
384 }
385 if i < body.len() {
386 starts.push(i);
387 }
388 let mut j = i;
389 while j < body.len() {
390 let b = body.as_bytes()[j];
391 let prev_backslash = j > 0 && body.as_bytes()[j - 1] == b'\\';
392 if in_s {
393 if b == b'\'' && !prev_backslash {
394 in_s = false;
395 }
396 } else if in_d {
397 if b == b'"' && !prev_backslash {
398 in_d = false;
399 }
400 } else {
401 match b {
402 b'\'' => in_s = true,
403 b'"' => in_d = true,
404 b'(' => depth_par += 1,
405 b')' => depth_par -= 1,
406 b'[' => depth_brk += 1,
407 b']' => depth_brk -= 1,
408 b'{' => depth_brc += 1,
409 b'}' => depth_brc -= 1,
410 b',' if depth_par == 0 && depth_brk == 0 && depth_brc == 0 => {
411 let mut k = j + 1;
413 while k < body.len() && body.as_bytes()[k].is_ascii_whitespace() {
414 k += 1;
415 }
416 if k < body.len() {
417 starts.push(k);
418 }
419 }
420 _ => {}
421 }
422 }
423 j += 1;
424 }
425 starts
426}
427
428pub fn pos_to_offset_bytes(text: &str, line: u32, ch: u32) -> usize {
430 let mut byte = 0usize;
431 for (cur, l) in text.split_inclusive('\n').enumerate() {
432 if cur as u32 == line {
433 return byte + (ch as usize).min(l.len());
434 }
435 byte += l.len();
436 }
437 text.len()
438}
439
440pub fn slice_in_range(text: &str, start: (u32, u32), end: (u32, u32)) -> (usize, usize, &str) {
442 let s = pos_to_offset_bytes(text, start.0, start.1);
443 let e = pos_to_offset_bytes(text, end.0, end.1);
444 let (s, e) = char_boundary_range(text, s, e);
445 (s, e, &text[s..e])
446}
447
448pub fn get_text_around_offset(content: &str, offset: usize, radius: usize) -> String {
450 get_text_window_around_offset(content, offset, radius).1
451}
452
453pub fn get_text_window_around_offset(
455 content: &str,
456 offset: usize,
457 radius: usize,
458) -> (usize, String) {
459 let offset = offset.min(content.len());
460 let start = offset.saturating_sub(radius);
461 let end = offset.saturating_add(radius).min(content.len());
462 let (start, end) = char_boundary_range(content, start, end);
463 (start, content[start..end].to_string())
464}
465
466fn char_boundary_range(text: &str, start: usize, end: usize) -> (usize, usize) {
467 let start = start.min(text.len());
468 let end = end.min(text.len());
469 if start > end {
470 let boundary = floor_char_boundary(text, end);
471 return (boundary, boundary);
472 }
473 (floor_char_boundary(text, start), ceil_char_boundary(text, end))
474}
475
476fn floor_char_boundary(text: &str, index: usize) -> usize {
477 let mut index = index.min(text.len());
478 while index > 0 && !text.is_char_boundary(index) {
479 index -= 1;
480 }
481 index
482}
483
484fn ceil_char_boundary(text: &str, index: usize) -> usize {
485 let mut index = index.min(text.len());
486 while index < text.len() && !text.is_char_boundary(index) {
487 index += 1;
488 }
489 index
490}
491
492pub fn extract_module_reference(text: &str, cursor_pos: usize) -> Option<String> {
494 extract_module_reference_at_cursor(text, cursor_pos)
495}
496
497pub fn extract_module_reference_extended(text: &str, cursor_pos: usize) -> Option<String> {
502 extract_module_reference_extended_at_cursor(text, cursor_pos)
503}
504
505pub fn position_to_offset(content: &str, line: u32, character: u32) -> Option<usize> {
507 let mut cur_line = 0u32;
508 let mut col_utf16 = 0u32;
509 let mut prev_was_cr = false;
510
511 for (byte_idx, ch) in content.char_indices() {
512 if cur_line == line && col_utf16 == character {
514 return Some(byte_idx);
515 }
516
517 match ch {
519 '\n' => {
520 if !prev_was_cr {
521 cur_line += 1;
523 col_utf16 = 0;
524 }
525 }
527 '\r' => {
528 cur_line += 1;
530 col_utf16 = 0;
531 }
532 _ => {
533 if cur_line == line {
535 col_utf16 += if ch.len_utf16() == 2 { 2 } else { 1 };
536 }
537 }
538 }
539
540 prev_was_cr = ch == '\r';
541 }
542
543 if cur_line == line && col_utf16 == character {
545 return Some(content.len());
546 }
547
548 None
550}
551
552pub fn offset_to_position(content: &str, offset: usize) -> Position {
554 let mut line = 0u32;
555 let mut col_utf16 = 0u32;
556 let mut byte_pos = 0usize;
557 let mut chars = content.chars().peekable();
558
559 while let Some(ch) = chars.next() {
560 if byte_pos >= offset {
561 break;
562 }
563
564 match ch {
565 '\r' => {
566 if chars.peek() == Some(&'\n') {
568 if byte_pos + 1 >= offset {
570 break;
572 }
573 chars.next(); line += 1;
576 col_utf16 = 0;
577 byte_pos += 2; } else {
579 line += 1;
581 col_utf16 = 0;
582 byte_pos += ch.len_utf8();
583 }
584 }
585 '\n' => {
586 line += 1;
588 col_utf16 = 0;
589 byte_pos += ch.len_utf8();
590 }
591 _ => {
592 col_utf16 += if ch.len_utf16() == 2 { 2 } else { 1 };
594 byte_pos += ch.len_utf8();
595 }
596 }
597 }
598
599 Position { line, character: col_utf16 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::{
605 arg_starts_in_call_body, arg_starts_top_level, byte_to_utf16_col, decode_text_bytes,
606 escape_markdown_text, extract_module_reference, find_matching_paren,
607 get_text_around_offset, get_text_window_around_offset, offset_to_position,
608 position_to_offset, slice_in_range, slice_until_stmt_end, smart_arg_anchor,
609 };
610 use lsp_types::Position;
611
612 #[test]
613 fn extract_module_reference_detects_use_statement_token() {
614 let line = "use Demo::Worker;";
615 let cursor = line.find("Worker").unwrap_or(0);
616
617 assert_eq!(extract_module_reference(line, cursor), Some("Demo::Worker".to_string()));
618 }
619
620 #[test]
621 fn extract_module_reference_normalizes_legacy_separators() {
622 let line = "require Demo'Worker;";
623 let cursor = line.find("Worker").unwrap_or(0);
624
625 assert_eq!(extract_module_reference(line, cursor), Some("Demo::Worker".to_string()));
626 }
627
628 #[test]
629 fn find_matching_paren_ignores_parens_inside_quotes() {
630 let text = r#"call("ignored ) token", nested(1, 2)) more"#;
631 assert_eq!(find_matching_paren(text, 4), Some(36));
632 }
633
634 #[test]
635 fn slice_until_stmt_end_skips_nested_structures() {
636 let text = r#"my $x = foo(";", [1, 2], { k => ";" }); my $y = 1;"#;
637 assert_eq!(slice_until_stmt_end(text, 0), 38);
638 }
639
640 #[test]
641 fn arg_start_helpers_ignore_nested_commas() {
642 let text = r#" $a, fn(1,2), {k=>3}, "x,y" "#;
643 assert_eq!(arg_starts_top_level(text), vec![1, 5, 14, 22]);
644 assert_eq!(arg_starts_in_call_body(text), vec![1, 5, 14, 22]);
645 }
646
647 #[test]
648 fn smart_arg_anchor_skips_our_keyword() {
649 let body = "our $fh, @rest";
650 assert_eq!(smart_arg_anchor(body, 0), 4);
651 }
652
653 #[test]
654 fn byte_to_utf16_col_counts_surrogate_pairs() {
655 let line = "a😀z";
656 assert_eq!(byte_to_utf16_col(line, 0), 0);
657 assert_eq!(byte_to_utf16_col(line, 1), 1);
658 assert_eq!(byte_to_utf16_col(line, 5), 3);
659 }
660
661 #[test]
662 fn position_offset_roundtrip_handles_crlf_and_emoji() {
663 let content = "my 😀\r\nnext";
664 let pos_after_emoji = Position { line: 0, character: 5 };
665 let offset = position_to_offset(content, pos_after_emoji.line, pos_after_emoji.character);
666 assert_eq!(offset, Some(7));
667 assert_eq!(offset_to_position(content, 7), pos_after_emoji);
668 }
669
670 #[test]
671 fn get_text_around_offset_snaps_start_to_utf8_boundary() {
672 let prefix = format!("{}{}\n", "🦀", "x".repeat(48));
673 let content = format!("{prefix}sub foo {{}}");
674 let offset = prefix.len();
675
676 let text_around = get_text_around_offset(&content, offset, 50);
677
678 assert!(text_around.starts_with("🦀"));
679 assert!(text_around.contains("sub foo"));
680 }
681
682 #[test]
683 fn get_text_window_around_offset_reports_adjusted_utf8_start() {
684 let prefix = format!("{}{}\n", "🦀", "x".repeat(48));
685 let content = format!("{prefix}sub foo {{}}");
686 let offset = prefix.len();
687
688 let (start, text_around) = get_text_window_around_offset(&content, offset, 50);
689 let cursor_in_text = offset.saturating_sub(start);
690
691 assert_eq!(start, 0);
692 assert!(text_around.is_char_boundary(cursor_in_text));
693 assert!(text_around[cursor_in_text..].starts_with("sub foo"));
694 }
695
696 #[test]
697 fn slice_in_range_snaps_utf8_cut_points() {
698 let text = "🦀abc";
699
700 let (start, end, slice) = slice_in_range(text, (0, 2), (0, 3));
701
702 assert_eq!(start, 0);
703 assert_eq!(end, "🦀".len());
704 assert_eq!(slice, "🦀");
705 }
706
707 #[test]
708 fn slice_in_range_returns_empty_for_reversed_range() {
709 let text = "🦀abc";
710
711 let (start, end, slice) = slice_in_range(text, (0, 5), (0, 2));
712
713 assert_eq!(start, 0);
714 assert_eq!(end, 0);
715 assert!(slice.is_empty());
716 }
717
718 #[test]
719 fn decode_text_bytes_supports_utf16_le_bom() {
720 let bytes = [0xFF, 0xFE, b'P', 0x00, b'e', 0x00, b'r', 0x00, b'l', 0x00];
721 assert_eq!(decode_text_bytes(&bytes), "Perl");
722 }
723
724 #[test]
725 fn decode_text_bytes_falls_back_to_latin1() {
726 let bytes = [0x63, 0x61, 0x66, 0xE9];
727 assert_eq!(decode_text_bytes(&bytes), "café");
728 }
729
730 #[test]
733 fn decode_text_bytes_handles_odd_length_utf16_le() {
734 let bytes = [0xFF, 0xFE, 0x6D, 0x00, 0x79];
736 let decoded = decode_text_bytes(&bytes);
737 assert!(!decoded.is_empty());
740 }
741
742 #[test]
745 fn decode_text_bytes_handles_odd_length_utf16_be() {
746 let bytes = [0xFE, 0xFF, 0x00, 0x6D, 0x00];
747 let decoded = decode_text_bytes(&bytes);
748 assert!(!decoded.is_empty());
749 }
750
751 #[test]
752 fn escape_markdown_text_escapes_asterisks() {
753 let text = "This is *not* bold";
754 let escaped = escape_markdown_text(text);
755 assert_eq!(escaped, "This is \\*not\\* bold");
756 }
757
758 #[test]
759 fn escape_markdown_text_escapes_underscores() {
760 let text = "This is _not_ italic";
761 let escaped = escape_markdown_text(text);
762 assert_eq!(escaped, "This is \\_not\\_ italic");
763 }
764
765 #[test]
766 fn escape_markdown_text_escapes_backticks() {
767 let text = "This is `code` text";
768 let escaped = escape_markdown_text(text);
769 assert_eq!(escaped, "This is \\`code\\` text");
770 }
771
772 #[test]
773 fn escape_markdown_text_escapes_brackets() {
774 let text = "This is [link] text";
775 let escaped = escape_markdown_text(text);
776 assert_eq!(escaped, "This is \\[link\\] text");
777 }
778
779 #[test]
780 fn escape_markdown_text_escapes_hash() {
781 let text = "This is #heading text";
782 let escaped = escape_markdown_text(text);
783 assert_eq!(escaped, "This is \\#heading text");
784 }
785
786 #[test]
787 fn escape_markdown_text_escapes_multiple_special_chars() {
788 let text = "Variable *tracks* [documentation] with `code`";
789 let escaped = escape_markdown_text(text);
790 assert_eq!(escaped, "Variable \\*tracks\\* \\[documentation\\] with \\`code\\`");
791 }
792
793 #[test]
794 fn escape_markdown_text_preserves_plain_text() {
795 let text = "This is plain text";
796 let escaped = escape_markdown_text(text);
797 assert_eq!(escaped, text);
798 }
799
800 #[test]
801 fn escape_markdown_text_escapes_dash() {
802 let text = "read-only access";
806 let escaped = escape_markdown_text(text);
807 assert_eq!(escaped, r"read\-only access");
808 }
809
810 #[test]
811 fn escape_markdown_text_escapes_backslash() {
812 let text = r"C:\path\file";
815 let escaped = escape_markdown_text(text);
816 assert_eq!(escaped, r"C:\\path\\file");
817 }
818
819 #[test]
820 fn escape_markdown_text_handles_empty_string() {
821 assert_eq!(escape_markdown_text(""), "");
822 }
823
824 #[test]
825 fn escape_markdown_text_handles_unicode() {
826 let text = "Résumé: *important*";
828 let escaped = escape_markdown_text(text);
829 assert_eq!(escaped, r"Résumé: \*important\*");
830 }
831}