From 63b29c2edd8ddcd63bad760dcae3b91cb2161d45 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Mon, 21 Sep 2026 16:10:07 +0000 Subject: [PATCH] Add support for the `"..."` entry in `terminal.path_hyperlink_regexes` (#64552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Allow custom terminal path regexes to extend inherited entries without copying them. This PR also corrects the existing mismatch between the documented and shipped default regexes. This is the last submission in the #64399 series, following #62769, #64222, #64440, #64484, and #64540. ## Solution The `"..."` entry is intentionally supported at the top level only. A `"..."` line inside a multiline entry remains regex text. Existing configurations that use bare `...` as a three-character regex can preserve that behavior by writing `["..."]` as an entry instead. The shipped regexes and hyperlink matching behavior are unchanged. ## Testing All focused tests and all hyperlink tests pass on macOS. The documented default expressions were checked against the shipped values. ## Self-Review Checklist: - [x] I’ve reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed’s UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Added support for `"..."` in `terminal.path_hyperlink_regexes` to extend inherited regexes without repeating them. --- assets/settings/default.json | 17 +- crates/settings_content/src/terminal.rs | 183 ++++++++++++++++---- crates/terminal/src/alacritty/hyperlinks.rs | 30 ++++ crates/terminal/src/terminal_settings.rs | 162 +++++++++++++++++ docs/src/reference/all-settings.md | 58 ++++--- 5 files changed, 395 insertions(+), 55 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 4c7781c958..c0e79ab191 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2207,14 +2207,23 @@ // // [ // "\\s+(-->|:::|at) (?(?.+?))(:$|$)", - // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)" + // "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + // "..." // ], // - // could be used. Processing stops at the first regex with a match, even if no link is + // could be used. The `"..."` entry inserts inherited regexes at that position. In project + // settings, it extends the user or parent configuration value. Omit `"..."` to replace the + // inherited list, or set `[]` to clear it. Duplicates keep their first occurrence, comparing + // regex text after joining multiline entries with newlines. The marker is recognized only + // as a top-level string. Write `["..."]` as an entry to keep the three-character regex instead. + // A `"..."` line inside a multiline entry is always regex text. Invalid regexes are logged + // and ignored. + // + // Processing stops at the first regex with a match, even if no link is // produced which is the case when the cursor is not over the hyperlinked text. For best // performance it is recommended to order regexes from most common to least common. For - // readability and documentation, each regex may be an array of strings which are collected - // into one multi-line regex string for use in terminal path hyperlink detection. + // readability and documentation, each regex may be an array of strings which are joined + // with newlines into one regex string for use in terminal path hyperlink detection. "path_hyperlink_regexes": [ // Python-style diagnostics "File \"(?[^\"]+)\", line (?[0-9]+)", diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index 86b9557639..e9bf73ac56 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use collections::HashMap; +use collections::{HashMap, HashSet}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; @@ -30,35 +30,78 @@ pub struct ProjectTerminalSettingsContent { pub detect_venv: Option, /// Regexes used to identify paths for hyperlink navigation. /// - /// Default: [ - /// // Python-style diagnostics - /// "File \"(?[^\"]+)\", line (?[0-9]+)", - /// // Common path syntax with optional line, column, description, trailing punctuation, or - /// // surrounding symbols or quotes - /// [ - /// "(?x)", - /// "# optionally starts with 0-2 opening prefix symbols", - /// "[({\\[<]{0,2}", - /// "# which may be followed by an opening quote", - /// "(?[\"'`])?", - /// "# `path` is the shortest sequence of any non-space character", - /// "(?(?[^ ]+?", - /// " # which may end with a line and optionally a column,", - /// " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - /// "))", - /// "# which must be followed by a matching quote", - /// "(?()\\k)", - /// "# and optionally a single closing symbol", - /// "[)}\\]>]?", - /// "# if line/column matched, may be followed by a description", - /// "(?():[^ 0-9][^ ]*)?", - /// "# which may be followed by trailing punctuation", - /// "[.,:)}\\]>]*", - /// "# and always includes trailing whitespace or end of line", - /// "([ ]+|$)" - /// ] - /// ] - pub path_hyperlink_regexes: Option>, + /// Default: + /// + /// ```json + /// { + /// "terminal": { + /// "path_hyperlink_regexes": [ + /// // Python-style diagnostics + /// "File \"(?[^\"]+)\", line (?[0-9]+)", + /// // Common path syntax with optional line, column, description, trailing punctuation, or + /// // surrounding symbols or quotes + /// [ + /// "(?x)", + /// "(?", + /// " (", + /// " # multi-char path: first char (not opening delimiter, space, or box drawing char)", + /// " [^({\\[<\"'`\\ \\u2500-\\u257F]", + /// " # middle chars: non-space, and colon/paren only if not followed by digit/paren/space", + /// " ([^\\ :(]|[:(][^0-9()\\ ])*", + /// " # last char: not closing delimiter or colon", + /// " [^()}\\]>\"'`.,;:\\ ]", + /// " |", + /// " # single-char path: not delimiter, punctuation, space, or box drawing char", + /// " [^(){}\\[\\]<>\"'`.,;:\\ \\u2500-\\u257F]", + /// " )", + /// " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + /// " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + /// ")" + /// ] + /// ] + /// } + /// } + /// ``` + /// + /// Use `"..."` to add regexes without repeating Zed's defaults. In project + /// settings, it extends the user or parent configuration value. Omit + /// `"..."` to replace the inherited list. Set `[]` to clear it. Omitting this + /// setting keeps the inherited list. + /// + /// ```json + /// { + /// "terminal": { + /// "path_hyperlink_regexes": [ + /// "\\s+(-->|:::|at) (?(?.+?))(:$|$)", + /// "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + /// "..." + /// ] + /// } + /// } + /// ``` + /// + /// Inherited regexes are inserted at `"..."`, and duplicates keep their first + /// occurrence. Regexes are duplicates when their text is identical after + /// joining multiline entries with newlines. + /// + /// Each regex can be a single string or an array of strings joined with + /// newlines. The marker is recognized only as a top-level string. To use + /// `...` as a regex matching three characters, write `["..."]` as an entry. + /// A `"..."` line inside a multiline entry is always regex text. + /// + /// The optional named capture `path` selects the hyperlink target. Without + /// it, the entire match is the target. With `path`, `line` and `column` + /// specify the position. Without a captured `line`, built-in suffix + /// processing parses `line:column` and `(line,column)` variants. The optional + /// `link` capture selects the clickable text, otherwise the entire match is + /// clickable. + /// + /// Processing stops at the first regex that matches the terminal line, even + /// if the cursor is outside its clickable text. Put more specific regexes + /// before broader ones. Regexes use Rust's `regex` syntax. Invalid regexes + /// are logged and ignored. The `path_hyperlink_timeout_ms` setting controls + /// the discovery timeout. Setting it to `0` disables path hyperlinks. + pub path_hyperlink_regexes: Option, /// Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. /// /// Default: 1 @@ -504,6 +547,38 @@ impl VenvSettings { } } +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(transparent)] +pub struct PathHyperlinkRegexes(pub Vec); + +impl crate::merge_from::MergeFrom for PathHyperlinkRegexes { + fn merge_from(&mut self, other: &Self) { + let inherited = std::mem::take(&mut self.0); + let mut seen = HashSet::default(); + self.0 = other + .0 + .iter() + .flat_map(|entry| { + if entry.is_rest() { + inherited.as_slice() + } else { + std::slice::from_ref(entry) + } + }) + .filter(|entry| { + let regex = match entry { + PathHyperlinkRegex::SingleLine(regex) => regex.clone(), + PathHyperlinkRegex::MultiLine(lines) => lines.join("\n"), + }; + // Project layers can still contain an unresolved marker, which must + // not collapse with the literal regex written as ["..."] + seen.insert((entry.is_rest(), regex)) + }) + .cloned() + .collect(); + } +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] #[serde(untagged)] pub enum PathHyperlinkRegex { @@ -511,6 +586,12 @@ pub enum PathHyperlinkRegex { MultiLine(Vec), } +impl PathHyperlinkRegex { + fn is_rest(&self) -> bool { + matches!(self, Self::SingleLine(regex) if regex == crate::SplicingVec::REST) + } +} + #[derive( Copy, Clone, @@ -547,7 +628,47 @@ pub enum ActivateScript { mod test { use serde_json::json; - use crate::{ProjectSettingsContent, Shell}; + use crate::{PathHyperlinkRegexes, ProjectSettingsContent, Shell, merge_from::MergeFrom}; + + #[test] + fn test_path_hyperlink_regexes_preserve_entry_forms() -> anyhow::Result<()> { + let entries = json!([ + "...", + ["..."], + ["(?x)", "# comment", "...", "(?file)"], + "first\nsecond", + ["first", "second"] + ]); + let regexes: PathHyperlinkRegexes = serde_json::from_value(entries.clone())?; + assert_eq!(serde_json::to_value(regexes)?, entries); + Ok(()) + } + + #[test] + fn test_path_hyperlink_regexes_merge_preserves_first_representation() -> anyhow::Result<()> { + let mut regexes: PathHyperlinkRegexes = serde_json::from_value(json!([ + "...", + ["..."], + "first\nsecond", + ["(?x)", "# comment", "(?file)"] + ]))?; + regexes.merge_from(&serde_json::from_value(json!([ + ["first", "second"], + "...", + "(?x)\n# comment\n(?file)", + "..." + ]))?); + assert_eq!( + serde_json::to_value(regexes)?, + json!([ + ["first", "second"], + "...", + ["..."], + ["(?x)", "# comment", "(?file)"] + ]) + ); + Ok(()) + } #[test] #[ignore] diff --git a/crates/terminal/src/alacritty/hyperlinks.rs b/crates/terminal/src/alacritty/hyperlinks.rs index cabf1d3cf6..be314dc431 100644 --- a/crates/terminal/src/alacritty/hyperlinks.rs +++ b/crates/terminal/src/alacritty/hyperlinks.rs @@ -508,6 +508,36 @@ mod tests { assert_eq!(results, expected); } + #[test] + fn test_default_path_hyperlink_regexes() -> anyhow::Result<()> { + let content = settings::parse_json_with_comments(&settings::default_settings())?; + let settings = TerminalSettings::from_settings(&content); + let searches = RegexSearches::new( + &settings.path_hyperlink_regexes, + Duration::from_millis(settings.path_hyperlink_timeout_ms), + ); + let examples = [ + (r#"File "src/main.py", line 42"#, "src/main.py", Some("42")), + ("src/main.rs:42", "src/main.rs:42", None), + ]; + assert_eq!(searches.path_hyperlink_regexes.len(), examples.len()); + assert_eq!( + searches.path_hyperlink_regexes.len(), + settings.path_hyperlink_regexes.len() + ); + for (regex, (input, path, line)) in searches.path_hyperlink_regexes.iter().zip(examples) { + let captures = regex + .captures(input) + .ok_or_else(|| anyhow::anyhow!("Default regex does not match {input}"))?; + assert_eq!( + captures.name("path").map(|capture| capture.as_str()), + Some(path) + ); + assert_eq!(captures.name("line").map(|capture| capture.as_str()), line); + } + Ok(()) + } + #[test] fn test_url_regex() { re_test( diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index e9ac01b71f..6464d49e69 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -127,6 +127,7 @@ impl settings::Settings for TerminalSettings { path_hyperlink_regexes: project_content .path_hyperlink_regexes .unwrap() + .0 .into_iter() .map(|regex| match regex { PathHyperlinkRegex::SingleLine(regex) => regex, @@ -164,3 +165,164 @@ impl From for CursorShape { } } } + +#[cfg(test)] +mod tests { + use super::*; + use settings::{ + LocalSettingsKind, LocalSettingsPath, SettingsLocation, SettingsStore, WorktreeId, + }; + use util::rel_path::rel_path; + + fn path_regexes<'a>(store: &'a SettingsStore, path: Option<&str>) -> &'a [String] { + &store + .get::(path.map(|path| SettingsLocation { + worktree_id: WorktreeId::from_usize(1), + path: rel_path(path), + })) + .path_hyperlink_regexes + } + + #[gpui::test] + fn test_path_hyperlink_regexes_inherit_across_settings_layers(cx: &mut gpui::App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + let defaults = path_regexes(&store, None).to_vec(); + store + .set_user_settings( + r##"{"terminal": {"path_hyperlink_regexes": [ + ["(?x)", "# Keep this comment", "(?user)"], + "...", + "(?tail)", + "..." + ]}}"##, + cx, + ) + .expect("user terminal settings must parse"); + let user_regex = "(?x)\n# Keep this comment\n(?user)".to_owned(); + let mut user = vec![user_regex.clone()]; + user.extend(defaults.clone()); + user.push("(?tail)".into()); + assert_eq!(path_regexes(&store, None), user); + + for (path, content) in [ + ( + "project", + r#"{"terminal": {"path_hyperlink_regexes": [ + "(?project)", + "...", + ["..."], + "(?x)\n# Keep this comment\n(?user)" + ]}}"#, + ), + ( + "project/child", + r##"{"terminal": {"path_hyperlink_regexes": [ + ["(?x)", "# Keep this comment", "(?user)"], + "...", + ["..."], + ["(?x)", "# Keep this too", "(?child)", "..."] + ]}}"##, + ), + ] { + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path(path).into()), + LocalSettingsKind::Settings, + Some(content), + cx, + ) + .expect("project terminal settings must parse"); + } + + let mut project = vec!["(?project)".to_owned()]; + project.extend(user.clone()); + project.push("...".into()); + assert_eq!(path_regexes(&store, Some("project/file")), project); + + let mut child = vec![user_regex, "(?project)".to_owned()]; + child.extend(defaults); + child.extend([ + "(?tail)".into(), + "...".into(), + "(?x)\n# Keep this too\n(?child)\n...".into(), + ]); + assert_eq!(path_regexes(&store, Some("project/child/file")), child); + assert_eq!(path_regexes(&store, Some("elsewhere/file")), user); + } + + #[gpui::test] + fn test_path_hyperlink_regexes_replace_clear_and_omit(cx: &mut gpui::App) { + let mut store = SettingsStore::new(cx, &settings::default_settings()); + let defaults = path_regexes(&store, None).to_vec(); + store + .set_user_settings( + r#"{"terminal": {"path_hyperlink_regexes": ["(?user)"]}}"#, + cx, + ) + .expect("user terminal settings must parse"); + assert_eq!(path_regexes(&store, None), ["(?user)"]); + + for (path, content) in [ + ( + "project", + r#"{"terminal": {"path_hyperlink_regexes": ["(?project)"]}}"#, + ), + ( + "project/child", + r#"{"terminal": {"path_hyperlink_regexes": []}}"#, + ), + ( + "project/child/nested", + r#"{"terminal": {"path_hyperlink_regexes": [ + "...", ["(?x)", "...", "(?nested)"] + ]}}"#, + ), + ] { + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path(path).into()), + LocalSettingsKind::Settings, + Some(content), + cx, + ) + .expect("project terminal settings must parse"); + } + assert_eq!( + path_regexes(&store, Some("project/file")), + ["(?project)"] + ); + assert!(path_regexes(&store, Some("project/child/file")).is_empty()); + assert_eq!( + path_regexes(&store, Some("project/child/nested/file")), + ["(?x)\n...\n(?nested)"] + ); + assert_eq!(path_regexes(&store, None), ["(?user)"]); + + store + .set_local_settings( + WorktreeId::from_usize(1), + LocalSettingsPath::InWorktree(rel_path("project").into()), + LocalSettingsKind::Settings, + Some(r#"{"terminal": {"path_hyperlink_timeout_ms": 17}}"#), + cx, + ) + .expect("project terminal settings must parse"); + assert_eq!( + path_regexes(&store, Some("project/file")), + ["(?user)"] + ); + store + .set_user_settings(r#"{"terminal": {"path_hyperlink_regexes": []}}"#, cx) + .expect("user terminal settings must parse"); + assert!(path_regexes(&store, None).is_empty()); + assert!(path_regexes(&store, Some("project/file")).is_empty()); + store + .set_user_settings("{}", cx) + .expect("empty user settings must parse"); + assert_eq!(path_regexes(&store, None), defaults); + assert_eq!(path_regexes(&store, Some("project/file")), defaults); + assert!(path_regexes(&store, Some("project/child/file")).is_empty()); + } +} diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 7c11dae389..69d8eaa91c 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -5273,8 +5273,8 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` ### Terminal: Path Hyperlink Regexes -- Description: Regexes used to identify path hyperlinks. The regexes can be specified in two forms - a single regex string, or an array of strings (which will be collected into a single multi-line regex string). - Setting: `path_hyperlink_regexes` +- Description: Regexes used to identify paths for hyperlink navigation. - Default: ```json [settings] @@ -5287,31 +5287,49 @@ Example command to set the title: `echo -e "\e]2;New Title\007";` // surrounding symbols or quotes [ "(?x)", - "# optionally starts with 0-2 opening prefix symbols", - "[({\\[<]{0,2}", - "# which may be followed by an opening quote", - "(?[\"'`])?", - "# `path` is the shortest sequence of any non-space character", - "(?(?[^ ]+?", - " # which may end with a line and optionally a column,", - " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - "))", - "# which must be followed by a matching quote", - "(?()\\k)", - "# and optionally a single closing symbol", - "[)}\\]>]?", - "# if line/column matched, may be followed by a description", - "(?():[^ 0-9][^ ]*)?", - "# which may be followed by trailing punctuation", - "[.,:)}\\]>]*", - "# and always includes trailing whitespace or end of line", - "([ ]+|$)" + "(?", + " (", + " # multi-char path: first char (not opening delimiter, space, or box drawing char)", + " [^({\\[<\"'`\\ \\u2500-\\u257F]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren/space", + " ([^\\ :(]|[:(][^0-9()\\ ])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, space, or box drawing char", + " [^(){}\\[\\]<>\"'`.,;:\\ \\u2500-\\u257F]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")" ] ] } } ``` +Use `"..."` to add regexes without repeating Zed's defaults. In project settings, it extends the user or parent configuration value. Omit `"..."` to replace the inherited list. Set `[]` to clear it. Omitting this setting keeps the inherited list. + +```json [settings] +{ + "terminal": { + "path_hyperlink_regexes": [ + "\\s+(-->|:::|at) (?(?.+?))(:$|$)", + "\\s+(Compiling|Checking|Documenting) [^(]+\\((?(?.+))\\)", + "..." + ] + } +} +``` + +Inherited regexes are inserted at `"..."`, and duplicates keep their first occurrence. Regexes are duplicates when their text is identical after joining multiline entries with newlines. + +Each regex can be a single string or an array of strings joined with newlines. The marker is recognized only as a top-level string. To use `...` as a regex matching three characters, write `["..."]` as an entry. A `"..."` line inside a multiline entry is always regex text. + +The optional named capture `path` selects the hyperlink target. Without it, the entire match is the target. With `path`, `line` and `column` specify the position. Without a captured `line`, built-in suffix processing parses `line:column` and `(line,column)` variants. The optional `link` capture selects the clickable text, otherwise the entire match is clickable. + +Processing stops at the first regex that matches the terminal line, even if the cursor is outside its clickable text. Put more specific regexes before broader ones. Regexes use Rust's `regex` syntax. Invalid regexes are logged and ignored. The `path_hyperlink_timeout_ms` setting controls the discovery timeout. Setting it to `0` disables path hyperlinks. + ### Terminal: Path Hyperlink Timeout (ms) - Description: Maximum time to search for a path hyperlink. When set to 0, path hyperlinks are disabled.