Add support for the "..." entry in terminal.path_hyperlink_regexes (#64552)

## 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.
This commit is contained in:
Dom Porada authored and GitHub committed 2026-09-21 16:10:07 +00:00
1 parent a596e1b294
commit b015cdf8be
5 files changed
+395 -55

No files matched your search

+152 -31
View File
@@ -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<VenvSettings>,
/// Regexes used to identify paths for hyperlink navigation.
///
/// Default: [
/// // Python-style diagnostics
/// "File \"(?<path>[^\"]+)\", line (?<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",
/// "(?<quote>[\"'`])?",
/// "# `path` is the shortest sequence of any non-space character",
/// "(?<link>(?<path>[^ ]+?",
/// " # which may end with a line and optionally a column,",
/// " (?<line_column>:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?",
/// "))",
/// "# which must be followed by a matching quote",
/// "(?(<quote>)\\k<quote>)",
/// "# and optionally a single closing symbol",
/// "[)}\\]>]?",
/// "# if line/column matched, may be followed by a description",
/// "(?(<line_column>):[^ 0-9][^ ]*)?",
/// "# which may be followed by trailing punctuation",
/// "[.,:)}\\]>]*",
/// "# and always includes trailing whitespace or end of line",
/// "([ ]+|$)"
/// ]
/// ]
pub path_hyperlink_regexes: Option<Vec<PathHyperlinkRegex>>,
/// Default:
///
/// ```json
/// {
/// "terminal": {
/// "path_hyperlink_regexes": [
/// // Python-style diagnostics
/// "File \"(?<path>[^\"]+)\", line (?<line>[0-9]+)",
/// // Common path syntax with optional line, column, description, trailing punctuation, or
/// // surrounding symbols or quotes
/// [
/// "(?x)",
/// "(?<path>",
/// " (",
/// " # 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) (?<link>(?<path>.+?))(:$|$)",
/// "\\s+(Compiling|Checking|Documenting) [^(]+\\((?<link>(?<path>.+))\\)",
/// "..."
/// ]
/// }
/// }
/// ```
///
/// 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<PathHyperlinkRegexes>,
/// 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<PathHyperlinkRegex>);
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<String>),
}
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", "...", "(?<path>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", "(?<path>file)"]
]))?;
regexes.merge_from(&serde_json::from_value(json!([
["first", "second"],
"...",
"(?x)\n# comment\n(?<path>file)",
"..."
]))?);
assert_eq!(
serde_json::to_value(regexes)?,
json!([
["first", "second"],
"...",
["..."],
["(?x)", "# comment", "(?<path>file)"]
])
);
Ok(())
}
#[test]
#[ignore]
@@ -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(
+162
View File
@@ -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<settings::CursorShapeContent> 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::<TerminalSettings>(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", "(?<path>user)"],
"...",
"(?<path>tail)",
"..."
]}}"##,
cx,
)
.expect("user terminal settings must parse");
let user_regex = "(?x)\n# Keep this comment\n(?<path>user)".to_owned();
let mut user = vec![user_regex.clone()];
user.extend(defaults.clone());
user.push("(?<path>tail)".into());
assert_eq!(path_regexes(&store, None), user);
for (path, content) in [
(
"project",
r#"{"terminal": {"path_hyperlink_regexes": [
"(?<path>project)",
"...",
["..."],
"(?x)\n# Keep this comment\n(?<path>user)"
]}}"#,
),
(
"project/child",
r##"{"terminal": {"path_hyperlink_regexes": [
["(?x)", "# Keep this comment", "(?<path>user)"],
"...",
["..."],
["(?x)", "# Keep this too", "(?<path>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!["(?<path>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, "(?<path>project)".to_owned()];
child.extend(defaults);
child.extend([
"(?<path>tail)".into(),
"...".into(),
"(?x)\n# Keep this too\n(?<path>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": ["(?<path>user)"]}}"#,
cx,
)
.expect("user terminal settings must parse");
assert_eq!(path_regexes(&store, None), ["(?<path>user)"]);
for (path, content) in [
(
"project",
r#"{"terminal": {"path_hyperlink_regexes": ["(?<path>project)"]}}"#,
),
(
"project/child",
r#"{"terminal": {"path_hyperlink_regexes": []}}"#,
),
(
"project/child/nested",
r#"{"terminal": {"path_hyperlink_regexes": [
"...", ["(?x)", "...", "(?<path>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")),
["(?<path>project)"]
);
assert!(path_regexes(&store, Some("project/child/file")).is_empty());
assert_eq!(
path_regexes(&store, Some("project/child/nested/file")),
["(?x)\n...\n(?<path>nested)"]
);
assert_eq!(path_regexes(&store, None), ["(?<path>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")),
["(?<path>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());
}
}