From 5eaab414fc9b42c2f8e50090f3fc494f77628f64 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:58:22 -0700 Subject: [PATCH] json_schema_store: match symlinked settings/keymap files for autocomplete When a config file is opened via its symlink path, schema_file_match returned only the path-as-given. The schema selector matches that string against fileMatch globs like "**/*settings.json", but the opened buffer URI is the canonicalized real path, so the schema never bound and autocomplete was disabled. Return both the original path and the canonicalized path (when they differ), so either form binds to the schema. No-op for non-symlinked paths. Closes #54888. --- Cargo.lock | 1 + crates/json_schema_store/Cargo.toml | 1 + .../src/json_schema_store.rs | 118 ++++++++++++++---- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 101071bcdb5..b082d2150e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9268,6 +9268,7 @@ dependencies = [ "settings", "snippet_provider", "task", + "tempfile", "theme", "util", ] diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml index 8180ffd7917..3669f8ca08d 100644 --- a/crates/json_schema_store/Cargo.toml +++ b/crates/json_schema_store/Cargo.toml @@ -17,6 +17,7 @@ default = [] [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } +tempfile.workspace = true [dependencies] anyhow.workspace = true diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index b0cd3c0b35c..9dc515c69f8 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -449,45 +449,48 @@ pub fn all_schema_file_associations( .flat_map(|(_, glob_strings)| glob_strings) .cloned(); let jsonc_globs = extension_globs.chain(override_globs).collect::>(); + let settings_file_matches = schema_file_match_entries(paths::settings_file()); + let keymap_file_matches = schema_file_match_entries(paths::keymap_file()); + let mut tasks_file_matches = schema_file_match_entries(paths::tasks_file()); + tasks_file_matches.push( + paths::local_tasks_file_relative_path() + .as_unix_str() + .to_string(), + ); + let mut debug_file_matches = schema_file_match_entries(paths::debug_scenarios_file()); + debug_file_matches.push( + paths::local_debug_file_relative_path() + .as_unix_str() + .to_string(), + ); + let snippet_file_matches = + schema_file_match_entries(paths::snippets_dir().join("*.json").as_path()); let mut file_associations = serde_json::json!([ { - "fileMatch": [ - schema_file_match(paths::settings_file()), - ], + "fileMatch": settings_file_matches, "url": format!("{SCHEMA_URI_PREFIX}settings"), }, { "fileMatch": [ - paths::local_settings_file_relative_path()], + paths::local_settings_file_relative_path() + ], "url": format!("{SCHEMA_URI_PREFIX}project_settings"), }, { - "fileMatch": [schema_file_match(paths::keymap_file())], + "fileMatch": keymap_file_matches, "url": format!("{SCHEMA_URI_PREFIX}keymap"), }, { - "fileMatch": [ - schema_file_match(paths::tasks_file()), - paths::local_tasks_file_relative_path() - ], + "fileMatch": tasks_file_matches, "url": format!("{SCHEMA_URI_PREFIX}tasks"), }, { - "fileMatch": [ - schema_file_match(paths::debug_scenarios_file()), - paths::local_debug_file_relative_path() - ], + "fileMatch": debug_file_matches, "url": format!("{SCHEMA_URI_PREFIX}debug_tasks"), }, { - "fileMatch": [ - schema_file_match( - paths::snippets_dir() - .join("*.json") - .as_path() - ) - ], + "fileMatch": snippet_file_matches, "url": format!("{SCHEMA_URI_PREFIX}snippets"), }, { @@ -619,11 +622,80 @@ fn root_schema_from_action_schema( schema } +/// Build the LSP fileMatch entries for `path`. +/// +/// The JSON LSP matches incoming file URIs against these glob patterns, +/// so we register both the symlinked location (if any) and the resolved +/// canonical path. Without this, opening `~/.config/zed/settings.json` +/// when it is a symlink to a file under a different directory no longer +/// binds the settings schema (zed-industries/zed#54888). +fn schema_file_match_entries(path: &std::path::Path) -> Vec { + let mut out = Vec::with_capacity(2); + out.push(stripped_match(path)); + if let Ok(canonical) = path.canonicalize() { + if canonical != path { + out.push(stripped_match(&canonical)); + } + } + out +} + #[inline] -fn schema_file_match(path: &std::path::Path) -> String { - path.strip_prefix(path.parent().unwrap().parent().unwrap()) - .unwrap() +fn stripped_match(path: &std::path::Path) -> String { + let parent = path.parent().and_then(|p| p.parent()).unwrap_or(path); + path.strip_prefix(parent) + .unwrap_or(path) .display() .to_string() .replace('\\', "/") } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + #[test] + fn stripped_match_drops_two_parent_components() { + let path = PathBuf::from("/home/user/.config/zed/settings.json"); + assert_eq!(stripped_match(&path), "zed/settings.json"); + } + + #[test] + fn schema_file_match_entries_returns_single_for_regular_file() { + let tmp = tempfile::TempDir::new().unwrap(); + // Some platforms expose the temp directory through a symlinked prefix. + // Canonicalize the root so this test only covers non-symlinked files. + let root = tmp.path().canonicalize().unwrap(); + let zed_dir = root.join("zed"); + fs::create_dir(&zed_dir).unwrap(); + let regular = zed_dir.join("settings.json"); + fs::write(®ular, "{}").unwrap(); + let entries = schema_file_match_entries(®ular); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], "zed/settings.json"); + } + + #[test] + fn schema_file_match_entries_returns_both_for_symlink() { + let tmp = tempfile::TempDir::new().unwrap(); + let zed_dir = tmp.path().join("zed"); + fs::create_dir(&zed_dir).unwrap(); + let target = tmp.path().join("settings_target.json"); + fs::write(&target, "{}").unwrap(); + let link = zed_dir.join("settings.json"); + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &link).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(&target, &link).unwrap(); + let entries = schema_file_match_entries(&link); + assert!(entries.iter().any(|entry| entry == "zed/settings.json")); + assert!( + entries + .iter() + .any(|entry| entry.ends_with("settings_target.json")) + ); + assert_eq!(entries.len(), 2); + } +}