settings: Add auto completion to command aliases setting (#54496)

Dino created

Update the JSON schema generated for the settings file in order to be
able to provide the list of valid actions when editing the values for
the `command_aliases` setting.

While reviewing https://github.com/zed-industries/zed/pull/52892 , I
noticed that, even though we already have support for this in the keymap
file, we don't support it for the `command_aliases` setting, so went
ahead and refactored this a bit such that the existing functionality for
the keymap file JSON schema could also be re-used for the
`command_aliases` setting.

Here's a quick big-picture breakdown of the relevant changes:

* Add `settings_content::ActionName` newtype, representing a simple
named action without arguments. The
`settings_content::ActionName::build_schema` function can be used to
build the schema of all possible action names.
* Add `settings_content::ActionWithArguments` newtype, representing an
action with arguments. This was mostly done so as to keep both action
without arguments and action with arguments newtypes together,
even though we don't have
`settings_content::ActionWithArguments::build_schema`, as it is only
used by the keymap schema generation logic and probably doesn't warrant
moving it here right now.
* Update both
`settings_content::WorkspaceSettingsContent::command_aliases` and
`workspace::workspace_settings::WorkspaceSettings::command_aliases` to
now be of type `HashMap<String, ActionName>` such that, when the json
schema for `command_aliases` is generate, it'll now reference the
`#/$defs/ActionName` schema.
* Update `SettingsStore::json_schema` so as to populate the
`#/$defs/ActionName` schema at runtime, replacing it with the actual
list of valid action names.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Added support for auto-completing action names on `command_aliases`
setting

Change summary

crates/command_palette/src/command_palette.rs     |   2 
crates/docs_preprocessor/src/main.rs              |  15 +
crates/json_schema_store/src/json_schema_store.rs |  13 +
crates/settings/src/keymap_file.rs                |  42 ++-
crates/settings/src/settings_store.rs             |  30 ++
crates/settings_content/src/action.rs             | 202 +++++++++++++++++
crates/settings_content/src/settings_content.rs   |   2 
crates/settings_content/src/workspace.rs          |   8 
crates/vim/src/test.rs                            |   4 
crates/workspace/src/workspace_settings.rs        |   4 
10 files changed, 294 insertions(+), 28 deletions(-)

Detailed changes

crates/command_palette/src/command_palette.rs 🔗

@@ -442,7 +442,7 @@ impl PickerDelegate for CommandPaletteDelegate {
     ) -> gpui::Task<()> {
         let settings = WorkspaceSettings::get_global(cx);
         if let Some(alias) = settings.command_aliases.get(&query) {
-            query = alias.to_string();
+            query = alias.as_ref().to_owned();
         }
 
         let workspace = self.workspace.clone();

crates/docs_preprocessor/src/main.rs 🔗

@@ -3,7 +3,7 @@ use mdbook::BookItem;
 use mdbook::book::{Book, Chapter};
 use mdbook::preprocess::CmdPreprocessor;
 use regex::Regex;
-use settings::{KeymapFile, SettingsStore};
+use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore};
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};
 use std::io::{self, Read};
@@ -369,7 +369,18 @@ fn find_binding_with_overlay(
 }
 
 fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
-    let settings_schema = SettingsStore::json_schema(&Default::default());
+    let params = SettingsJsonSchemaParams {
+        language_names: &[],
+        font_names: &[],
+        theme_names: &[],
+        icon_theme_names: &[],
+        lsp_adapter_names: &[],
+        action_names: &[],
+        action_documentation: &HashMap::default(),
+        deprecations: &HashMap::default(),
+        deprecation_messages: &HashMap::default(),
+    };
+    let settings_schema = SettingsStore::json_schema(&params);
     let settings_validator = jsonschema::validator_for(&settings_schema)
         .expect("failed to compile settings JSON schema");
 

crates/json_schema_store/src/json_schema_store.rs 🔗

@@ -352,6 +352,11 @@ async fn resolve_dynamic_schema(
                 let icon_theme_names = icon_theme_names.as_slice();
                 let theme_names = theme_names.as_slice();
 
+                let action_names = cx.all_action_names();
+                let action_documentation = cx.action_documentation();
+                let deprecations = cx.deprecated_actions_to_preferred_actions();
+                let deprecation_messages = cx.action_deprecation_messages();
+
                 let mut schema =
                     settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams {
                         language_names,
@@ -359,6 +364,10 @@ async fn resolve_dynamic_schema(
                         theme_names,
                         icon_theme_names,
                         lsp_adapter_names: &lsp_adapter_names,
+                        action_names,
+                        action_documentation,
+                        deprecations,
+                        deprecation_messages,
                     });
                 inject_feature_flags_schema(&mut schema);
                 schema
@@ -387,6 +396,10 @@ async fn resolve_dynamic_schema(
                     font_names: &[],
                     theme_names: &[],
                     icon_theme_names: &[],
+                    action_names: &[],
+                    action_documentation: &HashMap::default(),
+                    deprecations: &HashMap::default(),
+                    deprecation_messages: &HashMap::default(),
                 });
             inject_feature_flags_schema(&mut schema);
             schema

crates/settings/src/keymap_file.rs 🔗

@@ -19,6 +19,7 @@ use util::{
 };
 
 use crate::SettingsAssets;
+use settings_content::{ActionName, ActionWithArguments};
 use settings_json::{
     append_top_level_array_value_in_json_text, parse_json_with_comments,
     replace_top_level_array_value_in_json_text,
@@ -698,10 +699,17 @@ impl KeymapFile {
             "minItems": 2,
             "maxItems": 2
         });
-        let mut keymap_action_alternatives = vec![
-            empty_action_name.clone(),
-            empty_action_name_with_input.clone(),
-        ];
+
+        let mut keymap_deprecations = deprecations.clone();
+        keymap_deprecations.insert(NoAction.name(), "null");
+        let action_name_schema = ActionName::build_schema(
+            action_schemas.iter().map(|(name, _)| *name),
+            action_documentation,
+            &keymap_deprecations,
+            deprecation_messages,
+        );
+
+        let mut action_with_arguments_alternatives = vec![empty_action_name_with_input.clone()];
         let mut unbind_target_action_alternatives =
             vec![empty_action_name, empty_action_name_with_input];
 
@@ -731,7 +739,6 @@ impl KeymapFile {
             if let Some(description) = &description {
                 add_description(&mut plain_action, description);
             }
-            keymap_action_alternatives.push(plain_action.clone());
             if include_in_unbind_target_schema {
                 unbind_target_action_alternatives.push(plain_action);
             }
@@ -760,7 +767,7 @@ impl KeymapFile {
                     "minItems": 2,
                     "maxItems": 2
                 });
-                keymap_action_alternatives.push(action_with_input.clone());
+                action_with_arguments_alternatives.push(action_with_input.clone());
                 if include_in_unbind_target_schema {
                     unbind_target_action_alternatives.push(action_with_input);
                 }
@@ -789,7 +796,7 @@ impl KeymapFile {
                 "This action does not take input - just the action name string should be used."
                     .to_string(),
             );
-            keymap_action_alternatives.push(actions_with_empty_input);
+            action_with_arguments_alternatives.push(actions_with_empty_input);
         }
 
         if !empty_schema_unbind_target_action_names.is_empty() {
@@ -812,17 +819,22 @@ impl KeymapFile {
             unbind_target_action_alternatives.push(actions_with_empty_input);
         }
 
-        // Placing null first causes json-language-server to default assuming actions should be
-        // null, so place it last.
-        keymap_action_alternatives.push(json_schema!({
-            "type": "null"
-        }));
+        generator.definitions_mut().insert(
+            ActionName::schema_name().to_string(),
+            action_name_schema.to_value(),
+        );
+        generator.definitions_mut().insert(
+            ActionWithArguments::schema_name().to_string(),
+            json!({ "anyOf": action_with_arguments_alternatives }),
+        );
 
         generator.definitions_mut().insert(
             KeymapAction::schema_name().to_string(),
-            json!({
-                "anyOf": keymap_action_alternatives
-            }),
+            json!({ "anyOf": [
+                { "$ref": format!("#/$defs/{}", ActionName::schema_name().to_string()) },
+                { "$ref": format!("#/$defs/{}", ActionWithArguments::schema_name().to_string()) },
+                { "type": "null" }
+            ] }),
         );
         generator.definitions_mut().insert(
             UnbindTargetAction::schema_name().to_string(),

crates/settings/src/settings_store.rs 🔗

@@ -13,7 +13,7 @@ use gpui::{
 use paths::{local_settings_file_relative_path, task_file_name};
 use schemars::{JsonSchema, json_schema};
 use serde_json::Value;
-use settings_content::ParseStatus;
+use settings_content::{ActionName, ParseStatus};
 use std::{
     any::{Any, TypeId, type_name},
     fmt::Debug,
@@ -272,13 +272,16 @@ pub trait AnySettingValue: 'static + Send + Sync {
 }
 
 /// Parameters that are used when generating some JSON schemas at runtime.
-#[derive(Default)]
 pub struct SettingsJsonSchemaParams<'a> {
     pub language_names: &'a [String],
     pub font_names: &'a [String],
     pub theme_names: &'a [SharedString],
     pub icon_theme_names: &'a [SharedString],
     pub lsp_adapter_names: &'a [String],
+    pub action_names: &'a [&'a str],
+    pub action_documentation: &'a HashMap<&'a str, &'a str>,
+    pub deprecations: &'a HashMap<&'a str, &'a str>,
+    pub deprecation_messages: &'a HashMap<&'a str, &'a str>,
 }
 
 impl SettingsStore {
@@ -1263,6 +1266,17 @@ impl SettingsStore {
             });
         }
 
+        if !params.action_names.is_empty() {
+            replace_subschema::<ActionName>(&mut generator, || {
+                ActionName::build_schema(
+                    params.action_names.iter().copied(),
+                    params.action_documentation,
+                    params.deprecations,
+                    params.deprecation_messages,
+                )
+            });
+        }
+
         generator
             .root_schema_for::<UserSettingsContent>()
             .to_value()
@@ -2738,6 +2752,10 @@ mod tests {
                 "rust-analyzer".to_string(),
                 "typescript-language-server".to_string(),
             ],
+            action_names: &[],
+            action_documentation: &HashMap::default(),
+            deprecations: &HashMap::default(),
+            deprecation_messages: &HashMap::default(),
         });
 
         let properties = schema
@@ -2789,6 +2807,10 @@ mod tests {
                 "rust-analyzer".to_string(),
                 "typescript-language-server".to_string(),
             ],
+            action_names: &[],
+            action_documentation: &HashMap::default(),
+            deprecations: &HashMap::default(),
+            deprecation_messages: &HashMap::default(),
         });
 
         let properties = schema
@@ -2837,6 +2859,10 @@ mod tests {
             theme_names: &["One Dark".into()],
             icon_theme_names: &["Zed Icons".into()],
             lsp_adapter_names: &["rust-analyzer".to_string()],
+            action_names: &[],
+            action_documentation: &HashMap::default(),
+            deprecations: &HashMap::default(),
+            deprecation_messages: &HashMap::default(),
         };
 
         let user_schema = SettingsStore::json_schema(&params);

crates/settings_content/src/action.rs 🔗

@@ -0,0 +1,202 @@
+use std::borrow::Cow;
+use std::fmt::{Display, Formatter, Result};
+
+use collections::HashMap;
+use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use settings_macros::MergeFrom;
+
+/// The name of a registered GPUI action, serialized as a plain JSON string, for
+/// example, "editor::Cancel"` or `"workspace::CloseActiveItem"`.
+///
+/// This newtype exists so that settings fields like `command_aliases`, or the
+/// keymap file bindings, can request JSON-schema auto completion over the set
+/// of actions known at runtime.
+#[derive(Serialize, Deserialize, Default, MergeFrom, Clone, Debug, PartialEq)]
+#[serde(transparent)]
+pub struct ActionName(String);
+
+/// Small helper function to populate the schema's `deprecationMessage` field with the
+/// provided deprecation message.
+fn add_deprecation(schema: &mut Schema, message: String) {
+    schema.insert("deprecationMessage".into(), Value::String(message));
+}
+
+/// Small helper function to populate the schema's `description` field with the
+/// provided description.
+fn add_description(schema: &mut Schema, description: &str) {
+    schema.insert("description".into(), Value::String(description.to_string()));
+}
+
+impl ActionName {
+    pub fn new(name: impl Into<String>) -> Self {
+        Self(name.into())
+    }
+
+    /// Build the JSON schema to be used for `$defs/ActionName`, basically an
+    /// `anyOf` of all of the available actions with per-action documentation
+    /// and deprecation metadata attached.
+    pub fn build_schema<'a>(
+        action_names: impl IntoIterator<Item = &'a str>,
+        action_documentation: &HashMap<&str, &str>,
+        deprecations: &HashMap<&str, &str>,
+        deprecation_messages: &HashMap<&str, &str>,
+    ) -> Schema {
+        let mut alternatives = Vec::new();
+
+        for action_name in action_names {
+            let mut entry = json_schema!({
+                "type": "string",
+                "const": action_name
+            });
+
+            if let Some(message) = deprecation_messages.get(action_name) {
+                add_deprecation(&mut entry, message.to_string());
+            } else if let Some(new_name) = deprecations.get(action_name) {
+                add_deprecation(&mut entry, format!("Deprecated, use {new_name}"));
+            }
+
+            if let Some(description) = action_documentation.get(action_name) {
+                add_description(&mut entry, description);
+            }
+
+            alternatives.push(entry);
+        }
+
+        json_schema!({ "anyOf": alternatives })
+    }
+}
+
+impl Display for ActionName {
+    fn fmt(&self, formatter: &mut Formatter<'_>) -> Result {
+        write!(formatter, "{}", self.0)
+    }
+}
+
+impl AsRef<str> for ActionName {
+    fn as_ref(&self) -> &str {
+        &self.0
+    }
+}
+
+impl JsonSchema for ActionName {
+    /// The name under which this type should be stored in a generator's `$defs`
+    /// map when schemars encounters it during schema generation.
+    /// Keeping it stable as `"ActionName"` lets consumers reference it by
+    /// `#/$defs/ActionName` and lets [`util::schemars::replace_subschema`] look
+    /// it up at runtime to swap in the real schema.
+    fn schema_name() -> Cow<'static, str> {
+        "ActionName".into()
+    }
+
+    /// Returns `true` as a placeholder.
+    ///
+    /// The real schema, an `anyOf` of every registered action name with action
+    /// documentation and deprecation metadata, cannot be produced here because
+    /// `JsonSchema::json_schema` receives no runtime context. It is instead
+    /// built by call sites that do have access to the GPUI action registry
+    /// using [`ActionName::build_schema`].
+    fn json_schema(_: &mut SchemaGenerator) -> Schema {
+        json_schema!(true)
+    }
+}
+
+/// A GPUI action together with its input data, serialized as a two-element JSON
+/// array of the form `["namespace::Name", { ... }]`, for example,
+/// `["pane::ActivateItem", { "index": 0 }]`.
+#[derive(Deserialize, Default)]
+#[serde(transparent)]
+pub struct ActionWithArguments(pub Value);
+
+impl JsonSchema for ActionWithArguments {
+    /// The name under which this type should be stored in a generator's `$defs`
+    /// map when schemars encounters it during schema generation.
+    /// Keeping it stable as `"ActionWithArguments"` lets consumers reference it
+    /// by `#/$defs/ActionWithArguments` and lets
+    /// [`util::schemars::replace_subschema`] look it up at runtime to swap in
+    /// the real schema.
+    fn schema_name() -> Cow<'static, str> {
+        "ActionWithArguments".into()
+    }
+
+    /// Returns `true` as a placeholder.
+    ///
+    /// The real schema, an `anyOf` of every registered action name that
+    /// supports arguments, with action documentation and deprecation metadata,
+    /// cannot be produced here because `JsonSchema::json_schema` receives no
+    /// runtime context. At the time of writing, it is instead built by
+    /// [`KeymapFile::generate_json_schema`], where all of the runtime
+    /// information is available.
+    fn json_schema(_: &mut SchemaGenerator) -> Schema {
+        json_schema!(true)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn build_schema_produces_anyof_of_consts_per_name() {
+        let mut action_documentation = HashMap::default();
+        let mut deprecations = HashMap::default();
+        let mut deprecation_messages = HashMap::default();
+        action_documentation.insert("editor::Cancel", "Cancel the current operation.");
+        deprecations.insert("workspace::CloseCurrentItem", "workspace::CloseActiveItem");
+        deprecation_messages.insert("editor::Explode", "DO NOT USE!");
+
+        let schema = ActionName::build_schema(
+            [
+                "editor::Cancel",
+                "editor::Explode",
+                "workspace::CloseCurrentItem",
+                "workspace::CloseActiveItem",
+            ],
+            &action_documentation,
+            &deprecations,
+            &deprecation_messages,
+        );
+
+        let value = schema.to_value();
+        let values = value
+            .pointer("/anyOf")
+            .and_then(|v| v.as_array())
+            .expect("anyOf should be present");
+        assert_eq!(values.len(), 4);
+
+        let (name, schema_type, description) = (
+            values[0].get("const").and_then(Value::as_str),
+            values[0].get("type").and_then(Value::as_str),
+            values[0].get("description").and_then(Value::as_str),
+        );
+        assert_eq!(name, Some("editor::Cancel"));
+        assert_eq!(schema_type, Some("string"));
+        assert_eq!(description, Some("Cancel the current operation."));
+
+        let (name, schema_type, message) = (
+            values[1].get("const").and_then(Value::as_str),
+            values[1].get("type").and_then(Value::as_str),
+            values[1].get("deprecationMessage").and_then(Value::as_str),
+        );
+        assert_eq!(name, Some("editor::Explode"));
+        assert_eq!(schema_type, Some("string"));
+        assert_eq!(message, Some("DO NOT USE!"));
+
+        let (name, schema_type, message) = (
+            values[2].get("const").and_then(Value::as_str),
+            values[2].get("type").and_then(Value::as_str),
+            values[2].get("deprecationMessage").and_then(Value::as_str),
+        );
+        assert_eq!(name, Some("workspace::CloseCurrentItem"));
+        assert_eq!(schema_type, Some("string"));
+        assert_eq!(message, Some("Deprecated, use workspace::CloseActiveItem"));
+
+        let (name, schema_type) = (
+            values[3].get("const").and_then(Value::as_str),
+            values[3].get("type").and_then(Value::as_str),
+        );
+        assert_eq!(name, Some("workspace::CloseActiveItem"));
+        assert_eq!(schema_type, Some("string"));
+    }
+}

crates/settings_content/src/settings_content.rs 🔗

@@ -1,3 +1,4 @@
+mod action;
 mod agent;
 mod editor;
 mod extension;
@@ -12,6 +13,7 @@ mod theme;
 mod title_bar;
 mod workspace;
 
+pub use action::{ActionName, ActionWithArguments};
 pub use agent::*;
 pub use editor::*;
 pub use extension::*;

crates/settings_content/src/workspace.rs 🔗

@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
 use settings_macros::{MergeFrom, with_fallible_options};
 
 use crate::{
-    CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides,
-    ShowScrollbar, serialize_optional_f32_with_two_decimal_places,
+    ActionName, CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity,
+    ShowIndentGuides, ShowScrollbar, serialize_optional_f32_with_two_decimal_places,
 };
 
 #[with_fallible_options]
@@ -88,9 +88,9 @@ pub struct WorkspaceSettingsContent {
     /// Aliases for the command palette. When you type a key in this map,
     /// it will be assumed to equal the value.
     ///
-    /// Default: true
+    /// Default: {}
     #[serde(default)]
-    pub command_aliases: HashMap<String, String>,
+    pub command_aliases: HashMap<String, ActionName>,
     /// Maximum open tabs in a pane. Will not close an unsaved
     /// tab. Set to `None` for unlimited tabs.
     ///

crates/vim/src/test.rs 🔗

@@ -18,7 +18,7 @@ use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext, px};
 use itertools::Itertools;
 use language::{CursorShape, Language, LanguageConfig, Point};
 pub use neovim_backed_test_context::*;
-use settings::SettingsStore;
+use settings::{ActionName, SettingsStore};
 use ui::Pixels;
 use util::{path, test::marked_text_ranges};
 pub use vim_test_context::*;
@@ -1926,7 +1926,7 @@ async fn test_command_alias(cx: &mut gpui::TestAppContext) {
     cx.update_global(|store: &mut SettingsStore, cx| {
         store.update_user_settings(cx, |s| {
             let mut aliases = HashMap::default();
-            aliases.insert("Q".to_string(), "upper".to_string());
+            aliases.insert("Q".to_string(), ActionName::new("upper"));
             s.workspace.command_aliases = aliases
         });
     });

crates/workspace/src/workspace_settings.rs 🔗

@@ -4,7 +4,7 @@ use crate::DockPosition;
 use collections::HashMap;
 use serde::Deserialize;
 pub use settings::{
-    AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity,
+    ActionName, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity,
     PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting,
     RestoreOnStartupBehavior, Settings,
 };
@@ -25,7 +25,7 @@ pub struct WorkspaceSettings {
     pub drop_target_size: f32,
     pub use_system_path_prompts: bool,
     pub use_system_prompts: bool,
-    pub command_aliases: HashMap<String, String>,
+    pub command_aliases: HashMap<String, ActionName>,
     pub max_tabs: Option<NonZeroUsize>,
     pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems,
     pub on_last_window_closed: settings::OnLastWindowClosed,