From 2893ddeaa04e222f92ee888f37b129145090bd62 Mon Sep 17 00:00:00 2001 From: Kyle Smith Date: Fri, 3 Mar 2023 10:25:27 -0500 Subject: [PATCH] [WIP] Add option documentation to :set-option, :get-option. --- Cargo.lock | 46 +++ helix-core/Cargo.toml | 1 + helix-core/src/syntax.rs | 5 +- helix-term/Cargo.toml | 1 + helix-term/src/commands.rs | 1 + helix-term/src/commands/runtime_options.rs | 378 +++++++++++++++++++++ helix-term/src/commands/typed.rs | 85 ++++- helix-view/Cargo.toml | 1 + helix-view/src/editor.rs | 165 +++++---- helix-view/src/graphics.rs | 3 +- xtask/Cargo.toml | 2 + xtask/src/main.rs | 14 + 12 files changed, 614 insertions(+), 88 deletions(-) create mode 100644 helix-term/src/commands/runtime_options.rs diff --git a/Cargo.lock b/Cargo.lock index af0858efe4d7c..e85373bb0242e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,6 +321,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c" +[[package]] +name = "dyn-clone" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b0cf012f1230e43cd00ebb729c6bb58707ecfa8ad08b52ef3a4ccd2697fc30" + [[package]] name = "either" version = "1.8.0" @@ -1086,6 +1092,7 @@ dependencies = [ "quickcheck", "regex", "ropey", + "schemars", "serde", "serde_json", "slotmap", @@ -1181,6 +1188,7 @@ dependencies = [ "log", "once_cell", "pulldown-cmark", + "schemars", "serde", "serde_json", "signal-hook", @@ -1244,6 +1252,7 @@ dependencies = [ "log", "once_cell", "parking_lot", + "schemars", "serde", "serde_json", "slotmap", @@ -1761,6 +1770,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c613288622e5f0c3fdc5dbd4db1c5fbe752746b1d1a56a0630b78fd00de44f" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109da1e6b197438deb6db99952990c7f959572794b80ff93707d55a232545e7c" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1793,6 +1826,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.94" @@ -2469,5 +2513,7 @@ dependencies = [ "helix-loader", "helix-term", "helix-view", + "schemars", + "serde_json", "toml", ] diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 62ec87b485ca7..5cbe5bd023a1d 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -46,6 +46,7 @@ chrono = { version = "0.4", default-features = false, features = ["alloc", "std" etcetera = "0.4" textwrap = "0.16.0" +schemars = { version = "0.8.12", features = ["derive_json_schema"] } [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 941e3ba7bd3b4..cd476806c5124 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -26,6 +26,7 @@ use std::{ }; use once_cell::sync::{Lazy, OnceCell}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use helix_loader::grammar::{get_language, load_runtime_file}; @@ -286,7 +287,7 @@ pub struct IndentationConfiguration { } /// Configuration for auto pairs -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] pub enum AutoPairConfig { /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. @@ -547,7 +548,7 @@ impl LanguageConfiguration { .ok() } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct SoftWrap { /// Soft wrap lines that exceed viewport width. Default to off diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 5222ddaa15f64..fc240899f234c 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -65,6 +65,7 @@ serde = { version = "1.0", features = ["derive"] } # ripgrep for global search grep-regex = "0.1.11" grep-searcher = "0.1.11" +schemars = { version = "0.8.12", features = ["derive_json_schema"] } [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 1c1edece1a313..f65c2761c0d0c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,5 +1,6 @@ pub(crate) mod dap; pub(crate) mod lsp; +pub(crate) mod runtime_options; pub(crate) mod typed; pub use dap::*; diff --git a/helix-term/src/commands/runtime_options.rs b/helix-term/src/commands/runtime_options.rs new file mode 100644 index 0000000000000..f2e160bea8294 --- /dev/null +++ b/helix-term/src/commands/runtime_options.rs @@ -0,0 +1,378 @@ +use std::collections::HashMap; + +use schemars::schema_for; +use serde; +use serde::Deserialize; + +#[derive(Debug)] +pub struct Options { + options: HashMap, +} + +#[derive(Debug, Deserialize)] +struct Option { + default: std::option::Option, + description: std::option::Option, + validation: std::option::Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct OptionValidation { + kind: std::option::Option, + one_of: std::option::Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum OptionKind { + Boolean, + Numeric, +} + +// `from_config` translates the embedded JSON Schema into a minimal representation +// of the configuration options available to be set at runtime. +pub fn from_config() -> anyhow::Result { + use schemars::schema::*; + + let mut options = HashMap::new(); + let root_schema: RootSchema = schema_for!(helix_view::editor::Config); + + fn bool_to_option(metadata: Metadata) -> Option { + Option { + default: metadata.default.unwrap().as_bool().map(|b| b.to_string()), + description: metadata.description, + validation: Some(OptionValidation { + kind: Some(OptionKind::Boolean), + one_of: None, + }), + } + } + + fn number_to_option(metadata: Metadata) -> Option { + Option { + default: metadata.default.unwrap().as_u64().map(|n| n.to_string()), + description: metadata.description, + validation: Some(OptionValidation { + kind: Some(OptionKind::Numeric), + one_of: None, + }), + } + } + + fn enum_to_option(schemas: &Vec, metadata: Metadata) -> Option { + let valid_options: Vec<_> = schemas + .iter() + .map(|s| s.clone().into_object()) + .flat_map(|s| s.enum_values.unwrap_or_default()) + .map(|v| v.as_str().unwrap().to_owned()) + .collect(); + + Option { + default: metadata + .default + // We add _or_default() here because enums with manual Default implementations + // are not picked up by JsonSchema derive. Example: CursorKind. + .unwrap_or_default() + .as_str() + .map(|s| s.to_owned()), + description: metadata.description, + validation: Some(OptionValidation { + one_of: Some(valid_options), + kind: None, + }), + } + } + + // For options where we don't have runtime validation on :set-option, still extract + // the default and doc comment for use in the editor and during documentation generation. + fn no_validation_option(metadata: Metadata) -> Option { + Option { + // REVIEW: This formats the default as JSON. This is probably correct or close most of the time. + default: metadata.default.map(|v| v.to_string()), + description: metadata.description, + validation: None, + } + } + + fn qualify_prefix(prefix: &Vec, node: String) -> String { + let mut prefix = prefix.clone(); + prefix.push(node); + prefix.join(".") + } + + fn traverse_schema( + prefix: Vec, + root_schema: &RootSchema, + mut schema: SchemaObject, + options: &mut HashMap, + ) { + use schemars::schema::SingleOrVec::{Single, Vec}; + + for (key, value) in &schema.object().properties { + let qualified_option_name = qualify_prefix(&prefix, key.to_owned()); + log::debug!("inspecting schema for {}", qualified_option_name); + + match value { + Schema::Object(object) => { + let mut object = object.clone(); + let metadata = object.clone().metadata().clone(); + + match &object.instance_type { + Some(Single(t)) if **t == InstanceType::Boolean => { + options.insert(qualified_option_name, bool_to_option(metadata)); + } + + Some(Single(t)) if **t == InstanceType::Integer => { + options.insert(qualified_option_name, number_to_option(metadata)); + } + + Some(Single(t)) if **t == InstanceType::Array => { + options.insert(qualified_option_name, no_validation_option(metadata)); + } + + Some(Single(s)) if **s == InstanceType::String => { + // TODO: We could adapt the string validation from JsonSchema for min/max + // length and patterns. For `char` types it correctly sets length 1, + // for example. + options.insert(qualified_option_name, no_validation_option(metadata)); + } + + // TODO: Other optional types could be supported. + Some(Vec(v)) if **v == vec![InstanceType::Integer, InstanceType::Null] => { + // TODO: This isn't quite right as "null" is currently accepted. + options.insert(qualified_option_name, number_to_option(metadata)); + } + + // This is what happens when another enum or struct is referenced. + None if object.subschemas.is_some() => { + // If we have an allOf schema, we might be an enum. + if let Some(schemas) = &object.subschemas().all_of { + let reference = + schemas.first().map(|s| s.clone().into_object().reference); + + if let Some(Some(reference_id)) = reference { + let reference_id = reference_id.split("/").last().unwrap(); + + let mut definition = root_schema + .definitions + .get(reference_id) + .unwrap() + .clone() + .into_object(); + + // If the definition has a subschema oneOf, it's likely an enum. + if let Some(schemas) = &definition.subschemas().one_of { + options.insert( + key.to_owned(), + enum_to_option(schemas, metadata), + ); + } else if definition.object.is_some() { + let mut prefix = prefix.clone(); + prefix.push(key.to_owned()); + + log::debug!("recursing: {:?}", prefix); + + // If the definition has object validations, it's a sub-structure we + // should recurse into. + traverse_schema(prefix, &root_schema, definition, options); + } else { + log::debug!( + "unhandled referenced subschema: {}:\n{:#?}", + qualified_option_name, + definition.subschemas() + ); + } + } else { + log::debug!( + "invalid reference_id for {}: {:?}", + qualified_option_name, + reference + ); + } + } else { + log::debug!( + "unhandled subschema type: {}:\n{:#?}", + qualified_option_name, + object.subschemas() + ); + } + } + _ => { + log::debug!("option cannot be parsed: {}", qualified_option_name); + log::debug!("\n{:#?}", object); + } + } + } + _ => { + log::debug!( + "option did not have Object schema: {}", + qualified_option_name + ); + log::debug!("\n{:#?}", value); + } + } + } + } + + traverse_schema( + vec![], + &root_schema, + root_schema.schema.clone(), + &mut options, + ); + + // log::debug!("options: {:?}", options); + + Ok(Options { options }) +} + +pub enum HelpResult<'a> { + /// We're aware of this option, but have no help text available. This is + /// most likely caused by either no doc comment on the struct field backing + /// this option, or an error in the schema parsing logic in `from_config()`. + NoDocumentationAvailable, + + /// This is not a known configuration option. + UnknownCommand, + + /// Documentation for the command is available. + Documentation(&'a str), +} + +impl Options { + pub fn get_help(&self, name: &str) -> HelpResult { + match self.options.get(name) { + Some(option) => match &option.description { + Some(description) => HelpResult::Documentation(description), + None => HelpResult::NoDocumentationAvailable, + }, + None => HelpResult::UnknownCommand, + } + } + + pub fn validate(&self, name: &str, value: &str) -> anyhow::Result<()> { + match self.options.get(name).map(|o| &o.validation) { + Some(Some(validation)) => validation.validate(value), + + // If we don't have any knowledge of this option, assume it validates. If it's truly invalid, + // it will fail to apply to the actual config objects. + _ => Ok(()), + } + } +} + +impl OptionValidation { + fn validate(&self, value: &str) -> anyhow::Result<()> { + log::debug!("validate: {:?}", self); + match self.kind { + Some(OptionKind::Numeric) => { + if !value.parse::().is_ok() { + anyhow::bail!("value must be numeric"); + } + } + Some(OptionKind::Boolean) => { + if !value.parse::().is_ok() { + anyhow::bail!("value must be one of: true, false"); + } + } + None => (), + } + + if let Some(one_of) = &self.one_of { + if !one_of.iter().any(|v| v == value) { + anyhow::bail!("value must be one of: {}", one_of.join(", ")); + } + } + + Ok(()) + } +} + +#[test] +fn test_actual_from_config() { + from_config().expect("loads ok"); +} + +// #[test] +// fn test_get_help() { +// let options = from_str( +// r#" +// [my-option] +// description = "my excellent option" +// default = "`true`" + +// ["nested.my-option"] +// description = "into the depths" +// default = "`false`" +// "#, +// ) +// .expect("should parse"); + +// assert_eq!(Some("my excellent option"), options.get_help("my-option")); +// assert_eq!( +// Some("into the depths"), +// options.get_help("nested.my-option") +// ); +// assert_eq!(None, options.get_help("not.a-command")); +// } + +// #[test] +// fn test_validations() { +// let options = from_str( +// r#" +// [boolean-only] +// description = "should be a boolean" +// default = "`true`" +// validation = { kind = "boolean" } + +// [numeric-only] +// description = "should be a number" +// default = "`4`" +// validation = { kind = "numeric" } + +// [one-of-a-set] +// description = "favorite color" +// default = "`red`" +// validation = { one-of = ["red", "blue", "green"] } +// "#, +// ) +// .expect("should parse"); + +// #[rustfmt::skip] +// let ok_examples = [ +// ("boolean-only", "true"), +// ("boolean-only", "false"), + +// ("numeric-only", "0"), +// ("numeric-only", "12319872372"), + +// ("one-of-a-set", "red"), +// ("one-of-a-set", "blue"), +// ("one-of-a-set", "green"), +// ]; + +// #[rustfmt::skip] +// let fail_examples = [ +// ("boolean-only", "purple", "value must be one of: true, false"), + +// ("numeric-only", "-1000", "value must be numeric"), +// ("numeric-only", "purple", "value must be numeric"), + +// ("one-of-a-set", "purple", "value must be one of: red, blue, green"), +// ]; + +// for (name, value) in ok_examples { +// options +// .validate(name, value) +// .map_err(|_| format!("{} should allow value {}", name, value)) +// .unwrap() +// } + +// for (name, value, err_msg) in fail_examples { +// match options.validate(name, value) { +// Ok(_) => assert!(false, "{} should be invalid for {}", value, name), +// Err(e) => assert_eq!(err_msg, e.to_string()), +// } +// } +// } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 6fbdc0d7afa31..a00b5900cab9d 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -3,6 +3,7 @@ use std::ops::Deref; use crate::job::Job; +use super::runtime_options::HelpResult; use super::*; use helix_core::{encoding, shellwords::Shellwords}; @@ -1719,6 +1720,14 @@ fn set_option( } let (key, arg) = (&args[0].to_lowercase(), &args[1]); + // Attempt to provide helpful failure messages based on structured option + // documentation. + log::debug!("attempting to validate {}:{} using option docs", key, arg); + if OPTION_DOCUMENTATION.is_ok() { + log::debug!("documentation is ok!"); + OPTION_DOCUMENTATION.as_ref().unwrap().validate(key, arg)?; + } + let key_error = || anyhow::anyhow!("Unknown key `{}`", key); let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); @@ -2708,6 +2717,13 @@ pub static TYPABLE_COMMAND_MAP: Lazy> = + Lazy::new(|| runtime_options::from_config()); + +// Longer term this may call for some sort of "enhanced documentation" option on TypedCommand. +pub static OPTION_DOCUMENTATION_COMMANDS: [&'static str; 4] = + ["set", "set-option", "get", "get-option"]; + #[allow(clippy::unnecessary_unwrap)] pub(super) fn command_mode(cx: &mut Context) { let mut prompt = Prompt::new( @@ -2798,20 +2814,7 @@ pub(super) fn command_mode(cx: &mut Context) { } }, ); - prompt.doc_fn = Box::new(|input: &str| { - let part = input.split(' ').next().unwrap_or_default(); - - if let Some(typed::TypableCommand { doc, aliases, .. }) = - typed::TYPABLE_COMMAND_MAP.get(part) - { - if aliases.is_empty() { - return Some((*doc).into()); - } - return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); - } - - None - }); + prompt.doc_fn = Box::new(command_mode_documentation); // Calculate initial completion prompt.recalculate_completion(cx.editor); @@ -2826,6 +2829,35 @@ fn argument_number_of(shellwords: &Shellwords) -> usize { } } +fn command_mode_documentation(input: &str) -> Option> { + let mut words_iter = input.split(' '); + let part = words_iter.next().unwrap_or_default(); + + if OPTION_DOCUMENTATION.is_ok() && OPTION_DOCUMENTATION_COMMANDS.contains(&part) { + let option_name = words_iter.next().unwrap_or_default(); + + match OPTION_DOCUMENTATION.as_ref().unwrap().get_help(option_name) { + HelpResult::Documentation(text) => return Some(Cow::Owned(text.to_string())), + HelpResult::NoDocumentationAvailable => { + return Some(Cow::from(format!( + "No option documentation available for '{}'.", + option_name + ))) + } + HelpResult::UnknownCommand => (), + } + } + + if let Some(typed::TypableCommand { doc, aliases, .. }) = typed::TYPABLE_COMMAND_MAP.get(part) { + if aliases.is_empty() { + return Some((*doc).into()); + } + return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); + } + + None +} + #[test] fn test_argument_number_of() { let cases = vec![ @@ -2843,3 +2875,28 @@ fn test_argument_number_of() { assert_eq!(case.1, argument_number_of(&Shellwords::from(case.0))); } } + +#[test] +fn command_mode_doc_basic() { + assert_eq!( + None, + command_mode_documentation("buffer-n") // Incomplete command. + ); + assert_eq!( + Some(Cow::from("Goto next buffer.\nAliases: bn, bnext")), + command_mode_documentation("buffer-next") + ); +} + +#[test] +fn command_mode_doc_detailed_options() { + assert_eq!( + Some(Cow::from("Set a config option at runtime.\nFor example to disable smart case search, use `:set search.smart-case false`.\nAliases: set")), + command_mode_documentation("set-option buffe") // Incomplete option, show set-option documentation. + ); + + assert_eq!( + Some(Cow::from("Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use)")), + command_mode_documentation("set-option bufferline") + ); +} diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index e3f98a8d3c296..ad4e8efee5d54 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -44,6 +44,7 @@ log = "~0.4" which = "4.4" parking_lot = "0.12.1" +schemars = { version = "0.8.12", features = ["derive_json_schema"] } [target.'cfg(windows)'.dependencies] diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 7207baf38a2e0..23af7d3e2a810 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -15,6 +15,7 @@ use helix_vcs::DiffProviderRegistry; use futures_util::stream::select_all::SelectAll; use futures_util::{future, StreamExt}; use helix_lsp::Call; +use schemars::JsonSchema; use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ @@ -49,36 +50,17 @@ use helix_core::{Position, Selection}; use helix_dap as dap; use helix_lsp::lsp; -use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize}; use arc_swap::access::{DynAccess, DynGuard}; -fn deserialize_duration_millis<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let millis = u64::deserialize(deserializer)?; - Ok(Duration::from_millis(millis)) -} - -fn serialize_duration_millis(duration: &Duration, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_u64( - duration - .as_millis() - .try_into() - .map_err(|_| serde::ser::Error::custom("duration value overflowed u64"))?, - ) -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct GutterConfig { - /// Gutter Layout + /// Gutters to display: Available are diagnostics and diff and line-numbers and spacer, note that diagnostics also + /// includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty. pub layout: Vec, - /// Options specific to the "line-numbers" gutter + // Options specific to the "line-numbers" gutter. pub line_numbers: GutterLineNumbersConfig, } @@ -150,7 +132,7 @@ where deserializer.deserialize_any(GutterVisitor) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct GutterLineNumbersConfig { /// Minimum number of characters to use for line number gutter. Defaults to 3. @@ -163,10 +145,11 @@ impl Default for GutterLineNumbersConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +// TODO: Import text here. pub struct FilePickerConfig { - /// IgnoreOptions + // IgnoreOptions /// Enables ignoring hidden files. /// Whether to hide hidden files in file picker and global search results. Defaults to true. pub hidden: bool, @@ -189,7 +172,7 @@ pub struct FilePickerConfig { /// Enables reading `.git/info/exclude` files. /// Whether to hide files listed in .git/info/exclude in file picker and global search results. Defaults to true. pub git_exclude: bool, - /// WalkBuilder options + // WalkBuilder options /// Maximum Depth to recurse directories in file picker and global search. Defaults to `None`. pub max_depth: Option, } @@ -210,58 +193,57 @@ impl Default for FilePickerConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct Config { - /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. + /// Number of lines of padding around the edge of the screen when scrolling. pub scrolloff: usize, - /// Number of lines to scroll at once. Defaults to 3 + /// Number of lines to scroll per scroll wheel step. pub scroll_lines: isize, - /// Mouse support. Defaults to true. + /// Enable mouse mode. pub mouse: bool, - /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. + /// Shell to use when running external commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. pub shell: Vec, - /// Line number mode. + /// Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. pub line_number: LineNumber, - /// Highlight the lines cursors are currently on. Defaults to false. + /// Highlight all lines with a cursor. pub cursorline: bool, - /// Highlight the columns cursors are currently on. Defaults to false. + /// Highlight all columns with a cursor. pub cursorcolumn: bool, #[serde(deserialize_with = "deserialize_gutter_seq_or_struct")] pub gutters: GutterConfig, - /// Middle click paste support. Defaults to true. + /// Middle click paste support. pub middle_click_paste: bool, + // TODO: Not parsed by JsonSchema magic. /// Automatic insertion of pairs to parentheses, brackets, /// etc. Optionally, this can be a list of 2-tuples to specify a /// global list of characters to pair. Defaults to true. pub auto_pairs: AutoPairConfig, - /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. + /// Enable automatic pop up of auto-completion. pub auto_completion: bool, - /// Automatic formatting on save. Defaults to true. + /// Enable automatic formatting on save. pub auto_format: bool, - /// Automatic save on focus lost. Defaults to false. + // TODO: Markdown elements? + /// Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. pub auto_save: bool, /// Set a global text_width pub text_width: usize, - /// Time in milliseconds since last keypress before idle timers trigger. - /// Used for autocompletion, set to 0 for instant. Defaults to 400ms. - #[serde( - serialize_with = "serialize_duration_millis", - deserialize_with = "deserialize_duration_millis" - )] - pub idle_timeout: Duration, + /// Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. + pub idle_timeout: u64, + /// The min-length of word under cursor to trigger autocompletion. pub completion_trigger_len: u8, /// Whether to instruct the LSP to replace the entire word when applying a completion /// or to only insert new text pub completion_replace: bool, - /// Whether to display infoboxes. Defaults to true. + /// Whether to display infoboxes. pub auto_info: bool, pub file_picker: FilePickerConfig, - /// Configuration of the statusline elements + /// Configuration of the statusline elements. pub statusline: StatusLineConfig, - /// Shape for cursor in each mode + // TODO: Not parsed by JsonSchema magic. + /// Shape for cursor in each mode. pub cursor_shape: CursorShapeConfig, - /// Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. Defaults to `false`. + /// Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. pub true_color: bool, /// Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative. Defaults to `false`. pub undercurl: bool, @@ -270,20 +252,20 @@ pub struct Config { pub search: SearchConfig, pub lsp: LspConfig, pub terminal: Option, - /// Column numbers at which to draw the rulers. Default to `[]`, meaning no rulers. + /// List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. pub rulers: Vec, #[serde(default)] pub whitespace: WhitespaceConfig, - /// Persistently display open buffers along the top + /// Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use). pub bufferline: BufferLine, /// Vertical indent width guides. pub indent_guides: IndentGuidesConfig, - /// Whether to color modes with different colors. Defaults to `false`. + /// Whether to color the mode indicator with different colors depending on the mode itself. pub color_modes: bool, pub soft_wrap: SoftWrap, } -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct TerminalConfig { pub command: String, @@ -336,7 +318,7 @@ pub fn get_terminal_provider() -> Option { None } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct LspConfig { /// Enables LSP @@ -363,7 +345,7 @@ impl Default for LspConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct SearchConfig { /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. @@ -372,7 +354,7 @@ pub struct SearchConfig { pub wrap_around: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct StatusLineConfig { pub left: Vec, @@ -401,7 +383,7 @@ impl Default for StatusLineConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct ModeConfig { pub normal: String, @@ -419,7 +401,7 @@ impl Default for ModeConfig { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum StatusLineElement { /// The editor mode (Normal, Insert, Visual/Selection) @@ -531,8 +513,37 @@ impl Default for CursorShapeConfig { } } +// Since CursorShapeConfig has some performance optimizations and a custom (de)serializer, +// the derived JsonSchema is not accurate to the input from users. +impl JsonSchema for CursorShapeConfig { + fn schema_name() -> String { + "CursorShapeConfig".to_owned() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + // Here we create a type that looks a lot more like the input we expect in the configuration + // and from the user at runtime and use that for creating the schema. + + #[derive(JsonSchema, Default)] + #[allow(dead_code)] + struct FakeCursorShapeConfig { + /// Cursor used when in insert mode. Default: block. + insert: CursorKind, + + /// Cursor used when in normal mode. Default: block. + normal: CursorKind, + + /// Cursor used when in select mode. Default: block. + select: CursorKind, + } + + // TODO: This resulting schema does not have a default value for some reason. :-( + return ::json_schema(gen); + } +} + /// bufferline render modes -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum BufferLine { /// Don't render bufferline @@ -549,7 +560,7 @@ impl Default for BufferLine { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum LineNumber { /// Show absolute line number @@ -572,7 +583,7 @@ impl std::str::FromStr for LineNumber { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum GutterType { /// Show diagnostics and other features like breakpoints @@ -599,8 +610,9 @@ impl std::str::FromStr for GutterType { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default)] +// TODO: Not handled by JsonSchema magic. pub struct WhitespaceConfig { pub render: WhitespaceRender, pub characters: WhitespaceCharacters, @@ -615,7 +627,7 @@ impl Default for WhitespaceConfig { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(untagged, rename_all = "kebab-case")] pub enum WhitespaceRender { Basic(WhitespaceRenderValue), @@ -628,7 +640,7 @@ pub enum WhitespaceRender { }, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum WhitespaceRenderValue { None, @@ -672,7 +684,7 @@ impl WhitespaceRender { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct WhitespaceCharacters { pub space: char, @@ -694,11 +706,16 @@ impl Default for WhitespaceCharacters { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default, rename_all = "kebab-case")] pub struct IndentGuidesConfig { + /// Whether to render indent guides. pub render: bool, + + /// Literal character to use for rendering the indent guide. pub character: char, + + /// Number of indent levels to skip. pub skip_levels: u8, } @@ -712,6 +729,12 @@ impl Default for IndentGuidesConfig { } } +impl Config { + fn idle_timeout(&self) -> Duration { + Duration::from_millis(self.idle_timeout) + } +} + impl Default for Config { fn default() -> Self { Self { @@ -732,7 +755,7 @@ impl Default for Config { auto_completion: true, auto_format: true, auto_save: false, - idle_timeout: Duration::from_millis(400), + idle_timeout: 400, completion_trigger_len: 2, auto_info: true, file_picker: FilePickerConfig::default(), @@ -961,7 +984,7 @@ impl Editor { clipboard_provider: get_clipboard_provider(), status_msg: None, autoinfo: None, - idle_timer: Box::pin(sleep(conf.idle_timeout)), + idle_timer: Box::pin(sleep(conf.idle_timeout())), last_motion: None, last_completion: None, config, @@ -1004,7 +1027,7 @@ impl Editor { let config = self.config(); self.idle_timer .as_mut() - .reset(Instant::now() + config.idle_timeout); + .reset(Instant::now() + config.idle_timeout()); } pub fn clear_status(&mut self) { diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs index e813fb5604b8d..da715ef081f19 100644 --- a/helix-view/src/graphics.rs +++ b/helix-view/src/graphics.rs @@ -1,11 +1,12 @@ use bitflags::bitflags; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ cmp::{max, min}, str::FromStr, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] /// UNSTABLE pub enum CursorKind { diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index ed1182947b6f4..7fd478d240ab6 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -11,3 +11,5 @@ helix-core = { version = "0.6", path = "../helix-core" } helix-view = { version = "0.6", path = "../helix-view" } helix-loader = { version = "0.6", path = "../helix-loader" } toml = "0.7" +schemars = { version = "0.8.12", features = ["derive_json_schema"] } +serde_json = "1.0.93" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1421fd1a1de67..3bb274f962f9b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,6 +15,19 @@ pub mod tasks { use crate::themelint::{lint, lint_all}; use crate::DynError; + use schemars::schema_for; + + pub fn dump_config_schema() -> Result<(), DynError> { + let schema = schema_for!(helix_view::editor::Config); + + println!( + "{}", + serde_json::to_string_pretty(&schema).expect("valid schema to be generated") + ); + + Ok(()) + } + pub fn docgen() -> Result<(), DynError> { write(TYPABLE_COMMANDS_MD_OUTPUT, &typable_commands()?); write(LANG_SUPPORT_MD_OUTPUT, &lang_features()?); @@ -54,6 +67,7 @@ fn main() -> Result<(), DynError> { "docgen" => tasks::docgen()?, "themelint" => tasks::themelint(env::args().nth(2))?, "query-check" => tasks::querycheck()?, + "dump-config-schema" => tasks::dump_config_schema()?, invalid => return Err(format!("Invalid task name: {}", invalid).into()), }, };