lsp: Add schema support for LSP settings field (#48332)

Shuhei Kadowaki and Claude Opus 4.5 created

This extends the LSP settings schema system to also provide autocomplete
for the `settings` field (used for `workspace/configuration` responses),
in addition to the existing `initialization_options` support (#).

Changes:
- Add `settings_schema` method to `LspAdapter` trait and
`CachedLspAdapter`
- Update schema URL paths to be more explicit:
  - `lsp/{adapter}/initialization_options` for init options schema
  - `lsp/{adapter}/settings` for settings schema
- Add schema resolution logic for the new settings path
- Update tests to verify both schema references

Release Notes:

- Added autocomplete support for the `settings` field in LSP
configuration, complementing the existing `initialization_options`
autocomplete.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

Change summary

crates/json_schema_store/src/json_schema_store.rs | 42 ++++++++++++---
crates/language/src/language.rs                   | 23 ++++++++
crates/settings/src/settings_store.rs             | 44 +++++++++++++++-
3 files changed, 97 insertions(+), 12 deletions(-)

Detailed changes

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -209,7 +209,7 @@ async fn resolve_dynamic_schema(
 
     let schema = match schema_name {
         "settings" if rest.is_some_and(|r| r.starts_with("lsp/")) => {
-            let lsp_name = rest
+            let lsp_path = rest
                 .and_then(|r| {
                     r.strip_prefix(
                         LSP_SETTINGS_SCHEMA_URL_PREFIX
@@ -220,6 +220,26 @@ async fn resolve_dynamic_schema(
                 })
                 .context("Invalid LSP schema path")?;
 
+            // Parse the schema type from the path:
+            // - "rust-analyzer/initialization_options" → initialization_options_schema
+            // - "rust-analyzer/settings" → settings_schema
+            enum LspSchemaKind {
+                InitializationOptions,
+                Settings,
+            }
+            let (lsp_name, schema_kind) = if let Some(adapter_name) =
+                lsp_path.strip_suffix("/initialization_options")
+            {
+                (adapter_name, LspSchemaKind::InitializationOptions)
+            } else if let Some(adapter_name) = lsp_path.strip_suffix("/settings") {
+                (adapter_name, LspSchemaKind::Settings)
+            } else {
+                anyhow::bail!(
+                    "Invalid LSP schema path: expected '{{adapter}}/initialization_options' or '{{adapter}}/settings', got '{}'",
+                    lsp_path
+                );
+            };
+
             let adapter = languages
                 .all_lsp_adapters()
                 .into_iter()
@@ -246,15 +266,19 @@ async fn resolve_dynamic_schema(
                     "either LSP store is not in local mode or no worktree is available"
                 ))?;
 
-            adapter
-                .initialization_options_schema(&delegate, cx)
-                .await
-                .unwrap_or_else(|| {
-                    serde_json::json!({
-                        "type": "object",
-                        "additionalProperties": true
-                    })
+            let schema = match schema_kind {
+                LspSchemaKind::InitializationOptions => {
+                    adapter.initialization_options_schema(&delegate, cx).await
+                }
+                LspSchemaKind::Settings => adapter.settings_schema(&delegate, cx).await,
+            };
+
+            schema.unwrap_or_else(|| {
+                serde_json::json!({
+                    "type": "object",
+                    "additionalProperties": true
                 })
+            })
         }
         "settings" => {
             let lsp_adapter_names = languages

crates/language/src/language.rs 🔗

@@ -354,6 +354,17 @@ impl CachedLspAdapter {
             .await
     }
 
+    pub async fn settings_schema(
+        &self,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        self.adapter
+            .clone()
+            .settings_schema(delegate, self.cached_binary.clone().lock_owned().await, cx)
+            .await
+    }
+
     pub fn process_prompt_response(&self, context: &PromptResponseContext, cx: &mut AsyncApp) {
         self.adapter.process_prompt_response(context, cx)
     }
@@ -493,6 +504,18 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller {
         None
     }
 
+    /// Returns the JSON schema of the settings for the language server.
+    /// This corresponds to the `settings` field in `LspSettings`, which is used
+    /// to respond to `workspace/configuration` requests from the language server.
+    async fn settings_schema(
+        self: Arc<Self>,
+        _delegate: &Arc<dyn LspAdapterDelegate>,
+        _cached_binary: OwnedMutexGuard<Option<(bool, LanguageServerBinary)>>,
+        _cx: &mut AsyncApp,
+    ) -> Option<serde_json::Value> {
+        None
+    }
+
     async fn workspace_configuration(
         self: Arc<Self>,
         _: &Arc<dyn LspAdapterDelegate>,

crates/settings/src/settings_store.rs 🔗

@@ -1077,7 +1077,13 @@ impl SettingsStore {
                         properties_object.insert(
                             "initialization_options".to_string(),
                             serde_json::json!({
-                                "$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}")
+                                "$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}/initialization_options")
+                            }),
+                        );
+                        properties_object.insert(
+                            "settings".to_string(),
+                            serde_json::json!({
+                                "$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}/settings")
                             }),
                         );
                     }
@@ -2397,7 +2403,23 @@ mod tests {
             .as_str()
             .unwrap();
 
-        assert_eq!(init_options_ref, "zed://schemas/settings/lsp/rust-analyzer");
+        assert_eq!(
+            init_options_ref,
+            "zed://schemas/settings/lsp/rust-analyzer/initialization_options"
+        );
+
+        let settings_ref = properties
+            .get("rust-analyzer")
+            .unwrap()
+            .pointer("/properties/settings/$ref")
+            .expect("settings should have a $ref")
+            .as_str()
+            .unwrap();
+
+        assert_eq!(
+            settings_ref,
+            "zed://schemas/settings/lsp/rust-analyzer/settings"
+        );
     }
 
     #[gpui::test]
@@ -2432,7 +2454,23 @@ mod tests {
             .as_str()
             .unwrap();
 
-        assert_eq!(init_options_ref, "zed://schemas/settings/lsp/rust-analyzer");
+        assert_eq!(
+            init_options_ref,
+            "zed://schemas/settings/lsp/rust-analyzer/initialization_options"
+        );
+
+        let settings_ref = properties
+            .get("rust-analyzer")
+            .unwrap()
+            .pointer("/properties/settings/$ref")
+            .expect("settings should have a $ref")
+            .as_str()
+            .unwrap();
+
+        assert_eq!(
+            settings_ref,
+            "zed://schemas/settings/lsp/rust-analyzer/settings"
+        );
     }
 
     #[gpui::test]