settings: Extract project settings JSON schema (#47939)

Dino created

Introduces a separate JSON schema for project settings that excludes
user-only settings like `auto_update`, `telemetry`, `vim_mode`, etc.
This provides more accurate autocomplete and validation when editing
`.zed/settings.json`.

- Add `SettingsStore::project_json_schema`
- Map `.zed/settings.json` to `zed://schemas/project_settings` schema URL

Release Notes:

- Improved autocomplete for the project settings file
(`.zed/settings.json`) to only include settings that are valid at the
project level, excluding user-only settings.

Change summary

crates/json_schema_store/src/json_schema_store.rs |  36 +++
crates/settings/src/settings_store.rs             | 179 ++++++++++++----
2 files changed, 163 insertions(+), 52 deletions(-)

Detailed changes

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -73,6 +73,8 @@ pub fn init(cx: &mut App) {
             }
             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);
             });
         })
         .detach();
@@ -294,6 +296,34 @@ async fn resolve_dynamic_schema(
                 )
             })
         }
+        "project_settings" => {
+            let lsp_adapter_names = languages
+                .all_lsp_adapters()
+                .into_iter()
+                .map(|adapter| adapter.name().to_string())
+                .collect::<Vec<_>>();
+
+            cx.update(|cx| {
+                let language_names = &languages
+                    .language_names()
+                    .into_iter()
+                    .map(|name| name.to_string())
+                    .collect::<Vec<_>>();
+
+                cx.global::<settings::SettingsStore>().project_json_schema(
+                    &settings::SettingsJsonSchemaParams {
+                        language_names,
+                        lsp_adapter_names: &lsp_adapter_names,
+                        // These are not allowed in project-specific settings but
+                        // they're still fields required by the
+                        // `SettingsJsonSchemaParams` struct.
+                        font_names: &[],
+                        theme_names: &[],
+                        icon_theme_names: &[],
+                    },
+                )
+            })
+        }
         "debug_tasks" => {
             let adapter_schemas = cx.read_global::<dap::DapRegistry, _>(|dap_registry, _| {
                 dap_registry.adapters_schema()
@@ -344,10 +374,14 @@ pub fn all_schema_file_associations(
         {
             "fileMatch": [
                 schema_file_match(paths::settings_file()),
-                paths::local_settings_file_relative_path()
             ],
             "url": format!("{SCHEMA_URI_PREFIX}settings"),
         },
+        {
+            "fileMatch": [
+            paths::local_settings_file_relative_path()],
+            "url": format!("{SCHEMA_URI_PREFIX}project_settings"),
+        },
         {
             "fileMatch": [schema_file_match(paths::keymap_file())],
             "url": format!("{SCHEMA_URI_PREFIX}keymap"),

crates/settings/src/settings_store.rs 🔗

@@ -989,42 +989,76 @@ impl SettingsStore {
             .map(|((_, path), content)| (path.clone(), &content.project))
     }
 
-    pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
-        let mut generator = schemars::generate::SchemaSettings::draft2019_09()
-            .with_transform(DefaultDenyUnknownFields)
-            .with_transform(AllowTrailingCommas)
-            .into_generator();
-
-        UserSettingsContent::json_schema(&mut generator);
-
+    /// Configures common schema replacements shared between user and project
+    /// settings schemas.
+    ///
+    /// This sets up language-specific settings and LSP adapter settings that
+    /// are valid in both user and project settings.
+    fn configure_schema_generator(
+        generator: &mut schemars::SchemaGenerator,
+        params: &SettingsJsonSchemaParams,
+    ) {
         let language_settings_content_ref = generator
             .subschema_for::<LanguageSettingsContent>()
             .to_value();
 
+        replace_subschema::<LanguageToSettingsMap>(generator, || {
+            json_schema!({
+                "type": "object",
+                "errorMessage": "No language with this name is installed.",
+                "properties": params.language_names.iter().map(|name| (name.clone(), language_settings_content_ref.clone())).collect::<serde_json::Map<_, _>>()
+            })
+        });
+
         generator.subschema_for::<LspSettings>();
 
-        let lsp_settings_def = generator
+        let lsp_settings_definition = generator
             .definitions()
             .get("LspSettings")
             .expect("LspSettings should be defined")
             .clone();
 
-        replace_subschema::<LanguageToSettingsMap>(&mut generator, || {
+        replace_subschema::<LspSettingsMap>(generator, || {
+            let mut lsp_properties = serde_json::Map::new();
+
+            for adapter_name in params.lsp_adapter_names {
+                let mut base_lsp_settings = lsp_settings_definition
+                    .as_object()
+                    .expect("LspSettings should be an object")
+                    .clone();
+
+                if let Some(properties) = base_lsp_settings.get_mut("properties") {
+                    if let Some(properties_object) = properties.as_object_mut() {
+                        properties_object.insert(
+                            "initialization_options".to_string(),
+                            serde_json::json!({
+                                "$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}")
+                            }),
+                        );
+                    }
+                }
+
+                lsp_properties.insert(
+                    adapter_name.clone(),
+                    serde_json::Value::Object(base_lsp_settings),
+                );
+            }
+
             json_schema!({
                 "type": "object",
-                "properties": params
-                    .language_names
-                    .iter()
-                    .map(|name| {
-                        (
-                            name.clone(),
-                            language_settings_content_ref.clone(),
-                        )
-                    })
-                    .collect::<serde_json::Map<_, _>>(),
-                "errorMessage": "No language with this name is installed."
+                "properties": lsp_properties
             })
         });
+    }
+
+    pub fn json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
+        let mut generator = schemars::generate::SchemaSettings::draft2019_09()
+            .with_transform(DefaultDenyUnknownFields)
+            .with_transform(AllowTrailingCommas)
+            .into_generator();
+
+        UserSettingsContent::json_schema(&mut generator);
+        Self::configure_schema_generator(&mut generator, params);
 
         replace_subschema::<FontFamilyName>(&mut generator, || {
             json_schema!({
@@ -1047,40 +1081,24 @@ impl SettingsStore {
             })
         });
 
-        replace_subschema::<LspSettingsMap>(&mut generator, || {
-            let mut lsp_properties = serde_json::Map::new();
-
-            for adapter_name in params.lsp_adapter_names {
-                let mut base_lsp_settings = lsp_settings_def
-                    .as_object()
-                    .expect("LspSettings should be an object")
-                    .clone();
-
-                if let Some(properties) = base_lsp_settings.get_mut("properties") {
-                    if let Some(props_obj) = properties.as_object_mut() {
-                        props_obj.insert(
-                            "initialization_options".to_string(),
-                            serde_json::json!({
-                                "$ref": format!("{LSP_SETTINGS_SCHEMA_URL_PREFIX}{adapter_name}")
-                            }),
-                        );
-                    }
-                }
+        generator
+            .root_schema_for::<UserSettingsContent>()
+            .to_value()
+    }
 
-                lsp_properties.insert(
-                    adapter_name.clone(),
-                    serde_json::Value::Object(base_lsp_settings),
-                );
-            }
+    /// Generate JSON schema for project settings, including only settings valid
+    /// for project-level configurations.
+    pub fn project_json_schema(&self, params: &SettingsJsonSchemaParams) -> Value {
+        let mut generator = schemars::generate::SchemaSettings::draft2019_09()
+            .with_transform(DefaultDenyUnknownFields)
+            .with_transform(AllowTrailingCommas)
+            .into_generator();
 
-            json_schema!({
-                "type": "object",
-                "properties": lsp_properties,
-            })
-        });
+        ProjectSettingsContent::json_schema(&mut generator);
+        Self::configure_schema_generator(&mut generator, params);
 
         generator
-            .root_schema_for::<UserSettingsContent>()
+            .root_schema_for::<ProjectSettingsContent>()
             .to_value()
     }
 
@@ -2336,4 +2354,63 @@ mod tests {
 
         assert_eq!(init_options_ref, "zed://schemas/settings/lsp/rust-analyzer");
     }
+
+    #[gpui::test]
+    fn test_lsp_project_settings_schema_generation(cx: &mut App) {
+        let store = SettingsStore::test(cx);
+
+        let schema = store.project_json_schema(&SettingsJsonSchemaParams {
+            language_names: &["Rust".to_string(), "TypeScript".to_string()],
+            font_names: &["Zed Mono".to_string()],
+            theme_names: &["One Dark".into()],
+            icon_theme_names: &["Zed Icons".into()],
+            lsp_adapter_names: &[
+                "rust-analyzer".to_string(),
+                "typescript-language-server".to_string(),
+            ],
+        });
+
+        let properties = schema
+            .pointer("/$defs/LspSettingsMap/properties")
+            .expect("LspSettingsMap should have properties")
+            .as_object()
+            .unwrap();
+
+        assert!(properties.contains_key("rust-analyzer"));
+        assert!(properties.contains_key("typescript-language-server"));
+
+        let init_options_ref = properties
+            .get("rust-analyzer")
+            .unwrap()
+            .pointer("/properties/initialization_options/$ref")
+            .expect("initialization_options should have a $ref")
+            .as_str()
+            .unwrap();
+
+        assert_eq!(init_options_ref, "zed://schemas/settings/lsp/rust-analyzer");
+    }
+
+    #[gpui::test]
+    fn test_project_json_schema_differs_from_user_schema(cx: &mut App) {
+        let store = SettingsStore::test(cx);
+
+        let params = SettingsJsonSchemaParams {
+            language_names: &["Rust".to_string()],
+            font_names: &["Zed Mono".to_string()],
+            theme_names: &["One Dark".into()],
+            icon_theme_names: &["Zed Icons".into()],
+            lsp_adapter_names: &["rust-analyzer".to_string()],
+        };
+
+        let user_schema = store.json_schema(&params);
+        let project_schema = store.project_json_schema(&params);
+
+        assert_ne!(user_schema, project_schema);
+
+        let user_schema_str = serde_json::to_string(&user_schema).unwrap();
+        let project_schema_str = serde_json::to_string(&project_schema).unwrap();
+
+        assert!(user_schema_str.contains("\"auto_update\""));
+        assert!(!project_schema_str.contains("\"auto_update\""));
+    }
 }