From c74ecb46545b8dd45d957abab8987ae5295b0cfc Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 30 Jun 2025 17:34:25 -0600 Subject: [PATCH] Warn about unknown fields when editing settings json (#33678) Closes #30017 * While generating the settings JSON schema, defaults all schema definitions to reject unknown fields via `additionalProperties: false`. * Uses `unevaluatedProperties: false` at the top level to check fields that remain after the settings field names + release stage override field names. * Changes json schema version from `draft07` to `draft_2019_09` to have support for `unevaluatedProperties`. Release Notes: - Added warnings for unknown fields when editing `settings.json`. --- crates/agent_settings/src/agent_settings.rs | 1 - crates/call/src/call_settings.rs | 1 - crates/collab_ui/src/panel_settings.rs | 3 - crates/editor/src/editor_settings.rs | 1 - crates/gpui/src/text_system/font_features.rs | 3 +- crates/language/src/language_settings.rs | 1 - crates/languages/src/json.rs | 2 +- crates/project/src/project_settings.rs | 1 - crates/settings/src/keymap_file.rs | 2 +- crates/settings/src/settings_json.rs | 46 +++++++++++++-- crates/settings/src/settings_store.rs | 59 +++++++++++++------- crates/snippet_provider/src/format.rs | 2 +- crates/task/src/debug_format.rs | 2 +- crates/task/src/task_template.rs | 2 +- 14 files changed, 86 insertions(+), 40 deletions(-) diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 019ab18c20..c5488b75a8 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -212,7 +212,6 @@ impl AgentSettingsContent { } #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Default)] -#[schemars(deny_unknown_fields)] pub struct AgentSettingsContent { /// Whether the Agent is enabled. /// diff --git a/crates/call/src/call_settings.rs b/crates/call/src/call_settings.rs index dd6999a170..c8f51e0c1a 100644 --- a/crates/call/src/call_settings.rs +++ b/crates/call/src/call_settings.rs @@ -12,7 +12,6 @@ pub struct CallSettings { /// Configuration of voice calls in Zed. #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] pub struct CallSettingsContent { /// Whether the microphone should be muted when joining a channel or a call. /// diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index 497b403019..652d9eb67f 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -28,7 +28,6 @@ pub struct ChatPanelSettings { } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] pub struct ChatPanelSettingsContent { /// When to show the panel button in the status bar. /// @@ -52,7 +51,6 @@ pub struct NotificationPanelSettings { } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] pub struct PanelSettingsContent { /// Whether to show the panel button in the status bar. /// @@ -69,7 +67,6 @@ pub struct PanelSettingsContent { } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -#[schemars(deny_unknown_fields)] pub struct MessageEditorSettings { /// Whether to automatically replace emoji shortcodes with emoji characters. /// For example: typing `:wave:` gets replaced with `👋`. diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index f2cb41793c..d7b8bac359 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -378,7 +378,6 @@ pub enum SnippetSortOrder { } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] -#[schemars(deny_unknown_fields)] pub struct EditorSettingsContent { /// Whether the cursor blinks in the editor. /// diff --git a/crates/gpui/src/text_system/font_features.rs b/crates/gpui/src/text_system/font_features.rs index f95a0581f1..c1ab72b417 100644 --- a/crates/gpui/src/text_system/font_features.rs +++ b/crates/gpui/src/text_system/font_features.rs @@ -143,7 +143,8 @@ impl JsonSchema for FontFeatures { "minimum": 0, "multipleOf": 1 } - } + }, + "additionalProperties": false }) } } diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index d2b9005f97..bb143b3842 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -410,7 +410,6 @@ fn default_lsp_fetch_timeout_ms() -> u64 { /// The settings for a particular language. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] -#[schemars(deny_unknown_fields)] pub struct LanguageSettingsContent { /// How many columns a tab should occupy. /// diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index acc24bb29c..bd950f34f5 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -269,7 +269,7 @@ impl JsonLspAdapter { #[cfg(debug_assertions)] fn generate_inspector_style_schema() -> serde_json_lenient::Value { - let schema = schemars::generate::SchemaSettings::draft07() + let schema = schemars::generate::SchemaSettings::draft2019_09() .into_generator() .root_schema_for::(); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 38f9166bcd..1c35f16522 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -36,7 +36,6 @@ use crate::{ }; #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[schemars(deny_unknown_fields)] pub struct ProjectSettings { /// Configuration for language servers. /// diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index d97dff0880..fd35cc6116 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -427,7 +427,7 @@ impl KeymapFile { } pub fn generate_json_schema_for_registered_actions(cx: &mut App) -> Value { - let mut generator = schemars::generate::SchemaSettings::draft07().into_generator(); + let mut generator = schemars::generate::SchemaSettings::draft2019_09().into_generator(); let action_schemas = cx.action_schemas(&mut generator); let deprecations = cx.deprecated_actions_to_preferred_actions(); diff --git a/crates/settings/src/settings_json.rs b/crates/settings/src/settings_json.rs index e64d8efee0..ebf32c2948 100644 --- a/crates/settings/src/settings_json.rs +++ b/crates/settings/src/settings_json.rs @@ -1,17 +1,19 @@ use anyhow::Result; use gpui::App; -use schemars::{JsonSchema, Schema}; +use schemars::{JsonSchema, Schema, transform::transform_subschemas}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use std::{ops::Range, sync::LazyLock}; use tree_sitter::{Query, StreamingIterator as _}; use util::RangeExt; +/// Parameters that are used when generating some JSON schemas at runtime. pub struct SettingsJsonSchemaParams<'a> { pub language_names: &'a [String], pub font_names: &'a [String], } +/// Value registered which specifies JSON schemas that are generated at runtime. pub struct ParameterizedJsonSchema { pub add_and_get_ref: fn(&mut schemars::SchemaGenerator, &SettingsJsonSchemaParams, &App) -> schemars::Schema, @@ -19,24 +21,26 @@ pub struct ParameterizedJsonSchema { inventory::collect!(ParameterizedJsonSchema); +const DEFS_PATH: &str = "#/$defs/"; + +/// Replaces the JSON schema definition for some type, and returns a reference to it. pub fn replace_subschema( generator: &mut schemars::SchemaGenerator, schema: schemars::Schema, ) -> schemars::Schema { - const DEFINITIONS_PATH: &str = "#/definitions/"; // The key in definitions may not match T::schema_name() if multiple types have the same name. // This is a workaround for there being no straightforward way to get the key used for a type - // see https://github.com/GREsau/schemars/issues/449 let ref_schema = generator.subschema_for::(); if let Some(serde_json::Value::String(definition_pointer)) = ref_schema.get("$ref") { - if let Some(definition_name) = definition_pointer.strip_prefix(DEFINITIONS_PATH) { + if let Some(definition_name) = definition_pointer.strip_prefix(DEFS_PATH) { generator .definitions_mut() .insert(definition_name.to_string(), schema.to_value()); return ref_schema; } else { log::error!( - "bug: expected `$ref` field to start with {DEFINITIONS_PATH}, \ + "bug: expected `$ref` field to start with {DEFS_PATH}, \ got {definition_pointer}" ); } @@ -48,7 +52,39 @@ pub fn replace_subschema( generator .definitions_mut() .insert(schema_name.to_string(), schema.to_value()); - Schema::new_ref(format!("{DEFINITIONS_PATH}{schema_name}")) + Schema::new_ref(format!("{DEFS_PATH}{schema_name}")) +} + +/// Adds a new JSON schema definition and returns a reference to it. **Panics** if the name is +/// already in use. +pub fn add_new_subschema( + generator: &mut schemars::SchemaGenerator, + name: &str, + schema: Value, +) -> Schema { + let old_definition = generator.definitions_mut().insert(name.to_string(), schema); + assert_eq!(old_definition, None); + schemars::Schema::new_ref(format!("{DEFS_PATH}{name}")) +} + +/// Defaults `additionalProperties` to `true`, as if `#[schemars(deny_unknown_fields)]` was on every +/// struct. Skips structs that have `additionalProperties` set (such as if #[serde(flatten)] is used +/// on a map). +#[derive(Clone)] +pub struct DefaultDenyUnknownFields; + +impl schemars::transform::Transform for DefaultDenyUnknownFields { + fn transform(&mut self, schema: &mut schemars::Schema) { + if let Some(object) = schema.as_object_mut() { + if object.contains_key("properties") + && !object.contains_key("additionalProperties") + && !object.contains_key("unevaluatedProperties") + { + object.insert("additionalProperties".to_string(), false.into()); + } + } + transform_subschemas(self, schema); + } } pub fn update_value_in_json_text<'a>( diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index d2b78d03fc..0ba516ad7d 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -24,8 +24,8 @@ use util::{ResultExt as _, merge_non_null_json_value_into}; pub type EditorconfigProperties = ec4rs::Properties; use crate::{ - ParameterizedJsonSchema, SettingsJsonSchemaParams, VsCodeSettings, WorktreeId, - parse_json_with_comments, update_value_in_json_text, + DefaultDenyUnknownFields, ParameterizedJsonSchema, SettingsJsonSchemaParams, VsCodeSettings, + WorktreeId, add_new_subschema, parse_json_with_comments, update_value_in_json_text, }; /// A value that can be defined as a user setting. @@ -864,7 +864,9 @@ impl SettingsStore { } pub fn json_schema(&self, schema_params: &SettingsJsonSchemaParams, cx: &App) -> Value { - let mut generator = schemars::generate::SchemaSettings::draft07().into_generator(); + let mut generator = schemars::generate::SchemaSettings::draft2019_09() + .with_transform(DefaultDenyUnknownFields) + .into_generator(); let mut combined_schema = json!({ "type": "object", "properties": {} @@ -988,16 +990,34 @@ impl SettingsStore { } } + // add schemas which are determined at runtime for parameterized_json_schema in inventory::iter::() { (parameterized_json_schema.add_and_get_ref)(&mut generator, schema_params, cx); } + // add merged settings schema to the definitions const ZED_SETTINGS: &str = "ZedSettings"; - let old_zed_settings_definition = generator - .definitions_mut() - .insert(ZED_SETTINGS.to_string(), combined_schema); - assert_eq!(old_zed_settings_definition, None); - let zed_settings_ref = schemars::Schema::new_ref(format!("#/definitions/{ZED_SETTINGS}")); + let zed_settings_ref = add_new_subschema(&mut generator, ZED_SETTINGS, combined_schema); + + // add `ZedReleaseStageSettings` which is the same as `ZedSettings` except that unknown + // fields are rejected. + let mut zed_release_stage_settings = zed_settings_ref.clone(); + zed_release_stage_settings.insert("unevaluatedProperties".to_string(), false.into()); + let zed_release_stage_settings_ref = add_new_subschema( + &mut generator, + "ZedReleaseStageSettings", + zed_release_stage_settings.to_value(), + ); + + // Remove `"additionalProperties": false` added by `DefaultDenyUnknownFields` so that + // unknown fields can be handled by the root schema and `ZedReleaseStageSettings`. + let mut definitions = generator.take_definitions(true); + definitions + .get_mut(ZED_SETTINGS) + .unwrap() + .as_object_mut() + .unwrap() + .remove("additionalProperties"); let mut root_schema = if let Some(meta_schema) = generator.settings().meta_schema.as_ref() { json_schema!({ "$schema": meta_schema.to_string() }) @@ -1005,25 +1025,26 @@ impl SettingsStore { json_schema!({}) }; - // settings file contents matches ZedSettings + overrides for each release stage + // "unevaluatedProperties: false" to report unknown fields. + root_schema.insert("unevaluatedProperties".to_string(), false.into()); + + // Settings file contents matches ZedSettings + overrides for each release stage. root_schema.insert( "allOf".to_string(), json!([ zed_settings_ref, { "properties": { - "dev": zed_settings_ref, - "nightly": zed_settings_ref, - "stable": zed_settings_ref, - "preview": zed_settings_ref, + "dev": zed_release_stage_settings_ref, + "nightly": zed_release_stage_settings_ref, + "stable": zed_release_stage_settings_ref, + "preview": zed_release_stage_settings_ref, } } ]), ); - root_schema.insert( - "definitions".to_string(), - generator.take_definitions(true).into(), - ); + + root_schema.insert("$defs".to_string(), definitions.into()); root_schema.to_value() } @@ -1934,7 +1955,6 @@ mod tests { } #[derive(Default, Clone, Serialize, Deserialize, JsonSchema)] - #[schemars(deny_unknown_fields)] struct UserSettingsContent { name: Option, age: Option, @@ -1977,7 +1997,6 @@ mod tests { } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] - #[schemars(deny_unknown_fields)] struct MultiKeySettingsJson { key1: Option, key2: Option, @@ -2016,7 +2035,6 @@ mod tests { } #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] - #[schemars(deny_unknown_fields)] struct JournalSettingsJson { pub path: Option, pub hour_format: Option, @@ -2111,7 +2129,6 @@ mod tests { } #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] - #[schemars(deny_unknown_fields)] struct LanguageSettingEntry { language_setting_1: Option, language_setting_2: Option, diff --git a/crates/snippet_provider/src/format.rs b/crates/snippet_provider/src/format.rs index 7aa02c7db5..0d06cbbc88 100644 --- a/crates/snippet_provider/src/format.rs +++ b/crates/snippet_provider/src/format.rs @@ -12,7 +12,7 @@ pub struct VsSnippetsFile { impl VsSnippetsFile { pub fn generate_json_schema() -> Value { - let schema = schemars::generate::SchemaSettings::draft07() + let schema = schemars::generate::SchemaSettings::draft2019_09() .into_generator() .root_schema_for::(); diff --git a/crates/task/src/debug_format.rs b/crates/task/src/debug_format.rs index 81941e0074..e336fa1fd7 100644 --- a/crates/task/src/debug_format.rs +++ b/crates/task/src/debug_format.rs @@ -287,7 +287,7 @@ pub struct DebugTaskFile(pub Vec); impl DebugTaskFile { pub fn generate_json_schema(schemas: &AdapterSchemas) -> serde_json_lenient::Value { - let mut generator = schemars::generate::SchemaSettings::draft07().into_generator(); + let mut generator = schemars::generate::SchemaSettings::draft2019_09().into_generator(); let build_task_schema = generator.root_schema_for::(); let mut build_task_value = serde_json_lenient::to_value(&build_task_schema).unwrap_or_default(); diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 4ff45cad9e..65424eeed4 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -115,7 +115,7 @@ pub struct TaskTemplates(pub Vec); impl TaskTemplates { /// Generates JSON schema of Tasks JSON template format. pub fn generate_json_schema() -> serde_json_lenient::Value { - let schema = schemars::generate::SchemaSettings::draft07() + let schema = schemars::generate::SchemaSettings::draft2019_09() .into_generator() .root_schema_for::();