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

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:
-
80e00ecc39/Python/traceback.c (L613)
-
80e00ecc39/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>
This commit is contained in:
Thorben Kröger 2025-03-24 13:36:14 +01:00 committed by GitHub
parent 07727f939e
commit d253d46fdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 3 deletions

View file

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

View file

@ -39,6 +39,7 @@ use mappings::mouse::{
use collections::{HashMap, VecDeque}; use collections::{HashMap, VecDeque};
use futures::StreamExt; use futures::StreamExt;
use pty_info::PtyProcessInfo; use pty_info::PtyProcessInfo;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::Settings; use settings::Settings;
use smol::channel::{Receiver, Sender}; use smol::channel::{Receiver, Sender};
@ -52,7 +53,7 @@ use std::{
fmt::Display, fmt::Display,
ops::{Deref, Index, RangeInclusive}, ops::{Deref, Index, RangeInclusive},
path::PathBuf, path::PathBuf,
sync::Arc, sync::{Arc, LazyLock},
time::Duration, time::Duration,
}; };
use thiserror::Error; 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 // https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
const WORD_REGEX: &str = const WORD_REGEX: &str =
r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#; 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 { pub struct TerminalBuilder {
terminal: Terminal, terminal: Terminal,
@ -473,6 +488,7 @@ impl TerminalBuilder {
// hovered_word: false, // hovered_word: false,
url_regex: RegexSearch::new(URL_REGEX).unwrap(), url_regex: RegexSearch::new(URL_REGEX).unwrap(),
word_regex: RegexSearch::new(WORD_REGEX).unwrap(), word_regex: RegexSearch::new(WORD_REGEX).unwrap(),
python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),
vi_mode_enabled: false, vi_mode_enabled: false,
debug_terminal, debug_terminal,
is_ssh_terminal, is_ssh_terminal,
@ -629,6 +645,7 @@ pub struct Terminal {
selection_phase: SelectionPhase, selection_phase: SelectionPhase,
url_regex: RegexSearch, url_regex: RegexSearch,
word_regex: RegexSearch, word_regex: RegexSearch,
python_file_line_regex: RegexSearch,
task: Option<TaskState>, task: Option<TaskState>,
vi_mode_enabled: bool, vi_mode_enabled: bool,
debug_terminal: 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) { } 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()); let url = term.bounds_to_string(*url_match.start(), *url_match.end());
Some((url, true, url_match)) 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) { } 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()); 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 rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
use crate::{ 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] #[test]
@ -2285,4 +2311,33 @@ mod tests {
vec!["Main.cs:20:5:Error", "desc"], 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);
}
} }