Cache dynamic JSON schemas with invalidation (#46824)

Richard Feldman and Ben Kunkle created

Dynamic schemas (settings, settings/lsp/*, debug_tasks) are now cached
after first generation and returned immediately via Task::ready() on
subsequent requests.

Cache is invalidated when `notify_schema_changed()` is called, which
happens when:
- Extensions are installed/uninstalled (affects settings schema)
- DAP registry changes (affects debug_tasks schema)

This eliminates repeated foreground thread blocking for the same dynamic
schema.

**Note:** This PR is based on #46823 and should be merged after it.

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/json_schema_store/src/json_schema_store.rs | 135 ++++++++++------
1 file changed, 85 insertions(+), 50 deletions(-)

Detailed changes

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -1,4 +1,3 @@
-//! # json_schema_store
 use std::sync::{Arc, LazyLock};
 
 use anyhow::{Context as _, Result};
@@ -10,6 +9,8 @@ use project::{LspStore, lsp_store::LocalLspAdapterDelegate};
 use settings::LSP_SETTINGS_SCHEMA_URL_PREFIX;
 use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields};
 
+const SCHEMA_URI_PREFIX: &str = "zed://schemas/";
+
 const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json");
 const PACKAGE_JSON_SCHEMA: &str = include_str!("schemas/package.json");
 
@@ -41,6 +42,14 @@ static KEYMAP_SCHEMA: LazyLock<String> = LazyLock::new(|| {
 static ACTION_SCHEMA_CACHE: LazyLock<RwLock<HashMap<String, String>>> =
     LazyLock::new(|| RwLock::new(HashMap::default()));
 
+// Runtime cache for dynamic schemas that depend on runtime state:
+// - "settings": depends on installed fonts, themes, languages, LSP adapters (extensions can add these)
+// - "settings/lsp/*": depends on LSP adapter initialization options
+// - "debug_tasks": depends on DAP adapters (extensions can add these)
+// Cache is invalidated via notify_schema_changed() when extensions or DAP registry change.
+static DYNAMIC_SCHEMA_CACHE: LazyLock<RwLock<HashMap<String, String>>> =
+    LazyLock::new(|| RwLock::new(HashMap::default()));
+
 pub fn init(cx: &mut App) {
     cx.set_global(SchemaStore::default());
     project::lsp_store::json_language_server_ext::register_schema_handler(
@@ -55,7 +64,7 @@ pub fn init(cx: &mut App) {
     .detach();
 
     if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) {
-        cx.subscribe(&extension_events, |_, evt, cx| {
+        cx.subscribe(&extension_events, move |_, evt, cx| {
             match evt {
                 extension::Event::ExtensionInstalled(_)
                 | extension::Event::ExtensionUninstalled(_)
@@ -63,15 +72,15 @@ pub fn init(cx: &mut App) {
                 extension::Event::ExtensionsInstalledChanged => {}
             }
             cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-                schema_store.notify_schema_changed("zed://schemas/settings", cx);
+                schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}settings"), cx);
             });
         })
         .detach();
     }
 
-    cx.observe_global::<dap::DapRegistry>(|cx| {
+    cx.observe_global::<dap::DapRegistry>(move |cx| {
         cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-            schema_store.notify_schema_changed("zed://schemas/debug_tasks", cx);
+            schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}debug_tasks"), cx);
         });
     })
     .detach();
@@ -86,6 +95,8 @@ impl gpui::Global for SchemaStore {}
 
 impl SchemaStore {
     fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) {
+        DYNAMIC_SCHEMA_CACHE.write().remove(uri);
+
         let uri = uri.to_string();
         self.lsp_stores.retain(|lsp_store| {
             let Some(lsp_store) = lsp_store.upgrade() else {
@@ -106,38 +117,65 @@ pub fn handle_schema_request(
     uri: String,
     cx: &mut AsyncApp,
 ) -> Task<Result<String>> {
-    let path = match uri.strip_prefix("zed://schemas/") {
+    let path = match uri.strip_prefix(SCHEMA_URI_PREFIX) {
         Some(path) => path,
-        None => return Task::ready(Err(anyhow::anyhow!("Invalid URI: {}", uri))),
+        None => return Task::ready(Err(anyhow::anyhow!("Invalid schema URI: {}", uri))),
     };
 
+    if let Some(json) = resolve_static_schema(path) {
+        return Task::ready(Ok(json));
+    }
+
+    if let Some(cached) = DYNAMIC_SCHEMA_CACHE.read().get(&uri).cloned() {
+        return Task::ready(Ok(cached));
+    }
+
+    let path = path.to_string();
+    let uri_clone = uri.clone();
+    cx.spawn(async move |cx| {
+        let schema = resolve_dynamic_schema(lsp_store, &path, cx).await?;
+        let json = serde_json::to_string(&schema).context("Failed to serialize schema")?;
+
+        DYNAMIC_SCHEMA_CACHE.write().insert(uri_clone, json.clone());
+
+        Ok(json)
+    })
+}
+
+fn resolve_static_schema(path: &str) -> Option<String> {
     let (schema_name, rest) = path.split_once('/').unzip();
     let schema_name = schema_name.unwrap_or(path);
 
     match schema_name {
-        "tsconfig" => return Task::ready(Ok(TSCONFIG_SCHEMA.to_string())),
-        "package_json" => return Task::ready(Ok(PACKAGE_JSON_SCHEMA.to_string())),
-        "tasks" => return Task::ready(Ok(TASKS_SCHEMA.clone())),
-        "snippets" => return Task::ready(Ok(SNIPPETS_SCHEMA.clone())),
-        "jsonc" => return Task::ready(Ok(JSONC_SCHEMA.clone())),
-        "keymap" => return Task::ready(Ok(KEYMAP_SCHEMA.clone())),
+        "tsconfig" => Some(TSCONFIG_SCHEMA.to_string()),
+        "package_json" => Some(PACKAGE_JSON_SCHEMA.to_string()),
+        "tasks" => Some(TASKS_SCHEMA.clone()),
+        "snippets" => Some(SNIPPETS_SCHEMA.clone()),
+        "jsonc" => Some(JSONC_SCHEMA.clone()),
+        "keymap" => Some(KEYMAP_SCHEMA.clone()),
         "zed_inspector_style" => {
             #[cfg(debug_assertions)]
-            return Task::ready(Ok(INSPECTOR_STYLE_SCHEMA.clone()));
+            {
+                Some(INSPECTOR_STYLE_SCHEMA.clone())
+            }
             #[cfg(not(debug_assertions))]
-            return Task::ready(Ok(serde_json::to_string(
-                &schemars::json_schema!(true).to_value(),
-            )
-            .expect("true schema should serialize")));
+            {
+                Some(
+                    serde_json::to_string(&schemars::json_schema!(true).to_value())
+                        .expect("true schema should serialize"),
+                )
+            }
         }
+
         "action" => {
             let normalized_action_name = match rest {
                 Some(name) => name,
-                None => return Task::ready(Err(anyhow::anyhow!("No action name provided"))),
+                None => return None,
             };
             let action_name = denormalize_action_name(normalized_action_name);
+
             if let Some(cached) = ACTION_SCHEMA_CACHE.read().get(&action_name).cloned() {
-                return Task::ready(Ok(cached));
+                return Some(cached);
             }
 
             let mut generator = settings::KeymapFile::action_schema_generator();
@@ -151,34 +189,31 @@ pub fn handle_schema_request(
             ACTION_SCHEMA_CACHE
                 .write()
                 .insert(action_name, json.clone());
-            return Task::ready(Ok(json));
+            Some(json)
         }
-        _ => {}
-    }
 
-    let schema_name = schema_name.to_string();
-    let rest = rest.map(|s| s.to_string());
-    cx.spawn(async move |cx| {
-        let schema = resolve_dynamic_schema(lsp_store, &schema_name, rest.as_deref(), cx).await?;
-        serde_json::to_string(&schema).context("Failed to serialize schema")
-    })
+        _ => None,
+    }
 }
 
 async fn resolve_dynamic_schema(
     lsp_store: Entity<LspStore>,
-    schema_name: &str,
-    rest: Option<&str>,
+    path: &str,
     cx: &mut AsyncApp,
 ) -> Result<serde_json::Value> {
-    let languages = lsp_store.read_with(cx, |store, _| store.languages.clone());
+    let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone());
+    let (schema_name, rest) = path.split_once('/').unzip();
+    let schema_name = schema_name.unwrap_or(path);
+
     let schema = match schema_name {
         "settings" if rest.is_some_and(|r| r.starts_with("lsp/")) => {
             let lsp_name = rest
                 .and_then(|r| {
                     r.strip_prefix(
                         LSP_SETTINGS_SCHEMA_URL_PREFIX
-                            .strip_prefix("zed://schemas/settings/")
-                            .unwrap(),
+                            .strip_prefix(SCHEMA_URI_PREFIX)
+                            .and_then(|s| s.strip_prefix("settings/"))
+                            .unwrap_or("lsp/"),
                     )
                 })
                 .context("Invalid LSP schema path")?;
@@ -266,7 +301,7 @@ async fn resolve_dynamic_schema(
             task::DebugTaskFile::generate_json_schema(&adapter_schemas)
         }
         _ => {
-            anyhow::bail!("Unrecognized builtin JSON schema: {schema_name}");
+            anyhow::bail!("Unrecognized schema: {schema_name}");
         }
     };
     Ok(schema)
@@ -299,25 +334,25 @@ pub fn all_schema_file_associations(
                 schema_file_match(paths::settings_file()),
                 paths::local_settings_file_relative_path()
             ],
-            "url": "zed://schemas/settings",
+            "url": format!("{SCHEMA_URI_PREFIX}settings"),
         },
         {
             "fileMatch": [schema_file_match(paths::keymap_file())],
-            "url": "zed://schemas/keymap",
+            "url": format!("{SCHEMA_URI_PREFIX}keymap"),
         },
         {
             "fileMatch": [
                 schema_file_match(paths::tasks_file()),
                 paths::local_tasks_file_relative_path()
             ],
-            "url": "zed://schemas/tasks",
+            "url": format!("{SCHEMA_URI_PREFIX}tasks"),
         },
         {
             "fileMatch": [
                 schema_file_match(paths::debug_scenarios_file()),
                 paths::local_debug_file_relative_path()
             ],
-            "url": "zed://schemas/debug_tasks",
+            "url": format!("{SCHEMA_URI_PREFIX}debug_tasks"),
         },
         {
             "fileMatch": [
@@ -327,19 +362,19 @@ pub fn all_schema_file_associations(
                         .as_path()
                 )
             ],
-            "url": "zed://schemas/snippets",
+            "url": format!("{SCHEMA_URI_PREFIX}snippets"),
         },
         {
             "fileMatch": ["tsconfig.json"],
-            "url": "zed://schemas/tsconfig"
+            "url": format!("{SCHEMA_URI_PREFIX}tsconfig")
         },
         {
             "fileMatch": ["package.json"],
-            "url": "zed://schemas/package_json"
+            "url": format!("{SCHEMA_URI_PREFIX}package_json")
         },
         {
             "fileMatch": &jsonc_globs,
-            "url": "zed://schemas/jsonc"
+            "url": format!("{SCHEMA_URI_PREFIX}jsonc")
         },
     ]);
 
@@ -352,21 +387,21 @@ pub fn all_schema_file_associations(
                 "fileMatch": [
                     "zed-inspector-style.json"
                 ],
-                "url": "zed://schemas/zed_inspector_style"
+                "url": format!("{SCHEMA_URI_PREFIX}zed_inspector_style")
             }));
     }
 
-    file_associations.as_array_mut().unwrap().extend(
-        // ?PERF: use all_action_schemas() and don't include action schemas with no arguments
-        cx.all_action_names().into_iter().map(|&name| {
+    file_associations
+        .as_array_mut()
+        .unwrap()
+        .extend(cx.all_action_names().into_iter().map(|&name| {
             let normalized_name = normalize_action_name(name);
             let file_name = normalized_action_name_to_file_name(normalized_name.clone());
             serde_json::json!({
                 "fileMatch": [file_name],
-                "url": format!("zed://schemas/action/{normalized_name}")
+                "url": format!("{}action/{normalized_name}", SCHEMA_URI_PREFIX)
             })
-        }),
-    );
+        }));
 
     file_associations
 }