extension_api: Add language server schema methods (#48334)

Shuhei Kadowaki , Claude Opus 4.5 , and MrSubidubi created

(This should be merged after #48332)

This PR exposes the LSP settings schema functionality to extensions,
allowing them to provide JSON schema for `initialization_options` and
`settings` fields to enable autocomplete in settings files.

New extension API methods (v0.8.0+):
- `language_server_initialization_options_schema`
- `language_server_settings_schema`

Both methods return an optional JSON string conforming to JSON schema.
Older extension versions gracefully return `None`.

Release Notes:

- Added support for settings schemas for the next version of the
extension API so that settings autocompletion can be provided for
language server settings.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: MrSubidubi <finn@zed.dev>

Change summary

crates/extension/src/extension.rs                        | 12 ++
crates/extension_api/src/extension_api.rs                | 42 +++++++
crates/extension_api/wit/since_v0.8.0/extension.wit      | 10 +
crates/extension_host/src/wasm_host.rs                   | 42 +++++++
crates/extension_host/src/wasm_host/wit.rs               | 54 +++++++++
crates/json_schema_store/src/json_schema_store.rs        | 60 ++++++---
crates/language_extension/src/extension_lsp_adapter.rs   | 38 ++++++
crates/project/src/lsp_store/json_language_server_ext.rs | 24 ++-
8 files changed, 252 insertions(+), 30 deletions(-)

Detailed changes

crates/extension/src/extension.rs 🔗

@@ -80,6 +80,18 @@ pub trait Extension: Send + Sync + 'static {
         worktree: Arc<dyn WorktreeDelegate>,
     ) -> Result<Option<String>>;
 
+    async fn language_server_initialization_options_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>>;
+
+    async fn language_server_workspace_configuration_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>>;
+
     async fn language_server_additional_initialization_options(
         &self,
         language_server_id: LanguageServerName,

crates/extension_api/src/extension_api.rs 🔗

@@ -100,6 +100,28 @@ pub trait Extension: Send + Sync {
         Ok(None)
     }
 
+    /// Returns the JSON schema for the initialization options.
+    ///
+    /// The schema must conform to the JSON Schema speification.
+    fn language_server_initialization_options_schema(
+        &mut self,
+        _language_server_id: &LanguageServerId,
+        _worktree: &Worktree,
+    ) -> Option<serde_json::Value> {
+        None
+    }
+
+    /// Returns the JSON schema for the workspace configuration.
+    ///
+    /// The schema must conform to the JSON Schema specification.
+    fn language_server_workspace_configuration_schema(
+        &mut self,
+        _language_server_id: &LanguageServerId,
+        _worktree: &Worktree,
+    ) -> Option<serde_json::Value> {
+        None
+    }
+
     /// Returns the initialization options to pass to the other language server.
     fn language_server_additional_initialization_options(
         &mut self,
@@ -370,6 +392,26 @@ impl wit::Guest for Component {
             .and_then(|value| serde_json::to_string(&value).ok()))
     }
 
+    fn language_server_initialization_options_schema(
+        language_server_id: String,
+        worktree: &Worktree,
+    ) -> Option<String> {
+        let language_server_id = LanguageServerId(language_server_id);
+        extension()
+            .language_server_initialization_options_schema(&language_server_id, worktree)
+            .and_then(|value| serde_json::to_string(&value).ok())
+    }
+
+    fn language_server_workspace_configuration_schema(
+        language_server_id: String,
+        worktree: &Worktree,
+    ) -> Option<String> {
+        let language_server_id = LanguageServerId(language_server_id);
+        extension()
+            .language_server_workspace_configuration_schema(&language_server_id, worktree)
+            .and_then(|value| serde_json::to_string(&value).ok())
+    }
+
     fn language_server_additional_initialization_options(
         language_server_id: String,
         target_language_server_id: String,

crates/extension_api/wit/since_v0.8.0/extension.wit 🔗

@@ -101,6 +101,16 @@ world extension {
     /// Returns the workspace configuration options to pass to the language server.
     export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
 
+    /// Returns the JSON schema for the initialization options.
+    ///
+    /// The schema is represented as a JSON string conforming to the JSON Schema specification.
+    export language-server-initialization-options-schema: func(language-server-id: string, worktree: borrow<worktree>) -> option<string>;
+
+    /// Returns the JSON schema for the workspace configuration.
+    ///
+    /// The schema is represented as a JSON string conforming to the JSON Schema specification.
+    export language-server-workspace-configuration-schema: func(language-server-id: string, worktree: borrow<worktree>) -> option<string>;
+
     /// Returns the initialization options to pass to the other language server.
     export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow<worktree>) -> result<option<string>, string>;
 

crates/extension_host/src/wasm_host.rs 🔗

@@ -159,6 +159,48 @@ impl extension::Extension for WasmExtension {
         .await?
     }
 
+    async fn language_server_initialization_options_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>> {
+        self.call(|extension, store| {
+            async move {
+                let resource = store.data_mut().table().push(worktree)?;
+                extension
+                    .call_language_server_initialization_options_schema(
+                        store,
+                        &language_server_id,
+                        resource,
+                    )
+                    .await
+            }
+            .boxed()
+        })
+        .await?
+    }
+
+    async fn language_server_workspace_configuration_schema(
+        &self,
+        language_server_id: LanguageServerName,
+        worktree: Arc<dyn WorktreeDelegate>,
+    ) -> Result<Option<String>> {
+        self.call(|extension, store| {
+            async move {
+                let resource = store.data_mut().table().push(worktree)?;
+                extension
+                    .call_language_server_workspace_configuration_schema(
+                        store,
+                        &language_server_id,
+                        resource,
+                    )
+                    .await
+            }
+            .boxed()
+        })
+        .await?
+    }
+
     async fn language_server_additional_initialization_options(
         &self,
         language_server_id: LanguageServerName,

crates/extension_host/src/wasm_host/wit.rs 🔗

@@ -465,6 +465,60 @@ impl Extension {
         }
     }
 
+    pub async fn call_language_server_initialization_options_schema(
+        &self,
+        store: &mut Store<WasmState>,
+        language_server_id: &LanguageServerName,
+        resource: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<Option<String>> {
+        match self {
+            Extension::V0_8_0(ext) => {
+                ext.call_language_server_initialization_options_schema(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
+            Extension::V0_6_0(_)
+            | Extension::V0_5_0(_)
+            | Extension::V0_4_0(_)
+            | Extension::V0_3_0(_)
+            | Extension::V0_2_0(_)
+            | Extension::V0_1_0(_)
+            | Extension::V0_0_6(_)
+            | Extension::V0_0_4(_)
+            | Extension::V0_0_1(_) => Ok(None),
+        }
+    }
+
+    pub async fn call_language_server_workspace_configuration_schema(
+        &self,
+        store: &mut Store<WasmState>,
+        language_server_id: &LanguageServerName,
+        resource: Resource<Arc<dyn WorktreeDelegate>>,
+    ) -> Result<Option<String>> {
+        match self {
+            Extension::V0_8_0(ext) => {
+                ext.call_language_server_workspace_configuration_schema(
+                    store,
+                    &language_server_id.0,
+                    resource,
+                )
+                .await
+            }
+            Extension::V0_6_0(_)
+            | Extension::V0_5_0(_)
+            | Extension::V0_4_0(_)
+            | Extension::V0_3_0(_)
+            | Extension::V0_2_0(_)
+            | Extension::V0_1_0(_)
+            | Extension::V0_0_6(_)
+            | Extension::V0_0_4(_)
+            | Extension::V0_0_1(_) => Ok(None),
+        }
+    }
+
     pub async fn call_language_server_additional_initialization_options(
         &self,
         store: &mut Store<WasmState>,

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -67,25 +67,22 @@ pub fn init(cx: &mut App) {
     .detach();
 
     if let Some(extension_events) = extension::ExtensionEvents::try_global(cx) {
-        cx.subscribe(&extension_events, move |_, evt, cx| {
-            match evt {
-                extension::Event::ExtensionInstalled(_)
-                | extension::Event::ExtensionUninstalled(_)
-                | extension::Event::ConfigureExtensionRequested(_) => return,
-                extension::Event::ExtensionsInstalledChanged => {}
+        cx.subscribe(&extension_events, move |_, evt, cx| match evt {
+            extension::Event::ExtensionsInstalledChanged => {
+                cx.update_global::<SchemaStore, _>(|schema_store, cx| {
+                    schema_store.notify_schema_changed(ChangedSchemas::Settings, cx);
+                });
             }
-            cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-                schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}settings"), cx);
-                schema_store
-                    .notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}project_settings"), cx);
-            });
+            extension::Event::ExtensionUninstalled(_)
+            | extension::Event::ExtensionInstalled(_)
+            | extension::Event::ConfigureExtensionRequested(_) => {}
         })
         .detach();
     }
 
     cx.observe_global::<dap::DapRegistry>(move |cx| {
         cx.update_global::<SchemaStore, _>(|schema_store, cx| {
-            schema_store.notify_schema_changed(&format!("{SCHEMA_URI_PREFIX}debug_tasks"), cx);
+            schema_store.notify_schema_changed(ChangedSchemas::DebugTasks, cx);
         });
     })
     .detach();
@@ -98,18 +95,42 @@ pub struct SchemaStore {
 
 impl gpui::Global for SchemaStore {}
 
+enum ChangedSchemas {
+    Settings,
+    DebugTasks,
+}
+
 impl SchemaStore {
-    fn notify_schema_changed(&mut self, uri: &str, cx: &mut App) {
-        DYNAMIC_SCHEMA_CACHE.write().remove(uri);
+    fn notify_schema_changed(&mut self, changed_schemas: ChangedSchemas, cx: &mut App) {
+        let uris_to_invalidate = match changed_schemas {
+            ChangedSchemas::Settings => {
+                let settings_uri_prefix = &format!("{SCHEMA_URI_PREFIX}settings");
+                let project_settings_uri = &format!("{SCHEMA_URI_PREFIX}project_settings");
+                DYNAMIC_SCHEMA_CACHE
+                    .write()
+                    .extract_if(|uri, _| {
+                        uri == project_settings_uri || uri.starts_with(settings_uri_prefix)
+                    })
+                    .map(|(url, _)| url)
+                    .collect()
+            }
+            ChangedSchemas::DebugTasks => DYNAMIC_SCHEMA_CACHE
+                .write()
+                .remove_entry(&format!("{SCHEMA_URI_PREFIX}debug_tasks"))
+                .map_or_else(Vec::new, |(uri, _)| vec![uri]),
+        };
+
+        if uris_to_invalidate.is_empty() {
+            return;
+        }
 
-        let uri = uri.to_string();
         self.lsp_stores.retain(|lsp_store| {
             let Some(lsp_store) = lsp_store.upgrade() else {
                 return false;
             };
-            project::lsp_store::json_language_server_ext::notify_schema_changed(
+            project::lsp_store::json_language_server_ext::notify_schemas_changed(
                 lsp_store,
-                uri.clone(),
+                &uris_to_invalidate,
                 cx,
             );
             true
@@ -238,7 +259,8 @@ async fn resolve_dynamic_schema(
                 (adapter_name, LspSchemaKind::Settings)
             } else {
                 anyhow::bail!(
-                    "Invalid LSP schema path: expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'",
+                    "Invalid LSP schema path: \
+                    Expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'",
                     lsp_path
                 );
             };
@@ -484,7 +506,7 @@ pub fn all_schema_file_associations(
             let file_name = normalized_action_name_to_file_name(normalized_name.clone());
             serde_json::json!({
                 "fileMatch": [file_name],
-                "url": format!("{}action/{normalized_name}", SCHEMA_URI_PREFIX)
+                "url": format!("{SCHEMA_URI_PREFIX}action/{normalized_name}")
             })
         }));
 

crates/language_extension/src/extension_lsp_adapter.rs 🔗

@@ -350,6 +350,44 @@ impl LspAdapter for ExtensionLspAdapter {
         })
     }
 
+    async fn initialization_options_schema(
+        self: Arc<Self>,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        _cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+        _cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
+        let json_schema: Option<String> = self
+            .extension
+            .language_server_initialization_options_schema(
+                self.language_server_id.clone(),
+                delegate,
+            )
+            .await
+            .ok()
+            .flatten();
+        json_schema.and_then(|s| serde_json::from_str(&s).ok())
+    }
+
+    async fn settings_schema(
+        self: Arc<Self>,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        _cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+        _cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        let delegate = Arc::new(WorktreeDelegateAdapter(delegate.clone())) as _;
+        let json_schema: Option<String> = self
+            .extension
+            .language_server_workspace_configuration_schema(
+                self.language_server_id.clone(),
+                delegate,
+            )
+            .await
+            .ok()
+            .flatten();
+        json_schema.and_then(|s| serde_json::from_str(&s).ok())
+    }
+
     async fn additional_initialization_options(
         self: Arc<Self>,
         target_language_server_id: LanguageServerName,

crates/project/src/lsp_store/json_language_server_ext.rs 🔗

@@ -42,8 +42,8 @@ impl lsp::notification::Notification for SchemaContentsChanged {
     type Params = String;
 }
 
-pub fn notify_schema_changed(lsp_store: Entity<LspStore>, uri: String, cx: &App) {
-    zlog::trace!(LOGGER => "Notifying schema changed for URI: {:?}", uri);
+pub fn notify_schemas_changed(lsp_store: Entity<LspStore>, uris: &[String], cx: &App) {
+    zlog::trace!(LOGGER => "Notifying schema changes for URIs: {:?}", uris);
     let servers = lsp_store.read_with(cx, |lsp_store, _| {
         let mut servers = Vec::new();
         let Some(local) = lsp_store.as_local() else {
@@ -63,16 +63,18 @@ pub fn notify_schema_changed(lsp_store: Entity<LspStore>, uri: String, cx: &App)
         servers
     });
     for server in servers {
-        zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}",
-            NAME = server.name(),
-            ID = server.server_id()
-        );
-        if let Err(error) = server.notify::<SchemaContentsChanged>(uri.clone()) {
-            zlog::error!(
-                LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}",
-                    NAME = server.name(),
-                    ID = server.server_id(),
+        for uri in uris {
+            zlog::trace!(LOGGER => "Notifying server {NAME} (id {ID:?}) of schema change for URI: {uri:?}",
+                NAME = server.name(),
+                ID = server.server_id()
             );
+            if let Err(error) = server.notify::<SchemaContentsChanged>(uri.clone()) {
+                zlog::error!(
+                    LOGGER => "Failed to notify server {NAME} (id {ID:?}) of schema change for URI {uri:?}: {error:#}",
+                        NAME = server.name(),
+                        ID = server.server_id(),
+                );
+            }
         }
     }
 }