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.