diff --git a/Cargo.lock b/Cargo.lock index 85438e01ec12..86c0c5a33604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ "pulldown-cmark", "retain_mut", "serde", + "serde_ignored", "serde_json", "signal-hook", "signal-hook-tokio", @@ -913,6 +914,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_ignored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2c7d39d14f2f2ea82239de71594782f186fd03501ac81f0ce08e674819ff2f" +dependencies = [ + "serde", +] + [[package]] name = "serde_json" version = "1.0.79" diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index e736b37081d4..9a223727979e 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -179,7 +179,7 @@ pub struct IndentationConfiguration { /// Configuration for auto pairs #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] +#[serde(rename_all = "kebab-case", untagged)] pub enum AutoPairConfig { /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. Enable(bool), diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index 2e0b774ba6a7..efbca7f7bbd4 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -61,6 +61,7 @@ toml = "0.5" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +serde_ignored = "0.1.2" # ripgrep for global search grep-regex = "0.1.9" diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index ddf9e8d6dc6a..9dee71276266 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -19,6 +19,7 @@ use crate::{ use log::{error, warn}; use std::{ + collections::BTreeSet, io::{stdin, stdout, Write}, sync::Arc, time::{Duration, Instant}, @@ -271,10 +272,11 @@ impl Application { } fn refresh_config(&mut self) { - let config = Config::load(helix_loader::config_file()).unwrap_or_else(|err| { - self.editor.set_error(err.to_string()); - Config::default() - }); + let config = Config::load(helix_loader::config_file(), &mut BTreeSet::new()) + .unwrap_or_else(|err| { + self.editor.set_error(err.to_string()); + Config::default() + }); // Refresh theme if let Some(theme) = config.theme.clone() { diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 4407a882f838..5e04fae7bfa7 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,19 +1,23 @@ use crate::keymap::{default::default, merge_keys, Keymap}; use helix_view::document::Mode; use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fmt::Display; use std::io::Error as IOError; use std::path::PathBuf; use toml::de::Error as TomlError; +use helix_view::editor::ok_or_default; + +// NOTE: The fields in this struct use the deserializer ok_or_default to continue parsing when +// there is an error. In that case, it will use the default value. #[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Config { + #[serde(default, deserialize_with = "ok_or_default")] pub theme: Option, - #[serde(default = "default")] + #[serde(default = "default", deserialize_with = "ok_or_default")] pub keys: HashMap, - #[serde(default)] + #[serde(default, deserialize_with = "ok_or_default")] pub editor: helix_view::editor::Config, } @@ -43,17 +47,24 @@ impl Display for ConfigLoadError { } impl Config { - pub fn load(config_path: PathBuf) -> Result { + pub fn load( + config_path: PathBuf, + ignored_keys: &mut BTreeSet, + ) -> Result { match std::fs::read_to_string(config_path) { - Ok(config) => toml::from_str(&config) + Ok(config) => { + serde_ignored::deserialize(&mut toml::Deserializer::new(&config), |path| { + ignored_keys.insert(path.to_string()); + }) .map(merge_keys) - .map_err(ConfigLoadError::BadConfig), + .map_err(ConfigLoadError::BadConfig) + } Err(err) => Err(ConfigLoadError::Error(err)), } } - pub fn load_default() -> Result { - Config::load(helix_loader::config_file()) + pub fn load_default(ignored_keys: &mut BTreeSet) -> Result { + Config::load(helix_loader::config_file(), ignored_keys) } } @@ -104,4 +115,51 @@ mod tests { let default_keys = Config::default().keys; assert_eq!(default_keys, default()); } + + #[test] + fn partial_config_parsing() { + use crate::keymap; + use crate::keymap::Keymap; + use helix_core::hashmap; + use helix_view::document::Mode; + + let sample_keymaps = r#" + theme = false + + [editor] + line-number = false + mous = "false" + scrolloff = 7 + + [editor.search] + smart-case = false + + [keys.insert] + y = "move_line_down" + SC-a = "delete_selection" + + [keys.normal] + A-F12 = "move_next_word_end" + "#; + + let mut editor = helix_view::editor::Config::default(); + editor.search.smart_case = false; + editor.scrolloff = 7; + + assert_eq!( + toml::from_str::(sample_keymaps).unwrap(), + Config { + keys: hashmap! { + Mode::Insert => Keymap::new(keymap!({ "Insert mode" + "y" => move_line_down, + })), + Mode::Normal => Keymap::new(keymap!({ "Normal mode" + "A-F12" => move_next_word_end, + })), + }, + editor, + ..Default::default() + } + ); + } } diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 37dbc5de2e98..5a697acef161 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -34,6 +34,18 @@ impl<'de> Deserialize<'de> for KeyTrieNode { D: serde::Deserializer<'de>, { let map = HashMap::::deserialize(deserializer)?; + let map: HashMap<_, _> = map + .into_iter() + .filter_map(|(key, value)| { + // Filter the KeyEvents that has an invalid value because those come from a + // parsing error and we should just ignore them. + if key == KeyEvent::invalid() { + None + } else { + Some((key, value)) + } + }) + .collect(); let order = map.keys().copied().collect::>(); // NOTE: map.keys() has arbitrary order Ok(Self { map, diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 4a3434d1f01c..203d9f808f53 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -2,6 +2,8 @@ use anyhow::{Context, Error, Result}; use helix_term::application::Application; use helix_term::args::Args; use helix_term::config::{Config, ConfigLoadError}; +use helix_view::input::{get_config_error, set_config_error}; +use std::collections::BTreeSet; use std::path::PathBuf; fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { @@ -114,7 +116,8 @@ FLAGS: std::fs::create_dir_all(&conf_dir).ok(); } - let config = match Config::load_default() { + let mut ignored_keys = BTreeSet::new(); + let config = match Config::load_default(&mut ignored_keys) { Ok(config) => config, Err(err) => { match err { @@ -134,6 +137,19 @@ FLAGS: } }; + if !ignored_keys.is_empty() { + let keys = ignored_keys.into_iter().collect::>().join(", "); + eprintln!("Ignored keys in config: {}", keys); + set_config_error(); + } + + if get_config_error() { + eprintln!("Press to continue"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + } + setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; // TODO: use the thread local executor to spawn the application task separately from the work pool diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 9a2b4297f8b2..1f78fb4dfee5 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -3,7 +3,7 @@ use crate::{ document::{Mode, SCRATCH_BUFFER_NAME}, graphics::{CursorKind, Rect}, info::Info, - input::KeyEvent, + input::{set_config_error, KeyEvent}, theme::{self, Theme}, tree::{self, Tree}, Document, DocumentId, View, ViewId, @@ -44,6 +44,20 @@ use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer} use arc_swap::access::{DynAccess, DynGuard}; +pub fn ok_or_default<'a, T, D>(deserializer: D) -> Result +where + T: Deserialize<'a> + Default, + D: Deserializer<'a>, +{ + let result = T::deserialize(deserializer); + if let Err(ref error) = result { + // FIXME: the error message does not contain the key or the position. + eprintln!("Bad config for value: {}", error); + set_config_error(); + } + Ok(result.unwrap_or_default()) +} + fn deserialize_duration_millis<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -65,7 +79,7 @@ where } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +#[serde(rename_all = "kebab-case", default)] pub struct FilePickerConfig { /// IgnoreOptions /// Enables ignoring hidden files. @@ -104,26 +118,36 @@ impl Default for FilePickerConfig { } } +// NOTE: The fields in this struct use the deserializer ok_or_default to continue parsing when +// there is an error. In that case, it will use the default value. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +#[serde(rename_all = "kebab-case", default)] pub struct Config { /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. + #[serde(deserialize_with = "ok_or_default")] pub scrolloff: usize, /// Number of lines to scroll at once. Defaults to 3 + #[serde(deserialize_with = "ok_or_default")] pub scroll_lines: isize, /// Mouse support. Defaults to true. + #[serde(deserialize_with = "ok_or_default")] pub mouse: bool, /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. + #[serde(deserialize_with = "ok_or_default")] pub shell: Vec, /// Line number mode. + #[serde(deserialize_with = "ok_or_default")] pub line_number: LineNumber, /// Middle click paste support. Defaults to true. + #[serde(deserialize_with = "ok_or_default")] pub middle_click_paste: bool, /// 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. + #[serde(deserialize_with = "ok_or_default")] pub auto_pairs: AutoPairConfig, /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. + #[serde(deserialize_with = "ok_or_default")] pub auto_completion: bool, /// Time in milliseconds since last keypress before idle timers trigger. /// Used for autocompletion, set to 0 for instant. Defaults to 400ms. @@ -132,28 +156,35 @@ pub struct Config { deserialize_with = "deserialize_duration_millis" )] pub idle_timeout: Duration, + #[serde(deserialize_with = "ok_or_default")] pub completion_trigger_len: u8, /// Whether to display infoboxes. Defaults to true. + #[serde(deserialize_with = "ok_or_default")] pub auto_info: bool, + #[serde(deserialize_with = "ok_or_default")] pub file_picker: FilePickerConfig, /// Shape for cursor in each mode + #[serde(deserialize_with = "ok_or_default")] 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`. + #[serde(deserialize_with = "ok_or_default")] pub true_color: bool, /// Search configuration. - #[serde(default)] + #[serde(default, deserialize_with = "ok_or_default")] pub search: SearchConfig, + #[serde(default, deserialize_with = "ok_or_default")] pub lsp: LspConfig, } #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] pub struct LspConfig { + #[serde(deserialize_with = "ok_or_default")] pub display_messages: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] +#[serde(rename_all = "kebab-case", default)] pub struct SearchConfig { /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. pub smart_case: bool, @@ -226,6 +257,12 @@ pub enum LineNumber { Relative, } +impl Default for LineNumber { + fn default() -> Self { + Self::Absolute + } +} + impl std::str::FromStr for LineNumber { type Err = anyhow::Error; diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index 14dadc3b97bc..2fdfae41451f 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -1,11 +1,26 @@ //! Input event handling, currently backed by crossterm. use anyhow::{anyhow, Error}; use helix_core::unicode::width::UnicodeWidthStr; -use serde::de::{self, Deserialize, Deserializer}; +use serde::de::{Deserialize, Deserializer}; use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::keyboard::{KeyCode, KeyModifiers}; +static HAD_CONFIG_ERROR: AtomicBool = AtomicBool::new(false); + +/// To be called by the Config deserializer when there's an error so that the editor knows it +/// should wait the user to press Enter in order for the user to have the time to see the error +/// before the editor shows up. +pub fn set_config_error() { + HAD_CONFIG_ERROR.store(true, Ordering::SeqCst); +} + +/// Return true if there was an error during the Config deserialization. +pub fn get_config_error() -> bool { + HAD_CONFIG_ERROR.load(Ordering::SeqCst) +} + /// Represents a key event. // We use a newtype here because we want to customize Deserialize and Display. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)] @@ -22,6 +37,14 @@ impl KeyEvent { _ => None, } } + + /// Return an invalid KeyEvent to use for cases where an event key cannot be parsed. + pub fn invalid() -> Self { + Self { + code: KeyCode::Null, + modifiers: KeyModifiers::NONE, + } + } } pub(crate) mod keys { @@ -210,7 +233,13 @@ impl<'de> Deserialize<'de> for KeyEvent { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - s.parse().map_err(de::Error::custom) + let key_event = s.parse(); + if let Err(ref error) = key_event { + // TODO: show error position. + eprintln!("Bad config for key: {}", error); + set_config_error(); + } + Ok(key_event.unwrap_or_else(|_| KeyEvent::invalid())) } }