1use regex::Regex;
3use std::sync::OnceLock;
4
5static URL_REGEX: OnceLock<Regex> = OnceLock::new();
7
8static FILE_PATH_REGEX: OnceLock<Regex> = OnceLock::new();
10
11fn url_regex() -> &'static Regex {
13 URL_REGEX.get_or_init(|| {
14 Regex::new(
17 r"(?x)
18 \b(?:
19 # URLs with explicit schemes
20 (?:https?|ftps?|file|git|ssh)://[^\s<>{}|\\^`\[\]]+
21 |
22 # URLs starting with www.
23 www\.[^\s<>{}|\\^`\[\]]+
24 )\b
25 ",
26 )
27 .expect("Failed to compile URL regex")
28 })
29}
30
31fn file_path_regex() -> &'static Regex {
33 FILE_PATH_REGEX.get_or_init(|| {
34 Regex::new(
49 r#"(?x)
50 (?:
51 # Home-relative paths (~/...)
52 ~/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
53 |
54 # Relative paths starting with ./ or ../
55 \.\.?/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
56 |
57 # Absolute paths: must be at start of string or after whitespace
58 # Require at least two path components to reduce false positives
59 (?:^|\s)/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+/[^\s:,;'"<>|)\]}\[\(\x00-\x1f]+
60 )
61 # Optional line/column number in various formats
62 (?:
63 :\d+(?::\d+)? # :line or :line:col
64 | \[\d+(?:,\s?\d+)?\] # [line] or [line, col]
65 | \(\d+(?:,\s?\d+)?\) # (line) or (line, col)
66 )?
67 "#,
68 )
69 .expect("Failed to compile file path regex")
70 })
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub enum DetectedItemType {
76 Url,
78 FilePath {
80 line: Option<usize>,
82 column: Option<usize>,
84 },
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub struct DetectedUrl {
90 pub url: String,
92 pub start_col: usize,
94 pub end_col: usize,
96 pub row: usize,
98 pub hyperlink_id: Option<u32>,
100 pub item_type: DetectedItemType,
102}
103
104pub fn detect_urls_in_line(text: &str, row: usize) -> Vec<DetectedUrl> {
106 let regex = url_regex();
107 let mut urls = Vec::new();
108
109 for mat in regex.find_iter(text) {
110 let url = mat.as_str().to_string();
111 let start_col = mat.start();
112 let end_col = mat.end();
113
114 urls.push(DetectedUrl {
115 url,
116 start_col,
117 end_col,
118 row,
119 hyperlink_id: None, item_type: DetectedItemType::Url,
121 });
122 }
123
124 urls
125}
126
127pub fn detect_file_paths_in_line(text: &str, row: usize) -> Vec<DetectedUrl> {
131 let regex = file_path_regex();
132 let mut paths = Vec::new();
133
134 for mat in regex.find_iter(text) {
135 let full_match = mat.as_str();
136 let mut start_col = mat.start();
137 let end_col = mat.end();
138
139 let trimmed_match = if full_match.starts_with(char::is_whitespace) {
142 let trimmed = full_match.trim_start();
143 start_col += full_match.len() - trimmed.len();
144 trimmed
145 } else {
146 full_match
147 };
148
149 let (path, line, column) = parse_path_with_line_number(trimmed_match);
151
152 paths.push(DetectedUrl {
153 url: path,
154 start_col,
155 end_col,
156 row,
157 hyperlink_id: None,
158 item_type: DetectedItemType::FilePath { line, column },
159 });
160 }
161
162 paths
163}
164
165fn parse_path_with_line_number(path_str: &str) -> (String, Option<usize>, Option<usize>) {
170 if let Some(bracket_start) = path_str.rfind('[')
172 && path_str.ends_with(']')
173 {
174 let path = path_str[..bracket_start].to_string();
175 let inner = &path_str[bracket_start + 1..path_str.len() - 1];
176 let (line, col) = parse_line_col_pair(inner);
177 if line.is_some() {
178 return (path, line, col);
179 }
180 }
181
182 if let Some(paren_start) = path_str.rfind('(')
184 && path_str.ends_with(')')
185 {
186 let path = path_str[..paren_start].to_string();
187 let inner = &path_str[paren_start + 1..path_str.len() - 1];
188 let (line, col) = parse_line_col_pair(inner);
189 if line.is_some() {
190 return (path, line, col);
191 }
192 }
193
194 let parts: Vec<&str> = path_str.rsplitn(3, ':').collect();
196
197 match parts.len() {
198 3 => {
199 let col = parts[0].parse::<usize>().ok();
201 let line = parts[1].parse::<usize>().ok();
202 if line.is_some() {
203 let path = parts[2].to_string();
204 (path, line, col)
205 } else {
206 (path_str.to_string(), None, None)
207 }
208 }
209 2 => {
210 let line = parts[0].parse::<usize>().ok();
212 if line.is_some() {
213 let path = parts[1].to_string();
214 (path, line, None)
215 } else {
216 (path_str.to_string(), None, None)
217 }
218 }
219 _ => (path_str.to_string(), None, None),
220 }
221}
222
223fn parse_line_col_pair(s: &str) -> (Option<usize>, Option<usize>) {
225 let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
226 match parts.len() {
227 1 => (parts[0].parse().ok(), None),
228 2 => (parts[0].parse().ok(), parts[1].parse().ok()),
229 _ => (None, None),
230 }
231}
232
233pub fn detect_osc8_hyperlinks(
243 cells: &[crate::cell_renderer::Cell],
244 row: usize,
245 hyperlink_urls: &std::collections::HashMap<u32, String>,
246) -> Vec<DetectedUrl> {
247 let mut urls = Vec::new();
248 let mut current_hyperlink: Option<(u32, usize, String)> = None; for (col, cell) in cells.iter().enumerate() {
251 match (cell.hyperlink_id, ¤t_hyperlink) {
252 (Some(id), Some((current_id, _start_col, _url))) if id == *current_id => {
254 continue;
256 }
257 (Some(id), _) => {
258 if let Some((prev_id, start_col, url)) = current_hyperlink.take() {
261 urls.push(DetectedUrl {
262 url,
263 start_col,
264 end_col: col, row,
266 hyperlink_id: Some(prev_id),
267 item_type: DetectedItemType::Url,
268 });
269 }
270
271 if let Some(url) = hyperlink_urls.get(&id) {
273 current_hyperlink = Some((id, col, url.clone()));
274 }
275 }
276 (None, Some((prev_id, start_col, url))) => {
277 urls.push(DetectedUrl {
279 url: url.clone(),
280 start_col: *start_col,
281 end_col: col, row,
283 hyperlink_id: Some(*prev_id),
284 item_type: DetectedItemType::Url,
285 });
286 current_hyperlink = None;
287 }
288 (None, None) => {
289 continue;
291 }
292 }
293 }
294
295 if let Some((id, start_col, url)) = current_hyperlink {
297 urls.push(DetectedUrl {
298 url,
299 start_col,
300 end_col: cells.len(), row,
302 hyperlink_id: Some(id),
303 item_type: DetectedItemType::Url,
304 });
305 }
306
307 urls
308}
309
310pub fn find_url_at_position(urls: &[DetectedUrl], col: usize, row: usize) -> Option<&DetectedUrl> {
312 urls.iter()
313 .find(|url| url.row == row && col >= url.start_col && col < url.end_col)
314}
315
316pub fn ensure_url_scheme(url: &str) -> String {
322 if !url.contains("://") {
323 format!("https://{}", url)
324 } else {
325 url.to_string()
326 }
327}
328
329pub fn expand_link_handler(command: &str, url: &str) -> Result<Vec<String>, String> {
334 let expanded = command.replace("{url}", url);
335 let parts: Vec<String> = expanded.split_whitespace().map(String::from).collect();
336 if parts.is_empty() {
337 return Err("Link handler command is empty after expansion".to_string());
338 }
339 Ok(parts)
340}
341
342pub fn open_url(url: &str, link_handler_command: &str) -> Result<(), String> {
344 let url_with_scheme = ensure_url_scheme(url);
345
346 if link_handler_command.is_empty() {
347 open::that(&url_with_scheme).map_err(|e| format!("Failed to open URL: {}", e))
349 } else {
350 let parts = expand_link_handler(link_handler_command, &url_with_scheme)?;
352 std::process::Command::new(&parts[0])
353 .args(&parts[1..])
354 .spawn()
355 .map(|_| ())
356 .map_err(|e| format!("Failed to run link handler '{}': {}", parts[0], e))
357 }
358}
359
360pub fn open_file_in_editor(
371 path: &str,
372 line: Option<usize>,
373 column: Option<usize>,
374 editor_mode: crate::config::SemanticHistoryEditorMode,
375 editor_cmd: &str,
376 cwd: Option<&str>,
377) -> Result<(), String> {
378 let resolved_path = if path.starts_with("~/") {
380 if let Some(home) = dirs::home_dir() {
381 path.replacen("~", &home.to_string_lossy(), 1)
382 } else {
383 path.to_string()
384 }
385 } else {
386 path.to_string()
387 };
388
389 let resolved_path = if resolved_path.starts_with("./") || resolved_path.starts_with("../") {
391 if let Some(working_dir) = cwd {
392 let expanded_cwd = if working_dir.starts_with("~/") {
394 if let Some(home) = dirs::home_dir() {
395 working_dir.replacen("~", &home.to_string_lossy(), 1)
396 } else {
397 working_dir.to_string()
398 }
399 } else {
400 working_dir.to_string()
401 };
402
403 let cwd_path = std::path::Path::new(&expanded_cwd);
404 let full_path = cwd_path.join(&resolved_path);
405 crate::debug_info!(
406 "SEMANTIC",
407 "Resolved relative path: {:?} + {:?} = {:?}",
408 expanded_cwd,
409 resolved_path,
410 full_path
411 );
412 full_path
414 .canonicalize()
415 .map(|p| p.to_string_lossy().to_string())
416 .unwrap_or_else(|_| full_path.to_string_lossy().to_string())
417 } else {
418 resolved_path.clone()
419 }
420 } else {
421 resolved_path.clone()
422 };
423
424 let path_obj = std::path::Path::new(&resolved_path);
426 if !path_obj.exists() {
427 return Err(format!("Path not found: {}", resolved_path));
428 }
429
430 if path_obj.is_dir() {
432 crate::debug_info!(
433 "SEMANTIC",
434 "Opening directory in file manager: {}",
435 resolved_path
436 );
437 return open::that(&resolved_path).map_err(|e| format!("Failed to open directory: {}", e));
438 }
439
440 use crate::config::SemanticHistoryEditorMode;
442 let cmd = match editor_mode {
443 SemanticHistoryEditorMode::Custom => {
444 if editor_cmd.is_empty() {
445 crate::debug_info!(
447 "SEMANTIC",
448 "Custom mode but no editor configured, using system default for: {}",
449 resolved_path
450 );
451 return open::that(&resolved_path)
452 .map_err(|e| format!("Failed to open file: {}", e));
453 }
454 crate::debug_info!("SEMANTIC", "Using custom editor: {:?}", editor_cmd);
455 editor_cmd.to_string()
456 }
457 SemanticHistoryEditorMode::EnvironmentVariable => {
458 let env_editor = std::env::var("EDITOR")
460 .or_else(|_| std::env::var("VISUAL"))
461 .ok();
462 crate::debug_info!(
463 "SEMANTIC",
464 "Environment variable mode: EDITOR={:?}, VISUAL={:?}",
465 std::env::var("EDITOR").ok(),
466 std::env::var("VISUAL").ok()
467 );
468 if let Some(editor) = env_editor {
469 editor
470 } else {
471 crate::debug_info!(
472 "SEMANTIC",
473 "No $EDITOR/$VISUAL set, using system default for: {}",
474 resolved_path
475 );
476 return open::that(&resolved_path)
477 .map_err(|e| format!("Failed to open file: {}", e));
478 }
479 }
480 SemanticHistoryEditorMode::SystemDefault => {
481 crate::debug_info!(
482 "SEMANTIC",
483 "System default mode, opening with default app: {}",
484 resolved_path
485 );
486 return open::that(&resolved_path).map_err(|e| format!("Failed to open file: {}", e));
487 }
488 };
489
490 let line_str = line
492 .map(|l| l.to_string())
493 .unwrap_or_else(|| "1".to_string());
494 let col_str = column
495 .map(|c| c.to_string())
496 .unwrap_or_else(|| "1".to_string());
497
498 let full_cmd = cmd
499 .replace("{file}", &resolved_path)
500 .replace("{line}", &line_str)
501 .replace("{col}", &col_str);
502
503 let full_cmd = if !cmd.contains("{file}") {
505 format!("{} {}", full_cmd, shell_escape(&resolved_path))
506 } else {
507 full_cmd
508 };
509
510 crate::debug_info!(
512 "SEMANTIC",
513 "Executing editor command: {:?} for file: {} (line: {:?}, col: {:?})",
514 full_cmd,
515 resolved_path,
516 line,
517 column
518 );
519
520 #[cfg(target_os = "windows")]
521 {
522 std::process::Command::new("cmd")
523 .args(["/C", &full_cmd])
524 .spawn()
525 .map_err(|e| format!("Failed to launch editor: {}", e))?;
526 }
527
528 #[cfg(not(target_os = "windows"))]
529 {
530 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
533 std::process::Command::new(&shell)
534 .args(["-lc", &full_cmd])
535 .spawn()
536 .map_err(|e| format!("Failed to launch editor with {}: {}", shell, e))?;
537 }
538
539 Ok(())
540}
541
542fn shell_escape(s: &str) -> String {
544 format!("'{}'", s.replace('\'', "'\\''"))
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn test_detect_http_url() {
554 let text = "Visit https://example.com for more info";
555 let urls = detect_urls_in_line(text, 0);
556 assert_eq!(urls.len(), 1);
557 assert_eq!(urls[0].url, "https://example.com");
558 assert_eq!(urls[0].start_col, 6);
559 assert_eq!(urls[0].end_col, 25); }
561
562 #[test]
563 fn test_detect_www_url() {
564 let text = "Check out www.example.com";
565 let urls = detect_urls_in_line(text, 0);
566 assert_eq!(urls.len(), 1);
567 assert_eq!(urls[0].url, "www.example.com");
568 }
569
570 #[test]
571 fn test_detect_multiple_urls() {
572 let text = "See https://example.com and http://test.org";
573 let urls = detect_urls_in_line(text, 0);
574 assert_eq!(urls.len(), 2);
575 assert_eq!(urls[0].url, "https://example.com");
576 assert_eq!(urls[1].url, "http://test.org");
577 }
578
579 #[test]
580 fn test_find_url_at_position() {
581 let text = "Visit https://example.com for more";
582 let urls = detect_urls_in_line(text, 5);
583
584 assert!(find_url_at_position(&urls, 10, 5).is_some());
586
587 assert!(find_url_at_position(&urls, 0, 5).is_none());
589 assert!(find_url_at_position(&urls, 30, 5).is_none());
590
591 assert!(find_url_at_position(&urls, 10, 6).is_none());
593 }
594
595 #[test]
596 fn test_no_urls() {
597 let text = "This line has no URLs at all";
598 let urls = detect_urls_in_line(text, 0);
599 assert_eq!(urls.len(), 0);
600 }
601
602 #[test]
603 fn test_url_schemes() {
604 let text = "ftp://files.com ssh://git.com file:///path git://repo.com";
605 let urls = detect_urls_in_line(text, 0);
606 assert_eq!(urls.len(), 4);
607 }
608
609 #[test]
610 fn test_detect_relative_file_path() {
611 let text = "./src/lambda_check_sf_status/.gitignore";
612 let paths = detect_file_paths_in_line(text, 0);
613 assert_eq!(paths.len(), 1, "Should detect exactly one path");
614 assert_eq!(paths[0].url, "./src/lambda_check_sf_status/.gitignore");
615 assert_eq!(paths[0].start_col, 0);
616 assert_eq!(paths[0].end_col, text.len());
617 }
618
619 #[test]
620 fn test_detect_nested_path_no_double_match() {
621 let text = "./src/lambda_sap_po_to_zen/src/handler.py";
623 let paths = detect_file_paths_in_line(text, 0);
624 assert_eq!(
625 paths.len(),
626 1,
627 "Should detect exactly one path, not multiple overlapping ones"
628 );
629 assert_eq!(paths[0].url, text);
630 assert_eq!(paths[0].start_col, 0);
631 }
632
633 #[test]
634 fn test_detect_home_path() {
635 let text = "~/Documents/file.txt";
636 let paths = detect_file_paths_in_line(text, 0);
637 assert_eq!(paths.len(), 1);
638 assert_eq!(paths[0].url, "~/Documents/file.txt");
639 }
640
641 #[test]
642 fn test_detect_path_with_line_number() {
643 let text = "./src/main.rs:42";
644 let paths = detect_file_paths_in_line(text, 0);
645 assert_eq!(paths.len(), 1);
646 assert_eq!(paths[0].url, "./src/main.rs");
647 if let DetectedItemType::FilePath { line, column } = &paths[0].item_type {
648 assert_eq!(*line, Some(42));
649 assert_eq!(*column, None);
650 } else {
651 panic!("Expected FilePath type");
652 }
653 }
654
655 #[test]
656 fn test_detect_path_with_line_and_col() {
657 let text = "./src/main.rs:42:10";
658 let paths = detect_file_paths_in_line(text, 0);
659 assert_eq!(paths.len(), 1);
660 assert_eq!(paths[0].url, "./src/main.rs");
661 if let DetectedItemType::FilePath { line, column } = &paths[0].item_type {
662 assert_eq!(*line, Some(42));
663 assert_eq!(*column, Some(10));
664 } else {
665 panic!("Expected FilePath type");
666 }
667 }
668
669 #[test]
670 fn test_absolute_path_with_multiple_components() {
671 let text = "/Users/probello/.claude";
672 let paths = detect_file_paths_in_line(text, 0);
673 assert_eq!(
674 paths.len(),
675 1,
676 "Should match absolute path at start of string"
677 );
678 assert_eq!(paths[0].url, "/Users/probello/.claude");
679 assert_eq!(paths[0].start_col, 0);
680 }
681
682 #[test]
683 fn test_absolute_path_after_whitespace() {
684 let text = "ls /Users/probello/.claude";
685 let paths = detect_file_paths_in_line(text, 0);
686 assert_eq!(
687 paths.len(),
688 1,
689 "Should match absolute path after whitespace"
690 );
691 assert_eq!(paths[0].url, "/Users/probello/.claude");
692 assert_eq!(paths[0].start_col, 3);
693 }
694
695 #[test]
696 fn test_no_match_single_component_absolute_path() {
697 let text = "/etc";
699 let paths = detect_file_paths_in_line(text, 0);
700 assert_eq!(
701 paths.len(),
702 0,
703 "Should not match single-component absolute paths"
704 );
705 }
706
707 #[test]
708 fn test_no_false_absolute_match_inside_relative() {
709 let text = "./foo/bar/baz";
711 let paths = detect_file_paths_in_line(text, 0);
712 assert_eq!(
713 paths.len(),
714 1,
715 "Should only match the relative path, not internal absolute"
716 );
717 assert_eq!(paths[0].url, "./foo/bar/baz");
718 }
719
720 #[test]
724 fn test_byte_offset_to_column_mapping_with_multibyte() {
725 let graphemes = ["★", " ", "~", "/", "d", "o", "c", "s"];
729 let cols = graphemes.len();
730
731 let mut line = String::new();
733 let mut byte_to_col: Vec<usize> = Vec::new();
734 for (col_idx, g) in graphemes.iter().enumerate() {
735 for _ in 0..g.len() {
736 byte_to_col.push(col_idx);
737 }
738 line.push_str(g);
739 }
740 byte_to_col.push(cols); let map = |b: usize| -> usize { byte_to_col.get(b).copied().unwrap_or(cols) };
743
744 let paths = detect_file_paths_in_line(&line, 0);
746 assert_eq!(paths.len(), 1, "Should detect ~/docs");
747
748 assert_eq!(paths[0].start_col, 4, "Byte offset should be 4");
751
752 let start_col = map(paths[0].start_col);
754 let end_col = map(paths[0].end_col);
755 assert_eq!(start_col, 2, "Column should be 2 (after ★ and space)");
756 assert_eq!(end_col, cols, "End column should be 8 (end of line)");
757 }
758
759 #[test]
762 fn test_ensure_url_scheme_adds_https_when_no_scheme() {
763 assert_eq!(
764 ensure_url_scheme("www.example.com"),
765 "https://www.example.com"
766 );
767 assert_eq!(
768 ensure_url_scheme("example.com/path"),
769 "https://example.com/path"
770 );
771 }
772
773 #[test]
774 fn test_ensure_url_scheme_preserves_existing_scheme() {
775 assert_eq!(
776 ensure_url_scheme("https://example.com"),
777 "https://example.com"
778 );
779 assert_eq!(
780 ensure_url_scheme("http://example.com"),
781 "http://example.com"
782 );
783 assert_eq!(
784 ensure_url_scheme("ftp://files.example.com"),
785 "ftp://files.example.com"
786 );
787 assert_eq!(
788 ensure_url_scheme("file:///tmp/test.html"),
789 "file:///tmp/test.html"
790 );
791 }
792
793 #[test]
796 fn test_expand_link_handler_replaces_url_placeholder() {
797 let parts =
798 expand_link_handler("firefox {url}", "https://example.com").expect("should succeed");
799 assert_eq!(parts, vec!["firefox", "https://example.com"]);
800 }
801
802 #[test]
803 fn test_expand_link_handler_multi_word_command() {
804 let parts = expand_link_handler("open -a Firefox {url}", "https://example.com")
805 .expect("should succeed");
806 assert_eq!(parts, vec!["open", "-a", "Firefox", "https://example.com"]);
807 }
808
809 #[test]
810 fn test_expand_link_handler_no_placeholder() {
811 let parts =
813 expand_link_handler("my-browser", "https://example.com").expect("should succeed");
814 assert_eq!(parts, vec!["my-browser"]);
815 }
816
817 #[test]
818 fn test_expand_link_handler_errors_on_empty_expansion() {
819 let result = expand_link_handler(" ", "https://example.com");
821 assert!(result.is_err());
822 assert_eq!(
823 result.unwrap_err(),
824 "Link handler command is empty after expansion"
825 );
826 }
827
828 #[test]
829 fn test_expand_link_handler_empty_command() {
830 let result = expand_link_handler("", "https://example.com");
831 assert!(result.is_err());
832 }
833}