Warn about unknown fields when editing settings json (#33678)

Michael Sloan created

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`.

Change summary

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(-)

Detailed changes

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.
     ///

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.
     ///

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 `👋`.

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.
     ///

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.
     ///

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::<gpui::StyleRefinement>();
 

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.
     ///

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();

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<T: JsonSchema>(
     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::<T>();
     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<T: JsonSchema>(
     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>(

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::<ParameterizedJsonSchema>() {
             (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<String>,
         age: Option<u32>,
@@ -1977,7 +1997,6 @@ mod tests {
     }
 
     #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
-    #[schemars(deny_unknown_fields)]
     struct MultiKeySettingsJson {
         key1: Option<String>,
         key2: Option<String>,
@@ -2016,7 +2035,6 @@ mod tests {
     }
 
     #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]
-    #[schemars(deny_unknown_fields)]
     struct JournalSettingsJson {
         pub path: Option<String>,
         pub hour_format: Option<HourFormat>,
@@ -2111,7 +2129,6 @@ mod tests {
     }
 
     #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
-    #[schemars(deny_unknown_fields)]
     struct LanguageSettingEntry {
         language_setting_1: Option<bool>,
         language_setting_2: Option<bool>,

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::<Self>();
 

crates/task/src/debug_format.rs 🔗

@@ -287,7 +287,7 @@ pub struct DebugTaskFile(pub Vec<DebugScenario>);
 
 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::<BuildTaskDefinition>();
         let mut build_task_value =
             serde_json_lenient::to_value(&build_task_schema).unwrap_or_default();

crates/task/src/task_template.rs 🔗

@@ -115,7 +115,7 @@ pub struct TaskTemplates(pub Vec<TaskTemplate>);
 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::<Self>();