Make python's file, line output clickable in terminal (#26903)

Thorben Krรถger and Kirill Bulatov created

Closes #16004.


![image](https://github.com/user-attachments/assets/73cfe9da-5575-4616-9ed0-99fcb3ab61f5)

Python formats file and line number references in the form `File
"file.py", line 8"`
I'm not a CPython expert, but from a quick look, they appear to come
from:
-
https://github.com/python/cpython/blob/80e00ecc399db8aeaa9f3a1c87a2cfb34517d7be/Python/traceback.c#L613
-
https://github.com/python/cpython/blob/80e00ecc399db8aeaa9f3a1c87a2cfb34517d7be/Python/traceback.c#L927
I am not aware of the possiblity to also encode the column information.

Release Notes:

- File, line references from Python, like 'File "file.py", line 8' are
now clickable in the terminal

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/terminal/Cargo.toml      |  2 
crates/terminal/src/terminal.rs | 59 +++++++++++++++++++++++++++++++++-
2 files changed, 58 insertions(+), 3 deletions(-)

Detailed changes

crates/terminal/Cargo.toml ๐Ÿ”—

@@ -31,10 +31,10 @@ task.workspace = true
 theme.workspace = true
 thiserror.workspace = true
 util.workspace = true
+regex.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true
 
 [dev-dependencies]
 rand.workspace = true
-regex.workspace = true

crates/terminal/src/terminal.rs ๐Ÿ”—

@@ -39,6 +39,7 @@ use mappings::mouse::{
 use collections::{HashMap, VecDeque};
 use futures::StreamExt;
 use pty_info::PtyProcessInfo;
+use regex::Regex;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
 use smol::channel::{Receiver, Sender};
@@ -52,7 +53,7 @@ use std::{
     fmt::Display,
     ops::{Deref, Index, RangeInclusive},
     path::PathBuf,
-    sync::Arc,
+    sync::{Arc, LazyLock},
     time::Duration,
 };
 use thiserror::Error;
@@ -318,6 +319,20 @@ const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|http
 // https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
 const WORD_REGEX: &str =
     r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;
+const PYTHON_FILE_LINE_REGEX: &str = r#"File "(?P<file>[^"]+)", line (?P<line>\d+)"#;
+
+static PYTHON_FILE_LINE_MATCHER: LazyLock<Regex> =
+    LazyLock::new(|| Regex::new(PYTHON_FILE_LINE_REGEX).unwrap());
+
+fn python_extract_path_and_line(input: &str) -> Option<(&str, u32)> {
+    if let Some(captures) = PYTHON_FILE_LINE_MATCHER.captures(input) {
+        let path_part = captures.name("file")?.as_str();
+
+        let line_number: u32 = captures.name("line")?.as_str().parse().ok()?;
+        return Some((path_part, line_number));
+    }
+    None
+}
 
 pub struct TerminalBuilder {
     terminal: Terminal,
@@ -473,6 +488,7 @@ impl TerminalBuilder {
             // hovered_word: false,
             url_regex: RegexSearch::new(URL_REGEX).unwrap(),
             word_regex: RegexSearch::new(WORD_REGEX).unwrap(),
+            python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),
             vi_mode_enabled: false,
             debug_terminal,
             is_ssh_terminal,
@@ -629,6 +645,7 @@ pub struct Terminal {
     selection_phase: SelectionPhase,
     url_regex: RegexSearch,
     word_regex: RegexSearch,
+    python_file_line_regex: RegexSearch,
     task: Option<TaskState>,
     vi_mode_enabled: bool,
     debug_terminal: bool,
@@ -929,6 +946,14 @@ impl Terminal {
                 } else if let Some(url_match) = regex_match_at(term, point, &mut self.url_regex) {
                     let url = term.bounds_to_string(*url_match.start(), *url_match.end());
                     Some((url, true, url_match))
+                } else if let Some(python_match) =
+                    regex_match_at(term, point, &mut self.python_file_line_regex)
+                {
+                    let matching_line =
+                        term.bounds_to_string(*python_match.start(), *python_match.end());
+                    python_extract_path_and_line(&matching_line).map(|(file_path, line_number)| {
+                        (format!("{file_path}:{line_number}"), false, python_match)
+                    })
                 } else if let Some(word_match) = regex_match_at(term, point, &mut self.word_regex) {
                     let file_path = term.bounds_to_string(*word_match.start(), *word_match.end());
 
@@ -2097,7 +2122,8 @@ mod tests {
     use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
 
     use crate::{
-        content_index_for_mouse, rgb_for_index, IndexedCell, TerminalBounds, TerminalContent,
+        content_index_for_mouse, python_extract_path_and_line, rgb_for_index, IndexedCell,
+        TerminalBounds, TerminalContent,
     };
 
     #[test]
@@ -2285,4 +2311,33 @@ mod tests {
             vec!["Main.cs:20:5:Error", "desc"],
         );
     }
+
+    #[test]
+    fn test_python_file_line_regex() {
+        re_test(
+            crate::PYTHON_FILE_LINE_REGEX,
+            "hay File \"/zed/bad_py.py\", line 8 stack",
+            vec!["File \"/zed/bad_py.py\", line 8"],
+        );
+        re_test(crate::PYTHON_FILE_LINE_REGEX, "unrelated", vec![]);
+    }
+
+    #[test]
+    fn test_python_file_line() {
+        let inputs: Vec<(&str, Option<(&str, u32)>)> = vec![
+            (
+                "File \"/zed/bad_py.py\", line 8",
+                Some(("/zed/bad_py.py", 8u32)),
+            ),
+            ("File \"path/to/zed/bad_py.py\"", None),
+            ("unrelated", None),
+            ("", None),
+        ];
+        let actual = inputs
+            .iter()
+            .map(|input| python_extract_path_and_line(input.0))
+            .collect::<Vec<_>>();
+        let expected = inputs.iter().map(|(_, output)| *output).collect::<Vec<_>>();
+        assert_eq!(actual, expected);
+    }
 }