diff --git a/Cargo.lock b/Cargo.lock index b70f34c412b1..7f715ff0cc5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1116,6 +1116,7 @@ name = "helix-core" version = "0.6.0" dependencies = [ "ahash 0.8.3", + "anyhow", "arc-swap", "bitflags", "chrono", diff --git a/book/src/configuration.md b/book/src/configuration.md index 7514a3d0fcc3..27b2d6893212 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -26,7 +26,11 @@ hidden = false ``` You may also specify a file to use for configuration with the `-c` or -`--config` CLI argument: `hx -c path/to/custom-config.toml`. +`--config` CLI argument: `hx -c path/to/custom-config.toml`. + +Finally, you can have a `config.toml` local to a project by putting it under a `.helix` directory in your repository. +Its settings will be merged with the configuration directory `config.toml` and the built-in configuration, +if you have enabled the feature under `[editor.security]` in your global configuration. It is also possible to trigger configuration file reloading by sending the `USR1` signal to the helix process, e.g. via `pkill -USR1 hx`. This is only supported @@ -50,9 +54,11 @@ on unix operating systems. | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `auto-format` | Enable automatic formatting on save. | `true` | | `auto-save` | 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. | `false` | +| `load-local-config` | Look for `$PWD/.helix/config.toml` and merge with default and user configuration if it exists. | `true` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | +| `sorted-infobox` | Sort infoboxes by key event category rather than by predefined command categories | `false` | | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | | `bufferline` | 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) | `never` | diff --git a/book/src/install.md b/book/src/install.md index 7df9e6c778ea..543f9f8e664d 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -108,8 +108,9 @@ RUSTFLAGS="-C target-feature=-crt-static" Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the -config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overridden -via the `HELIX_RUNTIME` environment variable. +config directory (for example `~/.config/helix/runtime` on Linux/macOS). An alternative runtime directory can +be used by setting the `HELIX_RUNTIME` environment variable. Both runtime directories can be used at the same +time, with the files residing under the config runtime directory given priority. | OS | Command | | -------------------- | ------------------------------------------------ | @@ -134,11 +135,6 @@ cd %appdata%\helix mklink /D runtime "\runtime" ``` -The runtime location can be overridden via the `HELIX_RUNTIME` environment variable. - -> NOTE: if `HELIX_RUNTIME` is set prior to calling `cargo install --path helix-term --locked`, -> tree-sitter grammars will be built in `$HELIX_RUNTIME/grammars`. - If you plan on keeping the repo locally, an alternative to copying/symlinking runtime files is to set `HELIX_RUNTIME=/path/to/helix/runtime` (`HELIX_RUNTIME=$PWD/runtime` if you're in the helix repo directory). diff --git a/book/src/remapping.md b/book/src/remapping.md index 8339e05fce8c..0d58fd719610 100644 --- a/book/src/remapping.md +++ b/book/src/remapping.md @@ -49,6 +49,31 @@ c = ":run-shell-command cargo build" t = ":run-shell-command cargo test" ``` +## Custom descriptions + +Remapping of singular typable commands or seqences of any commands, can be given a custom description for the keymap menus (infoboxes). +This is the text on each row that's paired together with each key event trigger. + +```toml +[keys.normal] +A-k = { description = "Edit Config", exec = ":open ~/.config/helix/config.toml" } +# (Note that the example example above mostly for illustrative purposes, a :config-open is provided out of the box.) +"C-r" = { "description" = "Sort selection", "exec" = ["split_selection_on_newline", ":sort", "collapse_selection", "keep_primary_selection"] } +``` + +A custom descriptions can also be defined for sub-menus. +Aside from being them being shown in the parent-menu infobox, +sub-menu descriptions also act as infobox titles for the submenu itself. + +```toml +# A submenu accessed though space->f in normal mode. +[keys.normal.space.f] +description = "File" +f = "file_picker" +s = { description = "Save", exec = ":write" } +c = { description = "Format", exec = ":format" } +``` + ## Special keys and modifiers Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes @@ -76,5 +101,8 @@ Ctrl, Shift and Alt modifiers are encoded respectively with the prefixes Keys can be disabled by binding them to the `no_op` command. +Making a mode "sticky" can be achieved by adding `sticky = true` to the mapping. +(Predefined sticky keytries can like wise be made unsticky with `sticky = false`.) + Commands can be found at [Keymap](https://docs.helix-editor.com/keymap.html) Commands. > Commands can also be found in the source code at [`helix-term/src/commands.rs`](https://github.com/helix-editor/helix/blob/master/helix-term/src/commands.rs) at the invocation of `static_commands!` macro and the `TypableCommandList`. diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 62ec87b485ca..a2ffc828ec93 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -17,6 +17,7 @@ integration = [] [dependencies] helix-loader = { version = "0.6", path = "../helix-loader" } +anyhow = "1" ropey = { version = "1.6.0", default-features = false, features = ["simd"] } smallvec = "1.10" smartstring = "1.0.1" diff --git a/helix-core/src/config.rs b/helix-core/src/config.rs deleted file mode 100644 index 2076fc2244df..000000000000 --- a/helix-core/src/config.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// Syntax configuration loader based on built-in languages.toml. -pub fn default_syntax_loader() -> crate::syntax::Configuration { - helix_loader::config::default_lang_config() - .try_into() - .expect("Could not serialize built-in languages.toml") -} -/// Syntax configuration loader based on user configured languages.toml. -pub fn user_syntax_loader() -> Result { - helix_loader::config::user_lang_config()?.try_into() -} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index e3f862a6054c..66a1e0755e29 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -3,7 +3,6 @@ pub use encoding_rs as encoding; pub mod auto_pairs; pub mod chars; pub mod comment; -pub mod config; pub mod diagnostic; pub mod diff; pub mod doc_formatter; diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 1b6c1b1dab3e..1b44332cdd85 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -8,6 +8,7 @@ use crate::{ }; use ahash::RandomState; +use anyhow; use arc_swap::{ArcSwap, Guard}; use bitflags::bitflags; use hashbrown::raw::RawTable; @@ -60,13 +61,26 @@ fn default_timeout() -> u64 { } #[derive(Debug, Serialize, Deserialize)] -pub struct Configuration { +pub struct LanguageConfigurations { pub language: Vec, } -impl Default for Configuration { +impl LanguageConfigurations { + // Local, user config, and system language configs + pub fn merged() -> Result { + let merged_lang_configs = helix_loader::merged_lang_config()?; + merged_lang_configs + .try_into() + .map_err(|error| anyhow::anyhow!("{}", error)) + } +} + +impl Default for LanguageConfigurations { fn default() -> Self { - crate::config::default_syntax_loader() + toml::from_str( + &std::fs::read_to_string(helix_loader::repo_paths::default_lang_configs()).unwrap(), + ) + .expect("Failed to deserialize built-in languages.toml") } } @@ -561,7 +575,7 @@ pub struct Loader { } impl Loader { - pub fn new(config: Configuration) -> Self { + pub fn new(config: LanguageConfigurations) -> Self { let mut loader = Self { language_configs: Vec::new(), language_config_ids_by_extension: HashMap::new(), @@ -2272,7 +2286,7 @@ mod test { "#, ); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language("rust").unwrap(); let query = Query::new(language, query_str).unwrap(); @@ -2331,7 +2345,7 @@ mod test { .map(String::from) .collect(); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( @@ -2434,7 +2448,7 @@ mod test { ) { let source = Rope::from_str(source); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(LanguageConfigurations { language: vec![] }); let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs index 01c966c8c4bd..628e5d54d802 100644 --- a/helix-loader/src/grammar.rs +++ b/helix-loader/src/grammar.rs @@ -67,8 +67,9 @@ pub fn get_language(name: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn get_language(name: &str) -> Result { use libloading::{Library, Symbol}; - let mut library_path = crate::runtime_dir().join("grammars").join(name); - library_path.set_extension(DYLIB_EXTENSION); + let mut rel_library_path = PathBuf::from("grammars").join(name); + rel_library_path.set_extension(DYLIB_EXTENSION); + let library_path = crate::get_runtime_file(&rel_library_path); let library = unsafe { Library::new(&library_path) } .with_context(|| format!("Error opening dynamic library {:?}", library_path))?; @@ -191,7 +192,7 @@ pub fn build_grammars(target: Option) -> Result<()> { // merged. The `grammar_selection` key of the config is then used to filter // down all grammars into a subset of the user's choosing. fn get_grammar_configs() -> Result> { - let config: Configuration = crate::config::user_lang_config() + let config: Configuration = crate::merged_lang_config() .context("Could not parse languages.toml")? .try_into()?; @@ -252,7 +253,7 @@ fn fetch_grammar(grammar: GrammarConfiguration) -> Result { remote, revision, .. } = grammar.source { - let grammar_dir = crate::runtime_dir() + let grammar_dir = crate::get_first_runtime_dir() .join("grammars") .join("sources") .join(&grammar.grammar_id); @@ -350,7 +351,7 @@ fn build_grammar(grammar: GrammarConfiguration, target: Option<&str>) -> Result< let grammar_dir = if let GrammarSource::Local { path } = &grammar.source { PathBuf::from(&path) } else { - crate::runtime_dir() + crate::get_first_runtime_dir() .join("grammars") .join("sources") .join(&grammar.grammar_id) @@ -401,7 +402,10 @@ fn build_tree_sitter_library( None } }; - let parser_lib_path = crate::runtime_dir().join("grammars"); + let parser_lib_path = crate::get_runtime_dirs() + .first() + .expect("No runtime directories provided") // guaranteed by post-condition + .join("grammars"); let mut library_path = parser_lib_path.join(&grammar.grammar_id); library_path.set_extension(DYLIB_EXTENSION); @@ -511,9 +515,6 @@ fn mtime(path: &Path) -> Result { /// Gives the contents of a file from a language's `runtime/queries/` /// directory pub fn load_runtime_file(language: &str, filename: &str) -> Result { - let path = crate::RUNTIME_DIR - .join("queries") - .join(language) - .join(filename); + let path = crate::get_runtime_file(&PathBuf::from("queries").join(language).join(filename)); std::fs::read_to_string(path) } diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs index 8dc2928adc9f..0181559b1327 100644 --- a/helix-loader/src/lib.rs +++ b/helix-loader/src/lib.rs @@ -1,113 +1,210 @@ -pub mod config; pub mod grammar; +pub mod repo_paths; +pub mod ts_probe; +use anyhow::Error; use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub const VERSION_AND_GIT_HASH: &str = env!("VERSION_AND_GIT_HASH"); -pub static RUNTIME_DIR: once_cell::sync::Lazy = once_cell::sync::Lazy::new(runtime_dir); - +static LOG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); static CONFIG_FILE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); +static RUNTIME_DIRS: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(set_runtime_dirs); -pub fn initialize_config_file(specified_file: Option) { - let config_file = specified_file.unwrap_or_else(|| { - let config_dir = config_dir(); +pub fn log_file() -> PathBuf { + match LOG_FILE.get() { + Some(log_path) => log_path.to_path_buf(), + None => { + setup_log_file(None); + log_file() + } + } +} + +// TODO: allow env var override +pub fn cache_dir() -> PathBuf { + get_base_stategy().cache_dir().join("helix") +} +pub fn config_file() -> PathBuf { + match CONFIG_FILE.get() { + Some(config_path) => config_path.to_path_buf(), + None => { + setup_config_file(None); + config_file() + } + } +} + +// TODO: allow env var override +pub fn user_config_dir() -> PathBuf { + get_base_stategy().config_dir().join("helix") +} + +/// Returns a non-existent path relative to the local executable if none are found. +pub fn get_runtime_file(relative_path: &Path) -> PathBuf { + get_runtime_dirs() + .iter() + .find_map(|runtime_dir| { + let path = runtime_dir.join(relative_path); + match path.exists() { + true => Some(path), + false => None, + } + }) + .unwrap_or_else(|| { + get_runtime_dirs() + .last() + .expect("Path to local executable.") + .join(relative_path) + }) +} + +pub fn get_first_runtime_dir() -> &'static PathBuf { + get_runtime_dirs() + .first() + .expect("should return at least one directory") +} + +pub fn get_runtime_dirs() -> &'static [PathBuf] { + &RUNTIME_DIRS +} + +pub fn theme_dirs() -> Vec { + let mut theme_dirs = vec![user_config_dir()]; + theme_dirs.extend_from_slice(get_runtime_dirs()); + theme_dirs.iter().map(|p| p.join("themes")).collect() +} + +pub fn user_lang_config_file() -> PathBuf { + user_config_dir().join("languages.toml") +} + +pub fn setup_config_file(specified_file: Option) { + let config_file = specified_file.unwrap_or_else(|| { + let config_dir = user_config_dir(); if !config_dir.exists() { std::fs::create_dir_all(&config_dir).ok(); } - config_dir.join("config.toml") }); + CONFIG_FILE.set(config_file).unwrap(); +} - // We should only initialize this value once. - CONFIG_FILE.set(config_file).ok(); +pub fn setup_log_file(specified_file: Option) { + let log_file = specified_file.unwrap_or_else(|| { + let log_dir = cache_dir(); + if !log_dir.exists() { + std::fs::create_dir_all(&log_dir).ok(); + } + log_dir.join("helix.log") + }); + LOG_FILE.set(log_file).ok(); } -pub fn runtime_dir() -> PathBuf { - if let Ok(dir) = std::env::var("HELIX_RUNTIME") { - return dir.into(); +/// Runtime directory location priority: +/// 1. Sibling directory to `$CARGO_MANIFEST_DIR`, if set. (Often done by cargo) +/// 2. User data directory `$XDG_RUNTIME_DIR`/`%AppData%`, if it exists. +/// 3. Under user config directory, given that it exists. +/// 4. `$HELIX_RUNTIME`, if set. +/// 5. Under path to helix executable, always included. However, it might not exist. +fn set_runtime_dirs() -> Vec { + let mut runtime_dirs = Vec::new(); + const RUNTIME_DIR_NAME: &str = "runtime"; + if std::env::var("CARGO_MANIFEST_DIR").is_ok() { + let path = repo_paths::project_root().join(RUNTIME_DIR_NAME); + log::debug!("runtime dir: {}", path.to_string_lossy()); + runtime_dirs.push(path); } - if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") { - // this is the directory of the crate being run by cargo, we need the workspace path so we take the parent - let path = std::path::PathBuf::from(dir).parent().unwrap().join(RT_DIR); - log::debug!("runtime dir: {}", path.to_string_lossy()); - return path; + let data_dir = get_base_stategy().data_dir(); + if data_dir.exists() { + runtime_dirs.push(data_dir.join("helix").join(RUNTIME_DIR_NAME)); } - const RT_DIR: &str = "runtime"; - let conf_dir = config_dir().join(RT_DIR); + let conf_dir = user_config_dir().join(RUNTIME_DIR_NAME); if conf_dir.exists() { - return conf_dir; + runtime_dirs.push(conf_dir); + } + + if let Ok(dir) = std::env::var("HELIX_RUNTIME") { + runtime_dirs.push(dir.into()); } - // fallback to location of the executable being run // canonicalize the path in case the executable is symlinked - std::env::current_exe() - .ok() - .and_then(|path| std::fs::canonicalize(path).ok()) - .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) - .unwrap() + runtime_dirs.push( + std::env::current_exe() + .ok() + .and_then(|path| std::fs::canonicalize(path).ok()) + .and_then(|path| { + path.parent() + .map(|path| path.to_path_buf().join(RUNTIME_DIR_NAME)) + }) + .unwrap(), + ); + + runtime_dirs } -pub fn config_dir() -> PathBuf { - // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); - let mut path = strategy.config_dir(); - path.push("helix"); - path +fn get_base_stategy() -> impl BaseStrategy { + choose_base_strategy().expect("Unable to determine system base directory specification!") } -pub fn local_config_dirs() -> Vec { - let directories = find_local_config_dirs() +pub fn merged_config() -> Result { + let config_paths: Vec = local_config_dirs() .into_iter() - .map(|path| path.join(".helix")) + .chain([user_config_dir()].into_iter()) + .map(|path| path.join("config.toml")) .collect(); - log::debug!("Located configuration folders: {:?}", directories); - directories + merge_toml_by_config_paths(config_paths) } -pub fn cache_dir() -> PathBuf { - // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); - let mut path = strategy.cache_dir(); - path.push("helix"); - path -} - -pub fn config_file() -> PathBuf { - CONFIG_FILE - .get() - .map(|path| path.to_path_buf()) - .unwrap_or_else(|| config_dir().join("config.toml")) -} - -pub fn lang_config_file() -> PathBuf { - config_dir().join("languages.toml") -} - -pub fn log_file() -> PathBuf { - cache_dir().join("helix.log") +/// Searces for language.toml in config path (user config) and in 'helix' directories +/// in opened git repository (local). Merge order: +/// local -> user config -> default/system +pub fn merged_lang_config() -> Result { + let config_paths: Vec = local_config_dirs() + .into_iter() + .chain([user_config_dir(), repo_paths::default_config_dir()].into_iter()) + .map(|path| path.join("languages.toml")) + .collect(); + merge_toml_by_config_paths(config_paths) } -pub fn find_local_config_dirs() -> Vec { - let current_dir = std::env::current_dir().expect("unable to determine current directory"); +pub fn local_config_dirs() -> Vec { + let current_dir = std::env::current_dir().expect("Unable to determine current directory."); let mut directories = Vec::new(); - for ancestor in current_dir.ancestors() { + let potential_dir = ancestor.to_path_buf().join(".helix"); + if potential_dir.is_dir() { + directories.push(potential_dir); + } if ancestor.join(".git").exists() { - directories.push(ancestor.to_path_buf()); - // Don't go higher than repo if we're in one break; - } else if ancestor.join(".helix").is_dir() { - directories.push(ancestor.to_path_buf()); } } + log::debug!("Located local configuration folders: {:?}", directories); directories } +fn merge_toml_by_config_paths(config_paths: Vec) -> Result { + let mut configs: Vec = Vec::with_capacity(config_paths.len()); + for config_path in config_paths { + if config_path.exists() { + let config_string = std::fs::read_to_string(&config_path)?; + let config = toml::from_str(&config_string)?; + configs.push(config); + } + } + + Ok(configs + .into_iter() + .reduce(|a, b| merge_toml_values(b, a, 3)) + .expect("Supplied config paths should point to at least one valid config.")) +} + /// Merge two TOML documents, merging values from `right` onto `left` /// /// When an array exists in both `left` and `right`, `right`'s array is @@ -121,6 +218,22 @@ pub fn find_local_config_dirs() -> Vec { /// documents that use a top-level array of values like the `languages.toml`, /// where one usually wants to override or add to the array instead of /// replacing it altogether. +/// +/// For example: +/// +/// left: +/// [\[language\]] +/// name = "toml" +/// language-server = { command = "taplo", args = ["lsp", "stdio"] } +/// +/// right: +/// [\[language\]] +/// language-server = { command = "/usr/bin/taplo" } +/// +/// result: +/// [\[language\]] +/// name = "toml" +/// language-server = { command = "/usr/bin/taplo" } pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usize) -> toml::Value { use toml::Value; @@ -193,12 +306,14 @@ mod merge_toml_tests { indent = { tab-width = 4, unit = " ", test = "aaa" } "#; - let base = include_bytes!("../../languages.toml"); - let base = str::from_utf8(base).expect("Couldn't parse built-in languages config"); - let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config"); + // NOTE: Exact duplicate of helix_core::LanguageConfigurations::default() + let default: Value = toml::from_str( + &std::fs::read_to_string(crate::repo_paths::default_lang_configs()).unwrap(), + ) + .expect("Failed to deserialize built-in languages.toml"); let user: Value = toml::from_str(USER).unwrap(); - let merged = merge_toml_values(base, user, 3); + let merged = merge_toml_values(default, user, 3); let languages = merged.get("language").unwrap().as_array().unwrap(); let nix = languages .iter() @@ -227,12 +342,14 @@ mod merge_toml_tests { language-server = { command = "deno", args = ["lsp"] } "#; - let base = include_bytes!("../../languages.toml"); - let base = str::from_utf8(base).expect("Couldn't parse built-in languages config"); - let base: Value = toml::from_str(base).expect("Couldn't parse built-in languages config"); + // NOTE: Exact duplicate of helix_core::LanguageConfigurations::default() + let default: Value = toml::from_str( + &std::fs::read_to_string(crate::repo_paths::default_lang_configs()).unwrap(), + ) + .expect("Failed to deserialize built-in languages.toml"); let user: Value = toml::from_str(USER).unwrap(); - let merged = merge_toml_values(base, user, 3); + let merged = merge_toml_values(default, user, 3); let languages = merged.get("language").unwrap().as_array().unwrap(); let ts = languages .iter() diff --git a/helix-loader/src/repo_paths.rs b/helix-loader/src/repo_paths.rs new file mode 100644 index 000000000000..bc032bc171cd --- /dev/null +++ b/helix-loader/src/repo_paths.rs @@ -0,0 +1,37 @@ +use std::path::{Path, PathBuf}; + +pub fn project_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf() +} + +pub fn book_gen() -> PathBuf { + project_root().join("book/src/generated/") +} + +pub fn ts_queries() -> PathBuf { + project_root().join("runtime/queries") +} + +pub fn themes() -> PathBuf { + project_root().join("runtime/themes") +} + +pub fn default_config_dir() -> PathBuf { + // TODO: would be nice to move config files away from project root folder + project_root() +} + +pub fn default_lang_configs() -> PathBuf { + default_config_dir().join("languages.toml") +} + +pub fn default_theme() -> PathBuf { + default_config_dir().join("theme.toml") +} + +pub fn default_base16_theme() -> PathBuf { + default_config_dir().join("base16_theme.toml") +} diff --git a/helix-loader/src/ts_probe.rs b/helix-loader/src/ts_probe.rs new file mode 100644 index 000000000000..3d7190453f4b --- /dev/null +++ b/helix-loader/src/ts_probe.rs @@ -0,0 +1,39 @@ +// NOTE: currently not making use of folds, injections, locals, tags. +// (fd --hidden --glob *.scm --exec basename {} \; | sort | uniq) +/// Helper functions for probing Tree-sitter language support in Helix +#[derive(Copy, Clone)] +pub enum TsFeature { + Highlight, + TextObject, + AutoIndent, +} + +impl TsFeature { + pub fn all() -> &'static [Self] { + &[Self::Highlight, Self::TextObject, Self::AutoIndent] + } + + pub fn runtime_filename(&self) -> &'static str { + match *self { + Self::Highlight => "highlights.scm", + Self::TextObject => "textobjects.scm", + Self::AutoIndent => "indents.scm", + } + } + + pub fn long_title(&self) -> &'static str { + match *self { + Self::Highlight => "Syntax Highlighting", + Self::TextObject => "Treesitter Textobjects", + Self::AutoIndent => "Auto Indent", + } + } + + pub fn short_title(&self) -> &'static str { + match *self { + Self::Highlight => "Highlight", + Self::TextObject => "Textobject", + Self::AutoIndent => "Indent", + } + } +} diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c8e8ecb1ae61..7e949b4df1da 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -3,17 +3,8 @@ use futures_util::Stream; use helix_core::{ diagnostic::{DiagnosticTag, NumberOrString}, path::get_relative_path, - pos_at_coords, syntax, Selection, -}; -use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; -use helix_view::{ - align_view, - document::DocumentSavedEventResult, - editor::{ConfigEvent, EditorEvent}, - graphics::Rect, - theme, - tree::Layout, - Align, Editor, + syntax, + syntax::LanguageConfigurations, }; use serde_json::json; use tui::backend::Backend; @@ -24,13 +15,23 @@ use crate::{ compositor::{Compositor, Event}, config::Config, job::Jobs, - keymap::Keymaps, + keymap::Keymap, ui::{self, overlay::overlayed}, }; +use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; +use helix_view::{ + align_view, + document::DocumentSavedEventResult, + editor::{Action, ConfigEvent, EditorEvent}, + graphics::Rect, + tree::Layout, + Align, Editor, Theme, +}; use log::{debug, error, warn}; use std::{ io::{stdin, stdout, Write}, + path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -71,42 +72,13 @@ pub struct Application { compositor: Compositor, terminal: Terminal, pub editor: Editor, - config: Arc>, - - #[allow(dead_code)] - theme_loader: Arc, - #[allow(dead_code)] - syn_loader: Arc, - signals: Signals, jobs: Jobs, lsp_progress: LspProgressMap, last_render: Instant, } -#[cfg(feature = "integration")] -fn setup_integration_logging() { - let level = std::env::var("HELIX_LOG_LEVEL") - .map(|lvl| lvl.parse().unwrap()) - .unwrap_or(log::LevelFilter::Info); - - // Separate file config so we can include year, month and day in file logs - let _ = fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{} {} [{}] {}", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), - record.target(), - record.level(), - message - )) - }) - .level(level) - .chain(std::io::stdout()) - .apply(); -} - fn restore_term() -> Result<(), Error> { let mut stdout = stdout(); // reset cursor shape @@ -127,64 +99,38 @@ fn restore_term() -> Result<(), Error> { impl Application { pub fn new( args: Args, + theme: Theme, + langauge_configurations: LanguageConfigurations, config: Config, - syn_loader_conf: syntax::Configuration, ) -> Result { - #[cfg(feature = "integration")] - setup_integration_logging(); - - use helix_view::editor::Action; - - let theme_loader = std::sync::Arc::new(theme::Loader::new( - &helix_loader::config_dir(), - &helix_loader::runtime_dir(), - )); - - let true_color = config.editor.true_color || crate::true_color(); - let theme = config - .theme - .as_ref() - .and_then(|theme| { - theme_loader - .load(theme) - .map_err(|e| { - log::warn!("failed to load theme `{}` - {}", theme, e); - e - }) - .ok() - .filter(|theme| (true_color || theme.is_16_color())) - }) - .unwrap_or_else(|| theme_loader.default_theme(true_color)); - - let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); - #[cfg(not(feature = "integration"))] let backend = CrosstermBackend::new(stdout()); - #[cfg(feature = "integration")] let backend = TestBackend::new(120, 150); let terminal = Terminal::new(backend)?; - let area = terminal.size().expect("couldn't get terminal size"); - let mut compositor = Compositor::new(area); + let area = terminal.size(); + let config = Arc::new(ArcSwap::from_pointee(config)); + let mut editor = Editor::new( area, - theme_loader.clone(), - syn_loader.clone(), Arc::new(Map::new(Arc::clone(&config), |config: &Config| { &config.editor })), + theme, + Arc::new(syntax::Loader::new(langauge_configurations)), ); let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { &config.keys })); - let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys))); - compositor.push(editor_view); + + let mut compositor = Compositor::new(area); + compositor.push(Box::new(ui::EditorView::new(Keymap::new(keys)))); if args.load_tutor { - let path = helix_loader::runtime_dir().join("tutor"); + let path = helix_loader::get_runtime_file(Path::new("tutor")); editor.open(&path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(editor).set_path(None)?; @@ -197,7 +143,7 @@ impl Application { compositor.push(Box::new(overlayed(picker))); } else { let nr_of_files = args.files.len(); - for (i, (file, pos)) in args.files.into_iter().enumerate() { + for (i, (file, position_request)) in args.files.into_iter().enumerate() { if file.is_dir() { return Err(anyhow::anyhow!( "expected a path to file, found a directory. (to open a directory pass it as first argument)" @@ -223,8 +169,8 @@ impl Application { // opened last is focused on. let view_id = editor.tree.focus; let doc = doc_mut!(editor, &doc_id); - let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); - doc.set_selection(view_id, pos); + let selection = position_request.selection_for_doc(doc); + doc.set_selection(view_id, selection); } } editor.set_status(format!( @@ -250,8 +196,6 @@ impl Application { .unwrap_or_else(|_| editor.new_file(Action::VerticalSplit)); } - editor.set_theme(theme); - #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] @@ -262,12 +206,7 @@ impl Application { compositor, terminal, editor, - config, - - theme_loader, - syn_loader, - signals, jobs: Jobs::new(), lsp_progress: LspProgressMap::new(), @@ -402,47 +341,23 @@ impl Application { } } - /// refresh language config after config change - fn refresh_language_config(&mut self) -> Result<(), Error> { - let syntax_config = helix_core::config::user_syntax_loader() - .map_err(|err| anyhow::anyhow!("Failed to load language config: {}", err))?; - - self.syn_loader = std::sync::Arc::new(syntax::Loader::new(syntax_config)); - self.editor.syn_loader = self.syn_loader.clone(); - for document in self.editor.documents.values_mut() { - document.detect_language(self.syn_loader.clone()); - } + fn refresh_config(&mut self) { + let mut refresh_config = || -> Result<(), Error> { + let merged_user_config = Config::merged() + .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; + self.config.store(Arc::new(merged_user_config)); - Ok(()) - } + let language_configs = LanguageConfigurations::merged() + .map_err(|err| anyhow::anyhow!("Failed to load merged language config: {}", err))?; - /// Refresh theme after config change - fn refresh_theme(&mut self, config: &Config) -> Result<(), Error> { - if let Some(theme) = config.theme.clone() { - let true_color = self.true_color(); - let theme = self - .theme_loader - .load(&theme) - .map_err(|err| anyhow::anyhow!("Failed to load theme `{}`: {}", theme, err))?; - - if true_color || theme.is_16_color() { - self.editor.set_theme(theme); - } else { - anyhow::bail!("theme requires truecolor support, which is not available") + self.editor.lang_configs_loader = Arc::new(syntax::Loader::new(language_configs)); + for document in self.editor.documents.values_mut() { + document.detect_language(self.editor.lang_configs_loader.clone()); } - } - Ok(()) - } + self.editor + .set_theme(Theme::new(&self.config.load().theme)?); - fn refresh_config(&mut self) { - let mut refresh_config = || -> Result<(), Error> { - let default_config = Config::load_default() - .map_err(|err| anyhow::anyhow!("Failed to load config: {}", err))?; - self.refresh_language_config()?; - self.refresh_theme(&default_config)?; - // Store new config - self.config.store(Arc::new(default_config)); Ok(()) }; @@ -456,10 +371,6 @@ impl Application { } } - fn true_color(&self) -> bool { - self.config.load().editor.true_color || crate::true_color() - } - #[cfg(windows)] // no signal handling available on windows pub async fn handle_signals(&mut self, _signal: ()) {} @@ -480,7 +391,7 @@ impl Application { signal::SIGCONT => { self.claim_term().await.unwrap(); // redraw the terminal - let area = self.terminal.size().expect("couldn't get terminal size"); + let area = self.terminal.size(); self.compositor.resize(area); self.terminal.clear().expect("couldn't clear terminal"); @@ -550,12 +461,10 @@ impl Application { return; } - let loader = self.editor.syn_loader.clone(); - // borrowing the same doc again to get around the borrow checker let doc = doc_mut!(self.editor, &doc_save_event.doc_id); let id = doc.id(); - doc.detect_language(loader); + doc.detect_language(self.editor.lang_configs_loader.clone()); let _ = self.editor.refresh_language_server(id); } @@ -627,7 +536,7 @@ impl Application { .resize(Rect::new(0, 0, width, height)) .expect("Unable to resize terminal"); - let area = self.terminal.size().expect("couldn't get terminal size"); + let area = self.terminal.size(); self.compositor.resize(area); diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index dd787f1fd18c..abf3cd750e20 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -1,9 +1,11 @@ +mod position_request; + use anyhow::Result; -use helix_core::Position; use helix_view::tree::Layout; -use std::path::{Path, PathBuf}; +pub use position_request::PositionRequest; +use std::{iter::Peekable, path::PathBuf}; -#[derive(Default)] +#[derive(Debug, Default)] pub struct Args { pub display_help: bool, pub display_version: bool, @@ -16,106 +18,78 @@ pub struct Args { pub verbosity: u64, pub log_file: Option, pub config_file: Option, - pub files: Vec<(PathBuf, Position)>, + pub files: Vec<(PathBuf, PositionRequest)>, } impl Args { pub fn parse_args() -> Result { - let mut args = Args::default(); let mut argv = std::env::args().peekable(); + parse_args(&mut argv) + } +} - argv.next(); // skip the program, we don't care about that +pub fn parse_args(argv: &mut Peekable>) -> Result { + let mut args = Args::default(); + argv.next(); // skip the program, we don't care about that - while let Some(arg) = argv.next() { - match arg.as_str() { - "--" => break, // stop parsing at this point treat the remaining as files - "--version" => args.display_version = true, - "--help" => args.display_help = true, - "--tutor" => args.load_tutor = true, - "--vsplit" => match args.split { - Some(_) => anyhow::bail!("can only set a split once of a specific type"), - None => args.split = Some(Layout::Vertical), - }, - "--hsplit" => match args.split { - Some(_) => anyhow::bail!("can only set a split once of a specific type"), - None => args.split = Some(Layout::Horizontal), - }, - "--health" => { - args.health = true; - args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); - } - "-g" | "--grammar" => match argv.next().as_deref() { - Some("fetch") => args.fetch_grammars = true, - Some("build") => args.build_grammars = true, - _ => { - anyhow::bail!("--grammar must be followed by either 'fetch' or 'build'") - } - }, - "-c" | "--config" => match argv.next().as_deref() { - Some(path) => args.config_file = Some(path.into()), - None => anyhow::bail!("--config must specify a path to read"), - }, - "--log" => match argv.next().as_deref() { - Some(path) => args.log_file = Some(path.into()), - None => anyhow::bail!("--log must specify a path to write"), - }, - arg if arg.starts_with("--") => { - anyhow::bail!("unexpected double dash argument: {}", arg) + while let Some(arg) = argv.next() { + match arg.as_str() { + "--" => break, // stop parsing at this point treat the remaining as files + "--version" => args.display_version = true, + "--help" => args.display_help = true, + "--tutor" => args.load_tutor = true, + "--vsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Vertical), + }, + "--hsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Horizontal), + }, + "--health" => { + args.health = true; + args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); + } + "-g" | "--grammar" => match argv.next().as_deref() { + Some("fetch") => args.fetch_grammars = true, + Some("build") => args.build_grammars = true, + _ => { + anyhow::bail!("--grammar must be followed by either 'fetch' or 'build'") } - arg if arg.starts_with('-') => { - let arg = arg.get(1..).unwrap().chars(); - for chr in arg { - match chr { - 'v' => args.verbosity += 1, - 'V' => args.display_version = true, - 'h' => args.display_help = true, - _ => anyhow::bail!("unexpected short arg {}", chr), - } + }, + "-c" | "--config" => match argv.next().as_deref() { + Some(path) => args.config_file = Some(path.into()), + None => anyhow::bail!("--config must specify a path to read"), + }, + "--log" => match argv.next().as_deref() { + Some(path) => args.log_file = Some(path.into()), + None => anyhow::bail!("--log must specify a path to write"), + }, + arg if arg.starts_with("--") => { + anyhow::bail!("unexpected double dash argument: {}", arg) + } + arg if arg.starts_with('-') => { + let arg = arg.get(1..).unwrap().chars(); + for chr in arg { + match chr { + 'v' => args.verbosity += 1, + 'V' => args.display_version = true, + 'h' => args.display_help = true, + _ => anyhow::bail!("unexpected short arg {}", chr), } } - arg => args.files.push(parse_file(arg)), + } + _ => { + let file = PositionRequest::parse_positional_arg(arg, argv)?; + args.files.push(file); } } - - // push the remaining args, if any to the files - for arg in argv { - args.files.push(parse_file(&arg)); - } - - Ok(args) } -} -/// Parse arg into [`PathBuf`] and position. -pub(crate) fn parse_file(s: &str) -> (PathBuf, Position) { - let def = || (PathBuf::from(s), Position::default()); - if Path::new(s).exists() { - return def(); + while let Some(arg) = argv.next() { + let file = PositionRequest::parse_positional_arg(arg, argv)?; + args.files.push(file); } - split_path_row_col(s) - .or_else(|| split_path_row(s)) - .unwrap_or_else(def) -} - -/// Split file.rs:10:2 into [`PathBuf`], row and col. -/// -/// Does not validate if file.rs is a file or directory. -fn split_path_row_col(s: &str) -> Option<(PathBuf, Position)> { - let mut s = s.rsplitn(3, ':'); - let col: usize = s.next()?.parse().ok()?; - let row: usize = s.next()?.parse().ok()?; - let path = s.next()?.into(); - let pos = Position::new(row.saturating_sub(1), col.saturating_sub(1)); - Some((path, pos)) -} -/// Split file.rs:10 into [`PathBuf`] and row. -/// -/// Does not validate if file.rs is a file or directory. -fn split_path_row(s: &str) -> Option<(PathBuf, Position)> { - let (path, row) = s.rsplit_once(':')?; - let row: usize = row.parse().ok()?; - let path = path.into(); - let pos = Position::new(row.saturating_sub(1), 0); - Some((path, pos)) + Ok(args) } diff --git a/helix-term/src/args/position_request.rs b/helix-term/src/args/position_request.rs new file mode 100644 index 000000000000..c5ab8b866111 --- /dev/null +++ b/helix-term/src/args/position_request.rs @@ -0,0 +1,406 @@ +use std::{borrow::Cow, path::PathBuf}; + +use anyhow::Result; +use helix_core::{Position, Selection}; +use helix_view::Document; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PositionRequest { + Explicit(Position), + Eof, +} + +impl From for PositionRequest { + fn from(p: Position) -> Self { + Self::Explicit(p) + } +} + +impl Default for PositionRequest { + fn default() -> Self { + PositionRequest::Explicit(Position::default()) + } +} + +impl PositionRequest { + pub fn selection_for_doc(self, doc: &Document) -> Selection { + let text = doc.text().slice(..); + match self { + Self::Explicit(pos) => { + let pos = helix_core::pos_at_coords(text, pos, true); + Selection::point(pos) + } + Self::Eof => { + let line_idx = if text.line(text.len_lines() - 1).len_chars() == 0 { + // If the last line is blank, don't jump to it. + text.len_lines().saturating_sub(2) + } else { + text.len_lines() - 1 + }; + let pos = text.line_to_char(line_idx); + Selection::point(pos) + } + } + } + + pub fn parse_file<'a>(s: impl Into>) -> (PathBuf, Option) { + let s = s.into(); + match s.split_once(':') { + Some((s, rest)) => (s.into(), Self::parse_file_position(rest)), + None => (s.into_owned().into(), None), + } + } + + /// If an arg is a prefixed file position, then the next arg is expected to be a file. + /// File paths are not validated, that's left to the consumer. + pub fn parse_positional_arg( + arg: String, + argv: &mut impl Iterator, + ) -> Result<(PathBuf, Self)> { + let file = if let Some(s) = arg.strip_prefix('+') { + let prefix_pos = Self::parse_file_position(s); + let (path, postfix_pos) = match argv.next() { + Some(file) => Self::parse_file(file), + None => anyhow::bail!("expected a file after a position"), + }; + + if postfix_pos.is_some() { + anyhow::bail!("unexpected postfix position after prefix position"); + } + + (path, prefix_pos.unwrap_or_default()) + } else { + let (path, pos) = Self::parse_file(arg); + (path, pos.unwrap_or_default()) + }; + + Ok(file) + } + + pub fn parse_file_position(s: &str) -> Option { + let s = s.trim_matches(':'); + + if s.is_empty() { + return Some(PositionRequest::Eof); + } + + let (row, col) = s.split_once(':').unwrap_or((s, "1")); + let row: usize = row.parse().ok()?; + let col: usize = col.parse().ok()?; + let pos = Position::new(row.saturating_sub(1), col.saturating_sub(1)); + + Some(pos.into()) + } +} + +#[cfg(test)] +mod tests { + use crate::args::{parse_args, PositionRequest}; + use helix_core::Position; + use std::iter::Peekable; + + #[test] + fn should_parse_binary_only() { + parse_args(&mut str_to_arg_peekable("hx")).unwrap(); + } + + #[test] + fn should_parse_file_position_eof() { + assert_eq!( + PositionRequest::parse_file_position(":"), + Some(PositionRequest::Eof) + ); + assert_eq!( + PositionRequest::parse_file_position("::"), + Some(PositionRequest::Eof) + ); + } + + #[test] + fn should_parse_file_position_line_only() { + assert_eq!( + PositionRequest::parse_file_position("10"), + Some(PositionRequest::Explicit(Position { row: 9, col: 0 })) + ); + } + + #[test] + fn should_parse_file_position_line_only_with_trailing_delimiter() { + assert_eq!( + PositionRequest::parse_file_position("10:"), + Some(PositionRequest::Explicit(Position { row: 9, col: 0 })) + ); + } + + #[test] + fn should_parse_file_position_line_col() { + assert_eq!( + PositionRequest::parse_file_position("10:20"), + Some(PositionRequest::Explicit(Position { row: 9, col: 19 })) + ); + } + + #[test] + fn should_parse_file_position_line_col_with_trailing_delimiter() { + assert_eq!( + PositionRequest::parse_file_position("10:20:"), + Some(PositionRequest::Explicit(Position { row: 9, col: 19 })) + ); + } + + #[test] + fn should_give_none_if_any_pos_arg_invalid() { + assert_eq!(PositionRequest::parse_file_position("x"), None); + assert_eq!(PositionRequest::parse_file_position("x:y"), None); + assert_eq!(PositionRequest::parse_file_position("10:y"), None); + assert_eq!(PositionRequest::parse_file_position("x:20"), None); + } + + #[test] + fn should_parse_empty_file() { + assert_eq!( + PositionRequest::parse_file(""), + ("".to_owned().into(), None) + ); + } + + #[test] + fn should_parse_empty_file_with_eof_pos() { + assert_eq!( + PositionRequest::parse_file(":"), + ("".to_owned().into(), Some(PositionRequest::Eof)) + ); + } + + #[test] + fn should_parse_file_with_name_only() { + assert_eq!( + PositionRequest::parse_file("file"), + ("file".to_owned().into(), None) + ); + } + + #[test] + fn should_parse_file_with_eof_pos() { + assert_eq!( + PositionRequest::parse_file("file:"), + ("file".to_owned().into(), Some(PositionRequest::Eof)) + ); + } + + #[test] + fn should_parse_file_with_line_pos() { + assert_eq!( + PositionRequest::parse_file("file:10"), + ( + "file".to_owned().into(), + Some(PositionRequest::Explicit(Position { row: 9, col: 0 })) + ) + ); + } + + #[test] + fn should_parse_file_with_line_pos_and_trailing_delimiter() { + assert_eq!( + PositionRequest::parse_file("file:10:"), + ( + "file".to_owned().into(), + Some(PositionRequest::Explicit(Position { row: 9, col: 0 })) + ) + ); + } + + #[test] + fn should_parse_file_with_line_and_col_pos() { + assert_eq!( + PositionRequest::parse_file("file:10:20"), + ( + "file".to_owned().into(), + Some(PositionRequest::Explicit(Position { row: 9, col: 19 })) + ) + ); + } + + #[test] + fn should_parse_bare_files_args() { + let args = parse_args(&mut str_to_arg_peekable("hx Cargo.toml")).unwrap(); + assert_eq!( + args.files, + [("Cargo.toml".to_owned().into(), PositionRequest::default())] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx Cargo.toml README")).unwrap(); + assert_eq!( + args.files, + [ + ("Cargo.toml".to_owned().into(), PositionRequest::default()), + ("README".to_owned().into(), PositionRequest::default()) + ] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx -- Cargo.toml")).unwrap(); + assert_eq!( + args.files, + [("Cargo.toml".to_owned().into(), PositionRequest::default())] + ); + } + + #[test] + fn should_parse_prefix_pos_files() { + let args = parse_args(&mut str_to_arg_peekable("hx +10 Cargo.toml")).unwrap(); + assert_eq!( + args.files, + [( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + )] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx +: Cargo.toml")).unwrap(); + assert_eq!( + args.files, + [("Cargo.toml".to_owned().into(), PositionRequest::Eof)] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx +10 Cargo.toml +20 README")).unwrap(); + assert_eq!( + args.files, + [ + ( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + ), + ( + "README".to_owned().into(), + PositionRequest::Explicit(Position { row: 19, col: 0 }) + ) + ] + ); + + let args = parse_args(&mut str_to_arg_peekable( + "hx --vsplit -- +10 Cargo.toml +20 README", + )) + .unwrap(); + assert_eq!(args.split, Some(helix_view::tree::Layout::Vertical)); + assert_eq!( + args.files, + [ + ( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + ), + ( + "README".to_owned().into(), + PositionRequest::Explicit(Position { row: 19, col: 0 }) + ) + ] + ); + } + + #[test] + fn should_parse_intermixed_file_pos_notation() { + let args = parse_args(&mut str_to_arg_peekable( + "hx CHANGELOG +10 Cargo.toml README:20", + )) + .unwrap(); + assert_eq!( + args.files, + [ + ("CHANGELOG".to_owned().into(), PositionRequest::default(),), + ( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + ), + ( + "README".to_owned().into(), + PositionRequest::Explicit(Position { row: 19, col: 0 }) + ) + ] + ); + } + + #[test] + fn should_fail_on_file_with_prefix_and_postfix_pos() { + parse_args(&mut str_to_arg_peekable("hx +10 Cargo.toml:20")).unwrap_err(); + } + + #[test] + fn should_fail_on_orphan_prefix_pos() { + parse_args(&mut str_to_arg_peekable("hx +10")).unwrap_err(); + } + + #[test] + fn should_parse_postfix_pos_files() { + let args = parse_args(&mut str_to_arg_peekable("hx Cargo.toml:10")).unwrap(); + assert_eq!( + args.files, + [( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + )] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx Cargo.toml:")).unwrap(); + assert_eq!( + args.files, + [("Cargo.toml".to_owned().into(), PositionRequest::Eof)] + ); + + let args = parse_args(&mut str_to_arg_peekable("hx Cargo.toml:10 README:20")).unwrap(); + assert_eq!( + args.files, + [ + ( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + ), + ( + "README".to_owned().into(), + PositionRequest::Explicit(Position { row: 19, col: 0 }) + ) + ] + ); + + let args = parse_args(&mut str_to_arg_peekable( + "hx --vsplit -- Cargo.toml:10 README:20", + )) + .unwrap(); + assert_eq!(args.split, Some(helix_view::tree::Layout::Vertical)); + assert_eq!( + args.files, + [ + ( + "Cargo.toml".to_owned().into(), + PositionRequest::Explicit(Position { row: 9, col: 0 }) + ), + ( + "README".to_owned().into(), + PositionRequest::Explicit(Position { row: 19, col: 0 }) + ) + ] + ); + } + + #[test] + fn should_parse_config() { + let args = parse_args(&mut str_to_arg_peekable("hx --config other/config.toml")).unwrap(); + assert_eq!( + args.config_file, + Some("other/config.toml".to_owned().into()) + ); + } + + #[test] + fn should_parse_layout() { + let args = parse_args(&mut str_to_arg_peekable("hx --vsplit Cargo.toml")).unwrap(); + assert_eq!(args.split, Some(helix_view::tree::Layout::Vertical)); + + let args = parse_args(&mut str_to_arg_peekable("hx --hsplit Cargo.toml")).unwrap(); + assert_eq!(args.split, Some(helix_view::tree::Layout::Horizontal)); + parse_args(&mut str_to_arg_peekable("hx --hsplit -vsplit Cargo.toml")).unwrap_err(); + } + + fn str_to_arg_peekable(s: &'static str) -> Peekable> { + s.split_whitespace().map(ToOwned::to_owned).peekable() + } +} diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index fb55ca2a8e72..2a253baec3d5 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -5,7 +5,6 @@ pub(crate) mod typed; pub use dap::*; use helix_vcs::Hunk; pub use lsp::*; -use tui::widgets::Row; pub use typed::*; use helix_core::{ @@ -18,7 +17,7 @@ use helix_core::{ line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, movement::{self, move_vertically_visual, Direction}, - object, pos_at_coords, + object, regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, selection, shellwords, surround, @@ -51,8 +50,13 @@ use crate::{ compositor::{self, Component, Compositor}, filter_picker_entry, job::Callback, - keymap::ReverseKeymap, - ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, + keymap::CommandList, + ui::{ + self, + menu::{Cell, Row}, + overlay::overlayed, + FilePicker, Picker, Popup, Prompt, PromptEvent, + }, }; use crate::job::{self, Jobs}; @@ -140,23 +144,23 @@ pub enum MappableCommand { Typable { name: String, args: Vec, - doc: String, + description: String, }, Static { name: &'static str, fun: fn(cx: &mut Context), - doc: &'static str, + description: &'static str, }, } macro_rules! static_commands { - ( $($name:ident, $doc:literal,)* ) => { + ( $($name:ident, $description:literal,)* ) => { $( #[allow(non_upper_case_globals)] pub const $name: Self = Self::Static { name: stringify!($name), fun: $name, - doc: $doc + description: $description }; )* @@ -169,7 +173,11 @@ macro_rules! static_commands { impl MappableCommand { pub fn execute(&self, cx: &mut Context) { match &self { - Self::Typable { name, args, doc: _ } => { + Self::Typable { + name, + args, + description: _, + } => { let args: Vec> = args.iter().map(Cow::from).collect(); if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { @@ -193,10 +201,10 @@ impl MappableCommand { } } - pub fn doc(&self) -> &str { + pub fn get_description(&self) -> &str { match &self { - Self::Typable { doc, .. } => doc, - Self::Static { doc, .. } => doc, + Self::Typable { description, .. } => description, + Self::Static { description, .. } => description, } } @@ -492,11 +500,18 @@ impl std::str::FromStr for MappableCommand { let args = typable_command .map(|s| s.to_owned()) .collect::>(); + typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { - name: cmd.name.to_owned(), - doc: format!(":{} {:?}", cmd.name, args), + name: cmd.name.to_string(), + description: { + if args.is_empty() { + cmd.doc.to_string() + } else { + format!(":{} {}", cmd.name, args.join(" ")) + } + }, args, }) .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) @@ -2578,31 +2593,36 @@ fn jumplist_picker(cx: &mut Context) { cx.push_layer(Box::new(overlayed(picker))); } +// NOTE: does not present aliases impl ui::menu::Item for MappableCommand { - type Data = ReverseKeymap; + type Data = CommandList; - fn format(&self, keymap: &Self::Data) -> Row { - let fmt_binding = |bindings: &Vec>| -> String { - bindings.iter().fold(String::new(), |mut acc, bind| { - if !acc.is_empty() { - acc.push(' '); + fn format(&self, command_list: &Self::Data) -> Row { + match self { + MappableCommand::Typable { + description, name, .. + } => { + let mut row: Vec = vec![ + Cell::from(name.as_str()), + Cell::from(""), + Cell::from(description.as_str()), + ]; + if let Some(key_events) = command_list.get(name as &String) { + row[1] = Cell::from(key_events.join(", ")); } - for key in bind { - acc.push_str(&key.key_sequence_format()); + Row::new(row) + } + MappableCommand::Static { + description: doc, + name, + .. + } => { + let mut row: Vec = vec![Cell::from(*name), Cell::from(""), Cell::from(*doc)]; + if let Some(key_events) = command_list.get(*name) { + row[1] = Cell::from(key_events.join(", ")); } - acc - }) - }; - - match self { - MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { - Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [:{}]", doc, name).into(), - }, - MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { - Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [{}]", doc, name).into(), - }, + Row::new(row) + } } } } @@ -2610,45 +2630,51 @@ impl ui::menu::Item for MappableCommand { pub fn command_palette(cx: &mut Context) { cx.callback = Some(Box::new( move |compositor: &mut Compositor, cx: &mut compositor::Context| { - let keymap = compositor.find::().unwrap().keymaps.map() - [&cx.editor.mode] - .reverse_map(); + let keymap_command_lists = compositor + .find::() + .unwrap() + .keymap + .command_list(&cx.editor.mode); let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { MappableCommand::Typable { name: cmd.name.to_owned(), - doc: cmd.doc.to_owned(), + description: cmd.doc.to_owned(), args: Vec::new(), } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { - let mut ctx = Context { - register: None, - count: std::num::NonZeroUsize::new(1), - editor: cx.editor, - callback: None, - on_next_key_callback: None, - jobs: cx.jobs, - }; - let focus = view!(ctx.editor).id; + let picker = Picker::new( + commands, + keymap_command_lists, + move |cx, command, _action| { + let mut ctx = Context { + register: None, + count: std::num::NonZeroUsize::new(1), + editor: cx.editor, + callback: None, + on_next_key_callback: None, + jobs: cx.jobs, + }; + let focus = view!(ctx.editor).id; - command.execute(&mut ctx); + command.execute(&mut ctx); - if ctx.editor.tree.contains(focus) { - let config = ctx.editor.config(); - let mode = ctx.editor.mode(); - let view = view_mut!(ctx.editor, focus); - let doc = doc_mut!(ctx.editor, &view.doc); + if ctx.editor.tree.contains(focus) { + let config = ctx.editor.config(); + let mode = ctx.editor.mode(); + let view = view_mut!(ctx.editor, focus); + let doc = doc_mut!(ctx.editor, &view.doc); - view.ensure_cursor_in_view(doc, config.scrolloff); + view.ensure_cursor_in_view(doc, config.scrolloff); - if mode != Mode::Insert { - doc.append_changes_to_history(view); + if mode != Mode::Insert { + doc.append_changes_to_history(view); + } } - } - }); + }, + ); compositor.push(Box::new(overlayed(picker))); }, )); diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 3b94c9bd5558..c6f1b1a29659 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1127,7 +1127,7 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let mut contents = SignatureHelp::new( signature.label.clone(), language.to_string(), - Arc::clone(&editor.syn_loader), + Arc::clone(&editor.lang_configs_loader), ); let signature_doc = if config.lsp.display_signature_help_docs { @@ -1223,7 +1223,7 @@ pub fn hover(cx: &mut Context) { // skip if contents empty - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b0fd18a76b79..09abfe02ed7a 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1,13 +1,14 @@ use std::fmt::Write; use std::ops::Deref; -use crate::job::Job; +use crate::job::*; use super::*; use helix_core::encoding; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; +use helix_view::Theme; use serde_json::Value; use ui::completers::{self, Completer}; @@ -65,7 +66,8 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> ensure!(!args.is_empty(), "wrong argument count"); for arg in args { - let (path, pos) = args::parse_file(arg); + let (path, position_request) = args::PositionRequest::parse_file(arg.clone()); + let position_request = position_request.unwrap_or_default(); let path = helix_core::path::expand_tilde(&path); // If the path is a directory, open a file picker on that directory and update the status // message @@ -84,8 +86,8 @@ fn open(cx: &mut compositor::Context, args: &[Cow], event: PromptEvent) -> // Otherwise, just open the file let _ = cx.editor.open(&path, Action::Replace)?; let (view, doc) = current!(cx.editor); - let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); - doc.set_selection(view.id, pos); + let selection = position_request.selection_for_doc(doc); + doc.set_selection(view.id, selection); // does not affect opening a buffer without pos align_view(doc, view, Align::Center); } @@ -771,7 +773,6 @@ fn theme( args: &[Cow], event: PromptEvent, ) -> anyhow::Result<()> { - let true_color = cx.editor.config.load().true_color || crate::true_color(); match event { PromptEvent::Abort => { cx.editor.unset_theme_preview(); @@ -781,28 +782,16 @@ fn theme( // Ensures that a preview theme gets cleaned up if the user backspaces until the prompt is empty. cx.editor.unset_theme_preview(); } else if let Some(theme_name) = args.first() { - if let Ok(theme) = cx.editor.theme_loader.load(theme_name) { - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); - } + if let Ok(theme) = Theme::new(theme_name) { cx.editor.set_theme_preview(theme); }; }; } PromptEvent::Validate => { if let Some(theme_name) = args.first() { - let theme = cx - .editor - .theme_loader - .load(theme_name) - .map_err(|err| anyhow::anyhow!("Could not load theme: {}", err))?; - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); - } - cx.editor.set_theme(theme); + cx.editor.set_theme(Theme::new(theme_name)?); } else { let name = cx.editor.theme.name().to_string(); - cx.editor.set_status(name); } } @@ -1374,7 +1363,7 @@ fn tree_sitter_scopes( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, @@ -1534,7 +1523,7 @@ fn tutor( return Ok(()); } - let path = helix_loader::runtime_dir().join("tutor"); + let path = helix_loader::get_runtime_file(Path::new("tutor")); cx.editor.open(&path, Action::Replace)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(cx.editor).set_path(None)?; @@ -1714,7 +1703,7 @@ fn language( if args[0] == DEFAULT_LANGUAGE_NAME { doc.set_language(None, None) } else { - doc.set_language_by_language_id(&args[0], cx.editor.syn_loader.clone())?; + doc.set_language_by_language_id(&args[0], cx.editor.lang_configs_loader.clone())?; } doc.detect_indent_and_line_ending(); @@ -1852,7 +1841,8 @@ fn tree_sitter_subtree( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |editor: &mut Editor, compositor: &mut Compositor| { - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let contents = + ui::Markdown::new(contents, editor.lang_configs_loader.clone()); let popup = Popup::new("hover", contents).auto_close(true); compositor.replace_or_push("hover", popup); }, @@ -1985,7 +1975,7 @@ fn run_shell_command( move |editor: &mut Editor, compositor: &mut Compositor| { let contents = ui::Markdown::new( format!("```sh\n{}\n```", output), - editor.syn_loader.clone(), + editor.lang_configs_loader.clone(), ); let popup = Popup::new("shell", contents).position(Some( helix_core::Position::new(editor.cursor().0.unwrap_or_default().row, 2), diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 4407a882f838..b2e8aa512e44 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,73 +1,69 @@ -use crate::keymap::{default::default, merge_keys, Keymap}; +use crate::keymap::{default, keytrie::KeyTrie}; +use anyhow::{anyhow, Error}; use helix_view::document::Mode; use serde::Deserialize; use std::collections::HashMap; -use std::fmt::Display; -use std::io::Error as IOError; -use std::path::PathBuf; -use toml::de::Error as TomlError; #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { - pub theme: Option, - #[serde(default = "default")] - pub keys: HashMap, #[serde(default)] - pub editor: helix_view::editor::Config, + pub theme: String, + #[serde(default = "default::default")] + pub keys: HashMap, + #[serde(default)] + pub editor: helix_view::editor::EditorConfig, } -impl Default for Config { - fn default() -> Config { - Config { - theme: None, - keys: default(), - editor: helix_view::editor::Config::default(), - } +impl Config { + /// Merge user config with system keymap + pub fn merged() -> Result { + let config_string = std::fs::read_to_string(helix_loader::config_file())?; + toml::from_str(&config_string) + .map(|config: Config| config.merge_in_default_keymap()) + .map_err(|error| anyhow!("{}", error)) } -} -#[derive(Debug)] -pub enum ConfigLoadError { - BadConfig(TomlError), - Error(IOError), -} + /// Merge local config with user config and system keymap + pub fn merged_local_config() -> Result { + helix_loader::merged_config()? + .try_into() + .map(|config: Config| config.merge_in_default_keymap()) + .map_err(|error| anyhow!("{}", error)) + } -impl Display for ConfigLoadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigLoadError::BadConfig(err) => err.fmt(f), - ConfigLoadError::Error(err) => err.fmt(f), + pub fn merge_in_default_keymap(mut self) -> Self { + let mut delta = std::mem::replace(&mut self.keys, default::default()); + for (mode, keys) in &mut self.keys { + keys.merge_keytrie(delta.remove(mode).unwrap_or_default()) } + self } } -impl Config { - pub fn load(config_path: PathBuf) -> Result { - match std::fs::read_to_string(config_path) { - Ok(config) => toml::from_str(&config) - .map(merge_keys) - .map_err(ConfigLoadError::BadConfig), - Err(err) => Err(ConfigLoadError::Error(err)), +impl Default for Config { + fn default() -> Config { + Config { + theme: String::default(), + keys: default::default(), + editor: helix_view::editor::EditorConfig::default(), } } - - pub fn load_default() -> Result { - Config::load(helix_loader::config_file()) - } } #[cfg(test)] mod tests { - use super::*; + use crate::{ + commands::MappableCommand, + config::Config, + keymap::{default, keytrie::KeyTrie, keytrienode::KeyTrieNode, macros::*}, + }; + use helix_core::hashmap; + use helix_view::{document::Mode, input::KeyEvent}; + use std::{collections::BTreeMap, str::FromStr}; #[test] - fn parsing_keymaps_config_file() { - use crate::keymap; - use crate::keymap::Keymap; - use helix_core::hashmap; - use helix_view::document::Mode; - + fn parses_keymap_from_toml() { let sample_keymaps = r#" [keys.insert] y = "move_line_down" @@ -77,31 +73,263 @@ mod tests { A-F12 = "move_next_word_end" "#; - assert_eq!( - toml::from_str::(sample_keymaps).unwrap(), - Config { - keys: hashmap! { - Mode::Insert => Keymap::new(keymap!({ "Insert mode" - "y" => move_line_down, - "S-C-a" => delete_selection, - })), - Mode::Normal => Keymap::new(keymap!({ "Normal mode" - "A-F12" => move_next_word_end, - })), - }, - ..Default::default() + let config = Config { + keys: hashmap! { + Mode::Insert => keytrie!({ "Insert mode" + "y" => move_line_down, + "S-C-a" => delete_selection, + }), + Mode::Normal => keytrie!({ "Normal mode" + "A-F12" => move_next_word_end, + }), + }, + ..Default::default() + }; + + for mode in config.keys.keys() { + // toml keymap config is placed into a hashmap, so order can not be presumed to be conserved + // hence the insertion into a BTreeMap + assert_eq!( + ordered_mapping(config.keys.get(mode).unwrap()), + ordered_mapping( + toml::from_str::(sample_keymaps) + .unwrap() + .keys + .get(mode) + .unwrap() + ) + ); + } + + fn ordered_mapping(keytrie: &KeyTrie) -> BTreeMap<&KeyEvent, KeyTrieNode> { + let children = keytrie.get_children(); + let mut ordered_keymap = BTreeMap::new(); + for (key_event, order) in keytrie.get_child_order() { + ordered_keymap.insert(key_event, children[*order].clone()); } + ordered_keymap + } + } + + #[test] + fn false_to_true_sticky_override() { + let sample_keymap = r#" + [keys.normal.space] + sticky = true + "#; + assert!(_normal_mode_keytrie("space", sample_keymap).is_sticky) + } + + #[test] + fn true_to_undefined_remains_sticky() { + // NOTE: assumes Z binding is predefined as sticky. + let sample_keymap = r#" + [keys.normal.Z] + c = "no_op" + "#; + assert!(_normal_mode_keytrie("Z", sample_keymap).is_sticky) + } + + #[test] + fn true_to_false_sticky_override() { + // NOTE: assumes Z binding is predefined as sticky. + let sample_keymap = r#" + [keys.normal.Z] + sticky = false + "#; + assert!(!_normal_mode_keytrie("Z", sample_keymap).is_sticky) + } + + #[test] + fn parses_custom_typable_command_label_from_toml() { + let sample_keymap = r#" + [keys.normal] + A-k = { description = "Edit Config", exec = ":open ~/.config/helix/config.toml" } + "#; + let parsed_node: KeyTrieNode = _normal_mode_keytrie_node("A-k", sample_keymap); + let parsed_description = parsed_node.get_description().unwrap(); + assert_eq!(parsed_description, "Edit Config"); + + if let KeyTrieNode::MappableCommand(MappableCommand::Typable { name, .. }) = parsed_node { + assert_eq!(name, "open".to_string()); + return; + } + panic!("KeyTrieNode::MappableCommand::Typable expected.") + } + + #[test] + fn parses_custom_command_sequence_label_from_toml() { + let sample_keymap = r#" + [keys.normal] + "C-r" = { "description" = "Sort selection", "exec" = ["split_selection_on_newline", ":sort", "collapse_selection", "keep_primary_selection"] } + "#; + + let parsed_node: KeyTrieNode = _normal_mode_keytrie_node("C-r", sample_keymap); + let parsed_description = parsed_node.get_description().unwrap(); + assert_eq!(parsed_description, "Sort selection"); + + if let KeyTrieNode::CommandSequence(command_sequence) = parsed_node { + // IMPROVEMENT: Check that each command is correct + assert_eq!(command_sequence.get_commands().len(), 4) + } else { + panic!("KeyTrieNode::CommandSequence expected.") + } + } + + #[test] + fn parses_custom_infobox_label_from_toml() { + let sample_keymap = r#" + [keys.normal.b] + description = "Buffer menu" + b = "buffer_picker" + n = "goto_next_buffer" + "#; + let parsed_node: KeyTrieNode = _normal_mode_keytrie_node("b", sample_keymap); + assert_eq!(parsed_node.get_description().unwrap(), "Buffer menu"); + } + + #[test] + fn parses_custom_infobox_label_override_from_toml() { + let sample_keymap = r#" + [keys.normal.space] + description = "To the moon" + b = "buffer_picker" + "#; + let parsed_node: KeyTrieNode = _normal_mode_keytrie_node("space", sample_keymap); + assert_eq!(parsed_node.get_description().unwrap(), "To the moon"); + } + + #[test] + fn parses_empty_custom_infobox_label_override_from_toml() { + let sample_keymap = r#" + [keys.normal.space] + description = "To the moon" + "#; + let parsed_node: KeyTrie = _normal_mode_keytrie("space", sample_keymap); + assert!( + parsed_node.get_children().len() > 2, + "Empty custom label override does not override other defualt mappings in keytrie." ); } #[test] fn keys_resolve_to_correct_defaults() { - // From serde default - let default_keys = toml::from_str::("").unwrap().keys; - assert_eq!(default_keys, default()); - - // From the Default trait + let serde_default = toml::from_str::("").unwrap().keys; + assert_eq!(serde_default, default::default()); let default_keys = Config::default().keys; - assert_eq!(default_keys, default()); + assert_eq!(default_keys, default::default()); + } + + #[test] + fn user_config_merges_with_default() { + let user_config = Config { + keys: hashmap! { + Mode::Normal => keytrie!({ "Normal mode" + "i" => normal_mode, + "无" => insert_mode, + "z" => jump_backward, + "g" => { "Merge into goto mode" + "$" => goto_line_end, + "g" => delete_char_forward, + }, + "b" => { "Buffer menu" + "b" => buffer_picker, + }, + }) + + }, + ..Default::default() + }; + let mut merged_config = user_config.clone().merge_in_default_keymap(); + assert_ne!( + user_config, merged_config, + "Merged user keymap with default should differ from user keymap." + ); + + let keymap_normal_root_key_trie = &merged_config.keys.get_mut(&Mode::Normal).unwrap(); + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('i')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::normal_mode), + "User supplied mappable command should override default mappable command bound to the same key event." + ); + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('无')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::insert_mode), + "User supplied mappable command of new key event should be present in merged keymap." + ); + // Assumes that z is a node in the default keymap + assert_eq!( + keymap_normal_root_key_trie.traverse(&[key!('z')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::jump_backward), + "User supplied mappable command should replace a sub keytrie from default keymap bound to the same key event." + ); + // Assumes that `g` is a sub key trie in default keymap + assert_eq!( + keymap_normal_root_key_trie + .traverse(&[key!('g'), key!('$')]) + .unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::goto_line_end), + "User supplied mappable command should be inserted under the correct sub keytrie." + ); + // Assumes that `gg` is in default keymap + assert_eq!( + keymap_normal_root_key_trie + .traverse(&[key!('g'), key!('g')]) + .unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::delete_char_forward), + "User supplied mappable command should replace default even in sub keytries." + ); + // Assumes that `ge` is in default keymap + assert_eq!( + keymap_normal_root_key_trie + .traverse(&[key!('g'), key!('e')]) + .unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::goto_last_line), + "Default mappable commands that aren't ovveridden should exist in merged keymap." + ); + // Assumes that `b` is a MappableCommand in default keymap + assert_ne!( + keymap_normal_root_key_trie.traverse(&[key!('b')]).unwrap(), + KeyTrieNode::MappableCommand(MappableCommand::move_prev_word_start), + "Keytrie can override default mappable command." + ); + + // Huh? + assert!( + merged_config + .keys + .get(&Mode::Normal) + .unwrap() + .get_children() + .len() + > 1 + ); + assert!(!merged_config + .keys + .get(&Mode::Insert) + .unwrap() + .get_children() + .is_empty()); + } + + fn _normal_mode_keytrie(key_event_str: &str, sample_keymap: &str) -> KeyTrie { + if let KeyTrieNode::KeyTrie(_parsed_keytrie) = + _normal_mode_keytrie_node(key_event_str, sample_keymap) + { + _parsed_keytrie + } else { + panic!("KeyTrieNode::KeyTrie expected.") + } + } + + fn _normal_mode_keytrie_node(key_event_str: &str, sample_keymap: &str) -> KeyTrieNode { + toml::from_str::(sample_keymap) + .unwrap() + .merge_in_default_keymap() + .keys + .get(&Mode::Normal) + .unwrap() + .traverse(&[KeyEvent::from_str(key_event_str).unwrap()]) + .unwrap() } } diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 6558fe19fb4c..10403b871776 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -2,124 +2,87 @@ use crossterm::{ style::{Color, Print, Stylize}, tty::IsTty, }; -use helix_core::config::{default_syntax_loader, user_syntax_loader}; -use helix_loader::grammar::load_runtime_file; -use helix_view::clipboard::get_clipboard_provider; +use helix_core::syntax::{LanguageConfiguration, LanguageConfigurations}; +use helix_loader::grammar; +use helix_loader::ts_probe::TsFeature; +use helix_view::clipboard; use std::io::Write; -#[derive(Copy, Clone)] -pub enum TsFeature { - Highlight, - TextObject, - AutoIndent, -} - -impl TsFeature { - pub fn all() -> &'static [Self] { - &[Self::Highlight, Self::TextObject, Self::AutoIndent] - } - - pub fn runtime_filename(&self) -> &'static str { - match *self { - Self::Highlight => "highlights.scm", - Self::TextObject => "textobjects.scm", - Self::AutoIndent => "indents.scm", - } - } - - pub fn long_title(&self) -> &'static str { - match *self { - Self::Highlight => "Syntax Highlighting", - Self::TextObject => "Treesitter Textobjects", - Self::AutoIndent => "Auto Indent", - } - } - - pub fn short_title(&self) -> &'static str { - match *self { - Self::Highlight => "Highlight", - Self::TextObject => "Textobject", - Self::AutoIndent => "Indent", +pub fn print_health(health_arg: Option) -> std::io::Result<()> { + match health_arg.as_deref() { + None => { + display_paths()?; + display_clipboard()?; + writeln!(std::io::stdout().lock())?; + display_all_languages()?; } + Some("paths") => display_paths()?, + Some("clipboard") => display_clipboard()?, + Some("languages") => display_all_languages()?, + Some(lang) => display_language(lang.to_string())?, } + Ok(()) } -/// Display general diagnostics. -pub fn general() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); +fn display_paths() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); - let config_file = helix_loader::config_file(); - let lang_file = helix_loader::lang_config_file(); - let log_file = helix_loader::log_file(); - let rt_dir = helix_loader::runtime_dir(); - let clipboard_provider = get_clipboard_provider(); + writeln!( + stdout, + "Default config merged with user preferences supplied in:" + )?; + writeln!(stdout, "Config: {}", helix_loader::config_file().display())?; + writeln!( + stdout, + "Language config: {}", + helix_loader::user_lang_config_file().display() + )?; + writeln!(stdout, "Log file: {}", helix_loader::log_file().display())?; - if config_file.exists() { - writeln!(stdout, "Config file: {}", config_file.display())?; - } else { - writeln!(stdout, "Config file: default")?; - } - if lang_file.exists() { - writeln!(stdout, "Language file: {}", lang_file.display())?; - } else { - writeln!(stdout, "Language file: default")?; - } - writeln!(stdout, "Log file: {}", log_file.display())?; - writeln!(stdout, "Runtime directory: {}", rt_dir.display())?; + let rt_dirs = helix_loader::get_runtime_dirs(); + writeln!(stdout, "Runtime directories by order of priority:",)?; - if let Ok(path) = std::fs::read_link(&rt_dir) { - let msg = format!("Runtime directory is symlinked to {}", path.display()); - writeln!(stdout, "{}", msg.yellow())?; - } - if !rt_dir.exists() { - writeln!(stdout, "{}", "Runtime directory does not exist.".red())?; - } - if rt_dir.read_dir().ok().map(|it| it.count()) == Some(0) { - writeln!(stdout, "{}", "Runtime directory is empty.".red())?; + for rt_dir in rt_dirs { + write!(stdout, "- {};", rt_dir.display())?; + + if let Ok(path) = std::fs::read_link(rt_dir) { + let msg = format!(" (symlinked to {})", path.display()); + write!(stdout, "{}", msg.yellow())?; + } + if rt_dir.read_dir().ok().map(|it| it.count()) == Some(0) { + write!(stdout, "{}", " is empty.".yellow())?; + } + if !rt_dir.exists() { + write!(stdout, "{}", " does not exist.".red())?; + } + writeln!(stdout)?; } - writeln!(stdout, "Clipboard provider: {}", clipboard_provider.name())?; Ok(()) } -pub fn clipboard() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let board = get_clipboard_provider(); - match board.name().as_ref() { +fn display_clipboard() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + let clipboard = clipboard::get_clipboard_provider(); + match clipboard.name().as_ref() { "none" => { writeln!( stdout, "{}", - "System clipboard provider: Not installed".red() + "No system clipboard provider installed, refer to:".red() )?; - writeln!( - stdout, - " {}", - "For troubleshooting system clipboard issues, refer".red() - )?; - writeln!(stdout, " {}", - "https://github.com/helix-editor/helix/wiki/Troubleshooting#copypaste-fromto-system-clipboard-not-working" - .red().underlined())?; + let link = "https://github.com/helix-editor/helix/wiki/Troubleshooting#copypaste-fromto-system-clipboard-not-working"; + writeln!(stdout, "{}", link.red().underlined())?; } name => writeln!(stdout, "System clipboard provider: {}", name)?, } - Ok(()) } -pub fn languages_all() -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let mut syn_loader_conf = match user_syntax_loader() { - Ok(conf) => conf, - Err(err) => { - let stderr = std::io::stderr(); - let mut stderr = stderr.lock(); - +fn load_merged_language_configurations() -> std::io::Result> { + LanguageConfigurations::merged() + .or_else(|err| { + let mut stderr = std::io::stderr().lock(); writeln!( stderr, "{}: {}", @@ -127,113 +90,27 @@ pub fn languages_all() -> std::io::Result<()> { err )?; writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() - } - }; - - let mut headings = vec!["Language", "LSP", "DAP"]; - - for feat in TsFeature::all() { - headings.push(feat.short_title()) - } - - let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80); - let column_width = terminal_cols as usize / headings.len(); - let is_terminal = std::io::stdout().is_tty(); - - let column = |item: &str, color: Color| { - let mut data = format!( - "{:width$}", - item.get(..column_width - 2) - .map(|s| format!("{}…", s)) - .unwrap_or_else(|| item.to_string()), - width = column_width, - ); - if is_terminal { - data = data.stylize().with(color).to_string(); - } - - // We can't directly use println!() because of - // https://github.com/crossterm-rs/crossterm/issues/589 - let _ = crossterm::execute!(std::io::stdout(), Print(data)); - }; - - for heading in headings { - column(heading, Color::White); - } - writeln!(stdout)?; - - syn_loader_conf - .language - .sort_unstable_by_key(|l| l.language_id.clone()); - - let check_binary = |cmd: Option| match cmd { - Some(cmd) => match which::which(&cmd) { - Ok(_) => column(&format!("✓ {}", cmd), Color::Green), - Err(_) => column(&format!("✘ {}", cmd), Color::Red), - }, - None => column("None", Color::Yellow), - }; - - for lang in &syn_loader_conf.language { - column(&lang.language_id, Color::Reset); - - let lsp = lang - .language_server - .as_ref() - .map(|lsp| lsp.command.to_string()); - check_binary(lsp); - - let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string()); - check_binary(dap); - - for ts_feat in TsFeature::all() { - match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() { - true => column("✓", Color::Green), - false => column("✘", Color::Red), - } - } - - writeln!(stdout)?; - } - - Ok(()) + Ok(LanguageConfigurations::default()) + }) + .map(|lang_configs| lang_configs.language) } -/// Display diagnostics pertaining to a particular language (LSP, -/// highlight queries, etc). -pub fn language(lang_str: String) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let syn_loader_conf = match user_syntax_loader() { - Ok(conf) => conf, - Err(err) => { - let stderr = std::io::stderr(); - let mut stderr = stderr.lock(); +fn display_language(lang_str: String) -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); - writeln!( - stderr, - "{}: {}", - "Error parsing user language config".red(), - err - )?; - writeln!(stderr, "{}", "Using default language config".yellow())?; - default_syntax_loader() - } - }; - - let lang = match syn_loader_conf - .language + let language_configurations = load_merged_language_configurations()?; + let lang = match language_configurations .iter() .find(|l| l.language_id == lang_str) { - Some(l) => l, + Some(found_language) => found_language, None => { - let msg = format!("Language '{}' not found", lang_str); - writeln!(stdout, "{}", msg.red())?; - let suggestions: Vec<&str> = syn_loader_conf - .language + writeln!( + stdout, + "{}", + format!("Language '{lang_str}' not found").red() + )?; + let suggestions: Vec<&str> = language_configurations .iter() .filter(|l| l.language_id.starts_with(lang_str.chars().next().unwrap())) .map(|l| l.language_id.as_str()) @@ -250,73 +127,116 @@ pub fn language(lang_str: String) -> std::io::Result<()> { } }; + let probe_protocol = |protocol_name: &str, server_cmd: Option| -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + match server_cmd { + Some(server_cmd) => { + writeln!( + stdout, + "Configured {protocol_name}: {}", + server_cmd.clone().green() + )?; + let result = match which::which(&server_cmd) { + Ok(path) => path.display().to_string().green(), + Err(_) => "Not found in $PATH".to_string().red(), + }; + writeln!(stdout, "Binary for {server_cmd}: {result}")? + } + None => writeln!(stdout, "Configured {protocol_name}: {}", "None".yellow())?, + }; + Ok(()) + }; + probe_protocol( "language server", lang.language_server .as_ref() .map(|lsp| lsp.command.to_string()), )?; - probe_protocol( "debug adapter", lang.debugger.as_ref().map(|dap| dap.command.to_string()), )?; - - for ts_feat in TsFeature::all() { - probe_treesitter_feature(&lang_str, *ts_feat)? + if lang.formatter.is_some() { + probe_protocol( + "external formatter", + lang.formatter + .as_ref() + .map(|fmtcfg| fmtcfg.command.to_string()), + )?; + } + + for feature in TsFeature::all() { + let supported = + match grammar::load_runtime_file(&lang.language_id, feature.runtime_filename()).is_ok() + { + true => "✓".green(), + false => "✗".red(), + }; + writeln!(stdout, "{} queries: {supported}", feature.short_title())?; } - Ok(()) } -/// Display diagnostics about LSP and DAP. -fn probe_protocol(protocol_name: &str, server_cmd: Option) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - let cmd_name = match server_cmd { - Some(ref cmd) => cmd.as_str().green(), - None => "None".yellow(), - }; - writeln!(stdout, "Configured {}: {}", protocol_name, cmd_name)?; +fn display_all_languages() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); - if let Some(cmd) = server_cmd { - let path = match which::which(&cmd) { - Ok(path) => path.display().to_string().green(), - Err(_) => format!("'{}' not found in $PATH", cmd).red(), - }; - writeln!(stdout, "Binary for {}: {}", protocol_name, path)?; + let mut column_headers = vec!["Language", "LSP", "DAP"]; + for treesitter_feature in TsFeature::all() { + column_headers.push(treesitter_feature.short_title()) } - Ok(()) -} + let column_width = + crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80) as usize / column_headers.len(); + let print_column = |item: &str, color: Color| { + let mut data = format!( + "{:column_width$}", + item.get(..column_width - 2) + .map(|s| format!("{}…", s)) + .unwrap_or_else(|| item.to_string()) + ); -/// Display diagnostics about a feature that requires tree-sitter -/// query files (highlights, textobjects, etc). -fn probe_treesitter_feature(lang: &str, feature: TsFeature) -> std::io::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); + if std::io::stdout().is_tty() { + data = data.stylize().with(color).to_string(); + } + // https://github.com/crossterm-rs/crossterm/issues/589 + let _ = crossterm::execute!(std::io::stdout(), Print(data)); + }; + + for header in column_headers { + print_column(header, Color::White); + } + writeln!(stdout)?; - let found = match load_runtime_file(lang, feature.runtime_filename()).is_ok() { - true => "✓".green(), - false => "✘".red(), + let check_binary = |cmd: Option| match cmd { + Some(cmd) => match which::which(&cmd) { + Ok(_) => print_column(&format!("✓ {}", cmd), Color::Green), + Err(_) => print_column(&format!("✗ {}", cmd), Color::Red), + }, + None => print_column("None", Color::Yellow), }; - writeln!(stdout, "{} queries: {}", feature.short_title(), found)?; - Ok(()) -} + let mut language_configurations = load_merged_language_configurations()?; + language_configurations.sort_unstable_by_key(|l| l.language_id.clone()); + for lang in &language_configurations { + print_column(&lang.language_id, Color::Reset); -pub fn print_health(health_arg: Option) -> std::io::Result<()> { - match health_arg.as_deref() { - Some("languages") => languages_all()?, - Some("clipboard") => clipboard()?, - None | Some("all") => { - general()?; - clipboard()?; - writeln!(std::io::stdout().lock())?; - languages_all()?; + let lsp = lang + .language_server + .as_ref() + .map(|lsp| lsp.command.to_string()); + check_binary(lsp); + let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string()); + check_binary(dap); + + for ts_feat in TsFeature::all() { + match grammar::load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() + { + true => print_column("✓", Color::Green), + false => print_column("✗", Color::Red), + } } - Some(lang) => language(lang.to_string())?, + writeln!(stdout)?; } Ok(()) } diff --git a/helix-term/src/help.rs b/helix-term/src/help.rs new file mode 100644 index 000000000000..d6c8b2422fad --- /dev/null +++ b/helix-term/src/help.rs @@ -0,0 +1,45 @@ +pub fn help() -> String { + format!( + "\ +{pkg_name} {version} +{authors} +{description} + +USAGE: + hx [FLAGS] [files]... + +ARGS: + Open each file in a buffer. The cursor position for each file + argument can be specified via prefix flag, or a postfix postion: + + file([:row[:col]]|:) + + Postfixing with only a `:` will position the cursor at the end + of the file's buffer. + +FLAGS: + + -h, --help Prints help information + --tutor Loads the tutorial + --health [SECTION] Displays potential errors in editor setup. + Optional SECTION can 'paths', 'clipboard', 'languages' or a + singular language name. + -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml + -c, --config Specifies a file to use for configuration + -v Increases logging verbosity each use for up to 3 times + --log Specifies a file to use for logging + (default file: {log_file_path}) + -V, --version Prints version information + --vsplit Splits all given files vertically into different windows + --hsplit Splits all given files horizontally into different windows + +(row[:col]|:) Goto file position, can be prefixed on each file argument. + Prefixing with `+:` will position the cursor at the end + of the file's buffer. +", + pkg_name = env!("CARGO_PKG_NAME"), + version = helix_loader::VERSION_AND_GIT_HASH, + authors = env!("CARGO_PKG_AUTHORS"), + description = env!("CARGO_PKG_DESCRIPTION"), + log_file_path = helix_loader::log_file().display(), + ) +} diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index e94a5f66b447..52313b302677 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,642 +1,170 @@ pub mod default; pub mod macros; +// NOTE: Only pub becuase of their use in macros +pub mod keytrie; +pub mod keytrienode; +#[cfg(test)] +mod tests; + +use self::{keytrie::KeyTrie, keytrienode::KeyTrieNode, macros::key}; -pub use crate::commands::MappableCommand; -use crate::config::Config; +use crate::commands::MappableCommand; use arc_swap::{ access::{DynAccess, DynGuard}, ArcSwap, }; -use helix_view::{document::Mode, info::Info, input::KeyEvent}; -use serde::Deserialize; -use std::{ - borrow::Cow, - collections::{BTreeSet, HashMap}, - ops::{Deref, DerefMut}, - sync::Arc, -}; - -use default::default; -use macros::key; - -#[derive(Debug, Clone)] -pub struct KeyTrieNode { - /// A label for keys coming under this node, like "Goto mode" - name: String, - map: HashMap, - order: Vec, - pub is_sticky: bool, -} - -impl<'de> Deserialize<'de> for KeyTrieNode { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let map = HashMap::::deserialize(deserializer)?; - let order = map.keys().copied().collect::>(); // NOTE: map.keys() has arbitrary order - Ok(Self { - map, - order, - ..Default::default() - }) - } -} - -impl KeyTrieNode { - pub fn new(name: &str, map: HashMap, order: Vec) -> Self { - Self { - name: name.to_string(), - map, - order, - is_sticky: false, - } - } - - pub fn name(&self) -> &str { - &self.name - } - /// Merge another Node in. Leaves and subnodes from the other node replace - /// corresponding keyevent in self, except when both other and self have - /// subnodes for same key. In that case the merge is recursive. - pub fn merge(&mut self, mut other: Self) { - for (key, trie) in std::mem::take(&mut other.map) { - if let Some(KeyTrie::Node(node)) = self.map.get_mut(&key) { - if let KeyTrie::Node(other_node) = trie { - node.merge(other_node); - continue; - } - } - self.map.insert(key, trie); - } - for &key in self.map.keys() { - if !self.order.contains(&key) { - self.order.push(key); - } - } - } - - pub fn infobox(&self) -> Info { - let mut body: Vec<(&str, BTreeSet)> = Vec::with_capacity(self.len()); - for (&key, trie) in self.iter() { - let desc = match trie { - KeyTrie::Leaf(cmd) => { - if cmd.name() == "no_op" { - continue; - } - cmd.doc() - } - KeyTrie::Node(n) => n.name(), - KeyTrie::Sequence(_) => "[Multiple commands]", - }; - match body.iter().position(|(d, _)| d == &desc) { - Some(pos) => { - body[pos].1.insert(key); - } - None => body.push((desc, BTreeSet::from([key]))), - } - } - body.sort_unstable_by_key(|(_, keys)| { - self.order - .iter() - .position(|&k| k == *keys.iter().next().unwrap()) - .unwrap() - }); - let prefix = format!("{} ", self.name()); - if body.iter().all(|(desc, _)| desc.starts_with(&prefix)) { - body = body - .into_iter() - .map(|(desc, keys)| (desc.strip_prefix(&prefix).unwrap(), keys)) - .collect(); - } - Info::from_keymap(self.name(), body) - } - /// Get a reference to the key trie node's order. - pub fn order(&self) -> &[KeyEvent] { - self.order.as_slice() - } -} - -impl Default for KeyTrieNode { - fn default() -> Self { - Self::new("", HashMap::new(), Vec::new()) - } -} - -impl PartialEq for KeyTrieNode { - fn eq(&self, other: &Self) -> bool { - self.map == other.map - } -} - -impl Deref for KeyTrieNode { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.map - } -} - -impl DerefMut for KeyTrieNode { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.map - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum KeyTrie { - Leaf(MappableCommand), - Sequence(Vec), - Node(KeyTrieNode), -} - -impl<'de> Deserialize<'de> for KeyTrie { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(KeyTrieVisitor) - } -} - -struct KeyTrieVisitor; - -impl<'de> serde::de::Visitor<'de> for KeyTrieVisitor { - type Value = KeyTrie; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a command, list of commands, or sub-keymap") - } - - fn visit_str(self, command: &str) -> Result - where - E: serde::de::Error, - { - command - .parse::() - .map(KeyTrie::Leaf) - .map_err(E::custom) - } - - fn visit_seq(self, mut seq: S) -> Result - where - S: serde::de::SeqAccess<'de>, - { - let mut commands = Vec::new(); - while let Some(command) = seq.next_element::()? { - commands.push( - command - .parse::() - .map_err(serde::de::Error::custom)?, - ) - } - Ok(KeyTrie::Sequence(commands)) - } - - fn visit_map(self, mut map: M) -> Result - where - M: serde::de::MapAccess<'de>, - { - let mut mapping = HashMap::new(); - let mut order = Vec::new(); - while let Some((key, value)) = map.next_entry::()? { - mapping.insert(key, value); - order.push(key); - } - Ok(KeyTrie::Node(KeyTrieNode::new("", mapping, order))) - } -} - -impl KeyTrie { - pub fn node(&self) -> Option<&KeyTrieNode> { - match *self { - KeyTrie::Node(ref node) => Some(node), - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - } - } - - pub fn node_mut(&mut self) -> Option<&mut KeyTrieNode> { - match *self { - KeyTrie::Node(ref mut node) => Some(node), - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - } - } - - /// Merge another KeyTrie in, assuming that this KeyTrie and the other - /// are both Nodes. Panics otherwise. - pub fn merge_nodes(&mut self, mut other: Self) { - let node = std::mem::take(other.node_mut().unwrap()); - self.node_mut().unwrap().merge(node); - } - - pub fn search(&self, keys: &[KeyEvent]) -> Option<&KeyTrie> { - let mut trie = self; - for key in keys { - trie = match trie { - KeyTrie::Node(map) => map.get(key), - // leaf encountered while keys left to process - KeyTrie::Leaf(_) | KeyTrie::Sequence(_) => None, - }? - } - Some(trie) - } -} +use helix_view::{document::Mode, input::KeyEvent}; +use std::{collections::HashMap, sync::Arc}; #[derive(Debug, Clone, PartialEq)] pub enum KeymapResult { - /// Needs more keys to execute a command. Contains valid keys for next keystroke. - Pending(KeyTrieNode), + Pending(KeyTrie), Matched(MappableCommand), - /// Matched a sequence of commands to execute. - MatchedSequence(Vec), - /// Key was not found in the root keymap + MatchedCommandSequence(Vec), NotFound, - /// Key is invalid in combination with previous keys. Contains keys leading upto - /// and including current (invalid) key. + /// Contains pressed KeyEvents leading up to the cancellation. Cancelled(Vec), } -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(transparent)] pub struct Keymap { - /// Always a Node - root: KeyTrie, + pub keytries: Box>>, + /// Relative to a sticky node if Some. + pending_keys: Vec, + pub sticky_keytrie: Option, } -/// A map of command names to keybinds that will execute the command. -pub type ReverseKeymap = HashMap>>; - +pub type CommandList = HashMap>; impl Keymap { - pub fn new(root: KeyTrie) -> Self { - Keymap { root } - } - - pub fn reverse_map(&self) -> ReverseKeymap { - // recursively visit all nodes in keymap - fn map_node(cmd_map: &mut ReverseKeymap, node: &KeyTrie, keys: &mut Vec) { - match node { - KeyTrie::Leaf(cmd) => match cmd { - MappableCommand::Typable { name, .. } => { - cmd_map.entry(name.into()).or_default().push(keys.clone()) - } - MappableCommand::Static { name, .. } => cmd_map - .entry(name.to_string()) - .or_default() - .push(keys.clone()), - }, - KeyTrie::Node(next) => { - for (key, trie) in &next.map { - keys.push(*key); - map_node(cmd_map, trie, keys); - keys.pop(); - } - } - KeyTrie::Sequence(_) => {} - }; - } - - let mut res = HashMap::new(); - map_node(&mut res, &self.root, &mut Vec::new()); - res - } - - pub fn root(&self) -> &KeyTrie { - &self.root - } - - pub fn merge(&mut self, other: Self) { - self.root.merge_nodes(other.root); - } -} - -impl Deref for Keymap { - type Target = KeyTrieNode; - - fn deref(&self) -> &Self::Target { - self.root.node().unwrap() - } -} - -impl Default for Keymap { - fn default() -> Self { - Self::new(KeyTrie::Node(KeyTrieNode::default())) - } -} - -pub struct Keymaps { - pub map: Box>>, - /// Stores pending keys waiting for the next key. This is relative to a - /// sticky node if one is in use. - state: Vec, - /// Stores the sticky node if one is activated. - pub sticky: Option, -} - -impl Keymaps { - pub fn new(map: Box>>) -> Self { + pub fn new(keymaps: Box>>) -> Self { Self { - map, - state: Vec::new(), - sticky: None, + keytries: keymaps, + pending_keys: Vec::new(), + sticky_keytrie: None, } } - pub fn map(&self) -> DynGuard> { - self.map.load() + pub fn load_keymaps(&self) -> DynGuard> { + self.keytries.load() } /// Returns list of keys waiting to be disambiguated in current mode. pub fn pending(&self) -> &[KeyEvent] { - &self.state + &self.pending_keys } - pub fn sticky(&self) -> Option<&KeyTrieNode> { - self.sticky.as_ref() + pub fn sticky_keytrie(&self) -> Option<&KeyTrie> { + self.sticky_keytrie.as_ref() } - /// Lookup `key` in the keymap to try and find a command to execute. Escape - /// key cancels pending keystrokes. If there are no pending keystrokes but a - /// sticky node is in use, it will be cleared. + /// Lookup `key` in the keymap to try and find a command to execute. + /// Escape key represents cancellation. + /// This means clearing pending keystrokes, or the sticky_keytrie if none were present. pub fn get(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { // TODO: remove the sticky part and look up manually - let keymaps = &*self.map(); - let keymap = &keymaps[&mode]; + let keymaps = &*self.load_keymaps(); + let active_keymap = &keymaps[&mode]; - if key!(Esc) == key { - if !self.state.is_empty() { - // Note that Esc is not included here - return KeymapResult::Cancelled(self.state.drain(..).collect()); + if key == key!(Esc) { + if !self.pending_keys.is_empty() { + // NOTE: Esc is not included here + return KeymapResult::Cancelled(self.pending_keys.drain(..).collect()); } - self.sticky = None; + // TODO: Shouldn't we return here also? + self.sticky_keytrie = None; } - let first = self.state.get(0).unwrap_or(&key); - let trie_node = match self.sticky { - Some(ref trie) => Cow::Owned(KeyTrie::Node(trie.clone())), - None => Cow::Borrowed(&keymap.root), + // Check if sticky keytrie is to be used. + let starting_keytrie = match self.sticky_keytrie { + None => active_keymap, + Some(ref active_sticky_keytrie) => active_sticky_keytrie, }; - let trie = match trie_node.search(&[*first]) { - Some(KeyTrie::Leaf(ref cmd)) => { - return KeymapResult::Matched(cmd.clone()); + // TODO: why check either pending or regular key? + let first_key = self.pending_keys.get(0).unwrap_or(&key); + + let pending_keytrie: KeyTrie = match starting_keytrie.traverse(&[*first_key]) { + Some(KeyTrieNode::KeyTrie(sub_keytrie)) => sub_keytrie, + Some(KeyTrieNode::MappableCommand(cmd)) => { + return KeymapResult::Matched(cmd); } - Some(KeyTrie::Sequence(ref cmds)) => { - return KeymapResult::MatchedSequence(cmds.clone()); + Some(KeyTrieNode::CommandSequence(command_sequence)) => { + return KeymapResult::MatchedCommandSequence( + command_sequence.get_commands().clone(), + ); } None => return KeymapResult::NotFound, - Some(t) => t, }; - self.state.push(key); - match trie.search(&self.state[1..]) { - Some(KeyTrie::Node(map)) => { + self.pending_keys.push(key); + match pending_keytrie.traverse(&self.pending_keys[1..]) { + Some(KeyTrieNode::KeyTrie(map)) => { if map.is_sticky { - self.state.clear(); - self.sticky = Some(map.clone()); + self.pending_keys.clear(); + self.sticky_keytrie = Some(map.clone()); } - KeymapResult::Pending(map.clone()) + KeymapResult::Pending(map) } - Some(KeyTrie::Leaf(cmd)) => { - self.state.clear(); - KeymapResult::Matched(cmd.clone()) + Some(KeyTrieNode::MappableCommand(cmd)) => { + self.pending_keys.clear(); + KeymapResult::Matched(cmd) } - Some(KeyTrie::Sequence(cmds)) => { - self.state.clear(); - KeymapResult::MatchedSequence(cmds.clone()) + Some(KeyTrieNode::CommandSequence(command_sequence)) => { + self.pending_keys.clear(); + KeymapResult::MatchedCommandSequence(command_sequence.get_commands().clone()) } - None => KeymapResult::Cancelled(self.state.drain(..).collect()), + None => KeymapResult::Cancelled(self.pending_keys.drain(..).collect()), } } -} -impl Default for Keymaps { - fn default() -> Self { - Self::new(Box::new(ArcSwap::new(Arc::new(default())))) + fn get_keytrie(&self, mode: &Mode) -> KeyTrie { + // HELP: Unsure how I should handle this Option + self.keytries.load().get(mode).unwrap().clone() } -} -/// Merge default config keys with user overwritten keys for custom user config. -pub fn merge_keys(mut config: Config) -> Config { - let mut delta = std::mem::replace(&mut config.keys, default()); - for (mode, keys) in &mut config.keys { - keys.merge(delta.remove(mode).unwrap_or_default()) - } - config -} - -#[cfg(test)] -mod tests { - use super::macros::keymap; - use super::*; - use arc_swap::access::Constant; - use helix_core::hashmap; - - #[test] - #[should_panic] - fn duplicate_keys_should_panic() { - keymap!({ "Normal mode" - "i" => normal_mode, - "i" => goto_definition, - }); - } - - #[test] - fn check_duplicate_keys_in_default_keymap() { - // will panic on duplicate keys, assumes that `Keymaps` uses keymap! macro - Keymaps::default(); - } - - #[test] - fn merge_partial_keys() { - let config = Config { - keys: hashmap! { - Mode::Normal => Keymap::new( - keymap!({ "Normal mode" - "i" => normal_mode, - "无" => insert_mode, - "z" => jump_backward, - "g" => { "Merge into goto mode" - "$" => goto_line_end, - "g" => delete_char_forward, - }, - }) - ) - }, - ..Default::default() - }; - let mut merged_config = merge_keys(config.clone()); - assert_ne!(config, merged_config); - - let mut keymap = Keymaps::new(Box::new(Constant(merged_config.keys.clone()))); - assert_eq!( - keymap.get(Mode::Normal, key!('i')), - KeymapResult::Matched(MappableCommand::normal_mode), - "Leaf should replace leaf" - ); - assert_eq!( - keymap.get(Mode::Normal, key!('无')), - KeymapResult::Matched(MappableCommand::insert_mode), - "New leaf should be present in merged keymap" - ); - // Assumes that z is a node in the default keymap - assert_eq!( - keymap.get(Mode::Normal, key!('z')), - KeymapResult::Matched(MappableCommand::jump_backward), - "Leaf should replace node" - ); - - let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); - // Assumes that `g` is a node in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('$')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::goto_line_end), - "Leaf should be present in merged subnode" - ); - // Assumes that `gg` is in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('g')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::delete_char_forward), - "Leaf should replace old leaf in merged subnode" - ); - // Assumes that `ge` is in default keymap - assert_eq!( - keymap.root().search(&[key!('g'), key!('e')]).unwrap(), - &KeyTrie::Leaf(MappableCommand::goto_last_line), - "Old leaves in subnode should be present in merged node" - ); - - assert!(merged_config.keys.get(&Mode::Normal).unwrap().len() > 1); - assert!(merged_config.keys.get(&Mode::Insert).unwrap().len() > 0); - } - - #[test] - fn order_should_be_set() { - let config = Config { - keys: hashmap! { - Mode::Normal => Keymap::new( - keymap!({ "Normal mode" - "space" => { "" - "s" => { "" - "v" => vsplit, - "c" => hsplit, - }, - }, - }) - ) - }, - ..Default::default() - }; - let mut merged_config = merge_keys(config.clone()); - assert_ne!(config, merged_config); - let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); - // Make sure mapping works - assert_eq!( - keymap - .root() - .search(&[key!(' '), key!('s'), key!('v')]) - .unwrap(), - &KeyTrie::Leaf(MappableCommand::vsplit), - "Leaf should be present in merged subnode" - ); - // Make sure an order was set during merge - let node = keymap.root().search(&[crate::key!(' ')]).unwrap(); - assert!(!node.node().unwrap().order().is_empty()) - } - - #[test] - fn aliased_modes_are_same_in_default_keymap() { - let keymaps = Keymaps::default().map(); - let root = keymaps.get(&Mode::Normal).unwrap().root(); - assert_eq!( - root.search(&[key!(' '), key!('w')]).unwrap(), - root.search(&["C-w".parse::().unwrap()]).unwrap(), - "Mismatch for window mode on `Space-w` and `Ctrl-w`" + /// Returns a key-value list of all commands associated to a given Keymap. + /// Keys are the node names (see KeyTrieNode documentation) + /// Values are lists of stringified KeyEvents that triger the command. + /// Each element in the KeyEvent list is prefixed with prefixed the ancestor KeyEvents. + /// For example: Stringified KeyEvent element for the 'goto_next_window' command could be "space>w>w". + /// Ancestor KeyEvents are in this case "space" and "w". + pub fn command_list(&self, mode: &Mode) -> CommandList { + let mut list = HashMap::new(); + _command_list( + &mut list, + &KeyTrieNode::KeyTrie(self.get_keytrie(mode)), + &mut String::new(), ); - assert_eq!( - root.search(&[key!('z')]).unwrap(), - root.search(&[key!('Z')]).unwrap(), - "Mismatch for view mode on `z` and `Z`" - ); - } - - #[test] - fn reverse_map() { - let normal_mode = keymap!({ "Normal mode" - "i" => insert_mode, - "g" => { "Goto" - "g" => goto_file_start, - "e" => goto_file_end, - }, - "j" | "k" => move_line_down, - }); - let keymap = Keymap::new(normal_mode); - let mut reverse_map = keymap.reverse_map(); + return list; - // sort keybindings in order to have consistent tests - // HashMaps can be compared but we can still get different ordering of bindings - // for commands that have multiple bindings assigned - for v in reverse_map.values_mut() { - v.sort() + fn _command_list(list: &mut CommandList, node: &KeyTrieNode, prefix: &mut String) { + match node { + KeyTrieNode::KeyTrie(trie_node) => { + for (key_event, index) in trie_node.get_child_order() { + let mut temp_prefix: String = prefix.to_string(); + if !&temp_prefix.is_empty() { + temp_prefix.push('→'); + } + temp_prefix.push_str(&key_event.to_string()); + _command_list(list, &trie_node.get_children()[*index], &mut temp_prefix); + } + } + KeyTrieNode::MappableCommand(mappable_command) => { + if mappable_command.name() == "no_op" { + return; + } + list.entry(mappable_command.name().to_string()) + .or_default() + .push(prefix.to_string()); + } + KeyTrieNode::CommandSequence(_) => {} + }; } - - assert_eq!( - reverse_map, - HashMap::from([ - ("insert_mode".to_string(), vec![vec![key!('i')]]), - ( - "goto_file_start".to_string(), - vec![vec![key!('g'), key!('g')]] - ), - ( - "goto_file_end".to_string(), - vec![vec![key!('g'), key!('e')]] - ), - ( - "move_line_down".to_string(), - vec![vec![key!('j')], vec![key!('k')]] - ), - ]), - "Mismatch" - ) } +} - #[test] - fn escaped_keymap() { - use crate::commands::MappableCommand; - use helix_view::input::{KeyCode, KeyEvent, KeyModifiers}; - - let keys = r#" -"+" = [ - "select_all", - ":pipe sed -E 's/\\s+$//g'", -] - "#; - - let key = KeyEvent { - code: KeyCode::Char('+'), - modifiers: KeyModifiers::NONE, - }; - - let expectation = Keymap::new(KeyTrie::Node(KeyTrieNode::new( - "", - hashmap! { - key => KeyTrie::Sequence(vec!{ - MappableCommand::select_all, - MappableCommand::Typable { - name: "pipe".to_string(), - args: vec!{ - "sed".to_string(), - "-E".to_string(), - "'s/\\s+$//g'".to_string() - }, - doc: "".to_string(), - }, - }) - }, - vec![key], - ))); - - assert_eq!(toml::from_str(keys), Ok(expectation)); +impl Default for Keymap { + fn default() -> Self { + Self::new(Box::new(ArcSwap::new(Arc::new(default::default())))) } } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 01184f80edcd..a492ec004284 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -1,11 +1,10 @@ -use std::collections::HashMap; - -use super::macros::keymap; -use super::{Keymap, Mode}; +use super::{keytrie::KeyTrie, macros::keytrie}; use helix_core::hashmap; +use helix_view::document::Mode; +use std::collections::HashMap; -pub fn default() -> HashMap { - let normal = keymap!({ "Normal mode" +pub fn default() -> HashMap { + let normal = keytrie!({ "Normal mode" "h" | "left" => move_char_left, "j" | "down" => move_visual_line_down, "k" | "up" => move_visual_line_up, @@ -191,10 +190,10 @@ pub fn default() -> HashMap { "C-j" | "j" | "down" => jump_view_down, "C-k" | "k" | "up" => jump_view_up, "C-l" | "l" | "right" => jump_view_right, - "L" => swap_view_right, - "K" => swap_view_up, "H" => swap_view_left, "J" => swap_view_down, + "K" => swap_view_up, + "L" => swap_view_right, "n" => { "New split scratch buffer" "C-s" | "s" => hsplit_new, "C-v" | "v" => vsplit_new, @@ -321,7 +320,7 @@ pub fn default() -> HashMap { "C-x" => decrement, }); let mut select = normal.clone(); - select.merge_nodes(keymap!({ "Select mode" + select.merge_keytrie(keytrie!({ "Select mode" "h" | "left" => extend_char_left, "j" | "down" => extend_visual_line_down, "k" | "up" => extend_visual_line_up, @@ -352,7 +351,7 @@ pub fn default() -> HashMap { "j" => extend_line_down, }, })); - let insert = keymap!({ "Insert mode" + let insert = keytrie!({ "Insert mode" "esc" => normal_mode, "C-s" => commit_undo_checkpoint, @@ -378,8 +377,8 @@ pub fn default() -> HashMap { "end" => goto_line_end_newline, }); hashmap!( - Mode::Normal => Keymap::new(normal), - Mode::Select => Keymap::new(select), - Mode::Insert => Keymap::new(insert), + Mode::Normal => normal, + Mode::Select => select, + Mode::Insert => insert, ) } diff --git a/helix-term/src/keymap/keymaps.rs b/helix-term/src/keymap/keymaps.rs new file mode 100644 index 000000000000..f99cfe9bdebe --- /dev/null +++ b/helix-term/src/keymap/keymaps.rs @@ -0,0 +1,150 @@ +use super::*; +use crate::keymap::macros::*; +use crate::commands::MappableCommand; +use helix_view::{document::Mode, input::KeyEvent}; +use std::{sync::Arc, collections::HashMap}; +use arc_swap::{access::{DynAccess, DynGuard}, ArcSwap}; + +use std::ops::Deref; + +#[derive(Debug, Clone, PartialEq)] +pub enum KeymapResult { + Pending(KeyTrie), + Matched(MappableCommand), + MatchedCommandSequence(Vec), + NotFound, + /// Contains pressed KeyEvents leading up to the cancellation. + Cancelled(Vec), +} + +pub struct Keymaps { + pub keymaps: Box>>, + /// Relative to a sticky node if Some. + pending_keys: Vec, + pub sticky_keytrie: Option, +} + +pub type CommandList = HashMap>; +impl Keymaps { + pub fn new(keymaps: Box>>) -> Self { + Self { + keymaps, + pending_keys: Vec::new(), + sticky_keytrie: None, + } + } + + pub fn load_keymaps(&self) -> DynGuard> { + self.keymaps.load() + } + + /// Returns list of keys waiting to be disambiguated in current mode. + pub fn pending(&self) -> &[KeyEvent] { + &self.pending_keys + } + + pub fn sticky_keytrie(&self) -> Option<&KeyTrie> { + self.sticky_keytrie.as_ref() + } + + /// Lookup `key` in the keymap to try and find a command to execute. + /// Escape key represents cancellation. + /// This means clearing pending keystrokes, or the sticky_keytrie if none were present. + pub fn get(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { + // TODO: remove the sticky part and look up manually + let keymaps = &*self.load_keymaps(); + let active_keymap = &keymaps[&mode]; + + if key == key!(Esc) { + if !self.pending_keys.is_empty() { + // NOTE: Esc is not included here + return KeymapResult::Cancelled(self.pending_keys.drain(..).collect()); + } + // TODO: Shouldn't we return here also? + self.sticky_keytrie = None; + } + + // Check if sticky keytrie is to be used. + let starting_keytrie = match self.sticky_keytrie { + None => &active_keymap, + Some(ref active_sticky_keytrie) => active_sticky_keytrie, + }; + + // TODO: why check either pending or regular key? + let first_key = self.pending_keys.get(0).unwrap_or(&key); + + let pending_keytrie: KeyTrie = match starting_keytrie.traverse(&[*first_key]) { + Some(KeyTrieNode::KeyTrie(sub_keytrie)) => sub_keytrie, + Some(KeyTrieNode::MappableCommand(cmd)) => { + return KeymapResult::Matched(cmd.clone()); + } + Some(KeyTrieNode::CommandSequence(cmds)) => { + return KeymapResult::MatchedCommandSequence(cmds.clone()); + } + None => return KeymapResult::NotFound, + }; + + self.pending_keys.push(key); + match pending_keytrie.traverse(&self.pending_keys[1..]) { + Some(KeyTrieNode::KeyTrie(map)) => { + if map.is_sticky { + self.pending_keys.clear(); + self.sticky_keytrie = Some(map.clone()); + } + KeymapResult::Pending(map.clone()) + } + Some(KeyTrieNode::MappableCommand(cmd)) => { + self.pending_keys.clear(); + KeymapResult::Matched(cmd.clone()) + } + Some(KeyTrieNode::CommandSequence(cmds)) => { + self.pending_keys.clear(); + KeymapResult::MatchedCommandSequence(cmds.clone()) + } + None => KeymapResult::Cancelled(self.pending_keys.drain(..).collect()), + } + } + + fn get_keytrie(&self, mode: &Mode) -> KeyTrie { + // HELP: Unsure how I should handle this Option + self.keymaps.load().get(mode).unwrap().clone() + } + + /// Returns a key-value list of all commands associated to a given Keymap. + /// Keys are the node names (see KeyTrieNode documentation) + /// Values are lists of stringified KeyEvents that triger the command. + /// Each element in the KeyEvent list is prefixed with prefixed the ancestor KeyEvents. + /// For example: Stringified KeyEvent element for the 'goto_next_window' command could be "space>w>w". + /// Ancestor KeyEvents are in this case "space" and "w". + pub fn command_list(&self, mode: &Mode) -> CommandList { + let mut list = HashMap::new(); + _command_list(&mut list, &KeyTrieNode::KeyTrie(self.get_keytrie(mode)), &mut String::new()); + return list; + + fn _command_list(list: &mut CommandList, node: &KeyTrieNode, prefix: &mut String) { + match node { + KeyTrieNode::KeyTrie(trie_node) => { + for (key_event, subtrie_node) in trie_node.deref() { + let mut temp_prefix: String = prefix.to_string(); + if &temp_prefix != "" { + temp_prefix.push_str(">"); + } + temp_prefix.push_str(&key_event.to_string()); + _command_list(list, subtrie_node, &mut temp_prefix); + } + }, + KeyTrieNode::MappableCommand(mappable_command) => { + if mappable_command.name() == "no_op" { return } + list.entry(mappable_command.name().to_string()).or_default().push(prefix.to_string()); + }, + KeyTrieNode::CommandSequence(_) => {} + }; + } + } +} + +impl Default for Keymaps { + fn default() -> Self { + Self::new(Box::new(ArcSwap::new(Arc::new(default::default())))) + } +} diff --git a/helix-term/src/keymap/keytrie.rs b/helix-term/src/keymap/keytrie.rs new file mode 100644 index 000000000000..1d495c754cdd --- /dev/null +++ b/helix-term/src/keymap/keytrie.rs @@ -0,0 +1,305 @@ +use super::keytrienode::KeyTrieNode; +use helix_view::{info::Info, input::KeyEvent}; +use serde::Deserialize; +use std::{cmp::Ordering, collections::HashMap}; + +/// Edges of the trie are KeyEvents and the nodes are descrbibed by KeyTrieNode +#[derive(Debug, Clone)] +pub struct KeyTrie { + description: String, + /// Used for pre-defined order in infoboxes, values represent the index of the key tries children. + child_order: HashMap, + children: Vec, + pub is_sticky: bool, + /// Used to respect pre-defined stickyness. + pub explicitly_set_sticky: bool, + /// Used to override pre-defined descriptions. + pub explicitly_set_description: bool, +} + +impl KeyTrie { + pub fn new( + description: &str, + child_order: HashMap, + children: Vec, + ) -> Self { + Self { + description: description.to_string(), + child_order, + children, + is_sticky: false, + explicitly_set_sticky: false, + explicitly_set_description: false, + } + } + + pub fn get_child_order(&self) -> &HashMap { + &self.child_order + } + + pub fn get_children(&self) -> &Vec { + &self.children + } + + pub fn get_description(&self) -> &str { + &self.description + } + + // None symbolizes NotFound + pub fn traverse(&self, key_events: &[KeyEvent]) -> Option { + return _traverse(self, key_events, 0); + + fn _traverse( + keytrie: &KeyTrie, + key_events: &[KeyEvent], + mut depth: usize, + ) -> Option { + if depth == key_events.len() { + return Some(KeyTrieNode::KeyTrie(keytrie.clone())); + } else if let Some(found_index) = keytrie.child_order.get(&key_events[depth]) { + match &keytrie.children[*found_index] { + KeyTrieNode::KeyTrie(sub_keytrie) => { + depth += 1; + return _traverse(sub_keytrie, key_events, depth); + } + _found_child => return Some(_found_child.clone()), + } + } + None + } + } + + /// Other takes precedent. + pub fn merge_keytrie(&mut self, other_keytrie: Self) { + if other_keytrie.explicitly_set_sticky { + self.is_sticky = other_keytrie.is_sticky; + } + + if other_keytrie.explicitly_set_description { + self.description = other_keytrie.description.clone(); + } + + for (other_key_event, other_index) in other_keytrie.get_child_order() { + let other_child_keytrie_node = &other_keytrie.get_children()[*other_index]; + if let Some(existing_index) = self.child_order.get(other_key_event) { + if let KeyTrieNode::KeyTrie(ref mut self_clashing_child_key_trie) = + self.children[*existing_index] + { + if let KeyTrieNode::KeyTrie(other_child_keytrie) = other_child_keytrie_node { + self_clashing_child_key_trie.merge_keytrie(other_child_keytrie.clone()); + continue; + } + } + self.children[*existing_index] = other_child_keytrie_node.clone(); + } else { + self.child_order + .insert(*other_key_event, self.children.len()); + self.children.push(other_child_keytrie_node.clone()); + } + } + } + + // IMPROVEMENT: cache contents and update cache only when config is updated + /// Open an info box for a given KeyTrie + /// Shows the children as possible KeyEvents with thier associated description. + pub fn infobox(&self, sort_infobox: bool) -> Info { + let mut body: InfoBoxBody = Vec::with_capacity(self.children.len()); + let mut key_event_order = Vec::with_capacity(self.children.len()); + // child_order and children is of same length + #[allow(clippy::uninit_vec)] + unsafe { + key_event_order.set_len(self.children.len()); + } + for (key_event, index) in &self.child_order { + key_event_order[*index] = key_event; + } + + for (index, key_trie) in self.children.iter().enumerate() { + let description: String = match key_trie { + KeyTrieNode::MappableCommand(ref command) => { + if command.name() == "no_op" { + continue; + } + command.get_description().to_string() + } + KeyTrieNode::CommandSequence(command_sequence) => { + if let Some(custom_description) = command_sequence.get_description() { + custom_description.to_string() + } else { + command_sequence + .get_commands() + .iter() + .map(|command| command.name().to_string()) + .collect::>() + .join(" → ") + .clone() + } + } + KeyTrieNode::KeyTrie(key_trie) => key_trie.description.clone(), + }; + let key_event = key_event_order[index]; + match body + .iter() + .position(|(_, existing_description)| &description == existing_description) + { + Some(position) => body[position].0.push(key_event.to_string()), + None => body.push((vec![key_event.to_string()], description)), + } + } + + // TODO: Add "A-" acknowledgement? + // Shortest keyevent (as string) appears first, unless it's a "C-" KeyEvent + // Those events will always be placed after the one letter KeyEvent + for (key_events, _) in body.iter_mut() { + key_events.sort_unstable_by(|a, b| { + if a.len() == 1 { + return Ordering::Less; + } + if b.len() > a.len() && b.starts_with("C-") { + return Ordering::Greater; + } + a.len().cmp(&b.len()) + }); + } + + if sort_infobox { + body = keyevent_sort_infobox(body); + } + + let mut stringified_key_events_body = Vec::with_capacity(body.len()); + for (key_events, description) in body { + stringified_key_events_body.push((key_events.join(", "), description)); + } + + Info::new(&self.description, &stringified_key_events_body) + } +} + +impl Default for KeyTrie { + fn default() -> Self { + Self::new("", HashMap::new(), Vec::new()) + } +} + +impl PartialEq for KeyTrie { + fn eq(&self, other: &Self) -> bool { + self.children == other.children + } +} + +impl<'de> Deserialize<'de> for KeyTrie { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // NOTE: no assumption of pre-defined order in config + let child_collection = HashMap::::deserialize(deserializer)?; + let mut child_order = HashMap::::new(); + let mut children = Vec::new(); + for (key_event, keytrie_node) in child_collection { + child_order.insert(key_event, children.len()); + children.push(keytrie_node); + } + + Ok(Self { + child_order, + children, + ..Default::default() + }) + } +} + +/// (KeyEvents as strings, Description) +type InfoBoxRow = (Vec, String); +type InfoBoxBody = Vec; +/// Sorts by `ModifierKeyCode`, then by each `KeyCode` category, then by each `KeyEvent`. +/// KeyCode::Char sorting is special in that lower-case and upper-case equivalents are +/// placed together, and alphas are placed before the rest. +fn keyevent_sort_infobox(body: InfoBoxBody) -> InfoBoxBody { + use helix_view::keyboard::{KeyCode, KeyModifiers, MediaKeyCode}; + use std::collections::BTreeMap; + use std::str::FromStr; + + let mut category_holder: BTreeMap>> = + BTreeMap::new(); + let mut sorted_body: InfoBoxBody = Vec::with_capacity(body.len()); + for infobox_row in body { + let first_keyevent = KeyEvent::from_str(infobox_row.0[0].as_str()).unwrap(); + category_holder + .entry(first_keyevent.modifiers) + .or_insert_with(BTreeMap::new); + + // HACK: inserting by variant not by variant value. + // KeyCode:: Char, F, and MediaKeys can have muiltiple values for the given variant + // Hence the use of mock Variant values + let keycode_category = match first_keyevent.code { + KeyCode::Char(_) => KeyCode::Char('a'), + KeyCode::F(_) => KeyCode::F(0), + KeyCode::Media(_) => KeyCode::Media(MediaKeyCode::Play), + other_keycode => other_keycode, + }; + + let modifier_category = category_holder + .get_mut(&first_keyevent.modifiers) + .expect("keycode category existence should be checked."); + modifier_category + .entry(keycode_category) + .or_insert_with(Vec::new); + modifier_category + .get_mut(&keycode_category) + .expect("key existence should be checked") + .push(infobox_row); + } + + for (_, keycode_categories) in category_holder { + for (keycode_category, mut infobox_rows) in keycode_categories { + if infobox_rows.len() > 1 { + match keycode_category { + KeyCode::Char(_) => { + infobox_rows.sort_unstable_by(|a, b| { + a.0[0].to_lowercase().cmp(&b.0[0].to_lowercase()) + }); + + // Consistently place lowercase before uppercase of the same letter. + let mut x_index = 0; + let mut y_index = 1; + while y_index < infobox_rows.len() { + let x = &infobox_rows[x_index].0[0]; + let y = &infobox_rows[y_index].0[0]; + if x.to_lowercase() == y.to_lowercase() && x < y { + infobox_rows.swap(x_index, y_index); + } + x_index = y_index; + y_index += 1; + } + + let mut alphas = Vec::new(); + let mut misc = Vec::new(); + for infobox_row in infobox_rows { + if ('a'..='z') + .map(|char| char.to_string()) + .any(|alpha_char| *alpha_char == infobox_row.0[0].to_lowercase()) + { + alphas.push(infobox_row); + } else { + misc.push(infobox_row); + } + } + infobox_rows = Vec::with_capacity(alphas.len() + misc.len()); + for alpha_row in alphas { + infobox_rows.push(alpha_row); + } + for misc_row in misc { + infobox_rows.push(misc_row); + } + } + _ => { + infobox_rows.sort_unstable(); + } + } + } + sorted_body.append(infobox_rows.as_mut()); + } + } + sorted_body +} diff --git a/helix-term/src/keymap/keytrienode.rs b/helix-term/src/keymap/keytrienode.rs new file mode 100644 index 000000000000..fe58c3a40104 --- /dev/null +++ b/helix-term/src/keymap/keytrienode.rs @@ -0,0 +1,198 @@ +use super::keytrie::KeyTrie; +use crate::commands::MappableCommand; +use helix_view::input::KeyEvent; +use serde::{de::Visitor, Deserialize}; +use std::collections::HashMap; + +/// Each variant includes a description property. +/// For the MappableCommand and CommandSequence variants, the property is self explanatory. +/// For KeyTrie, the description is used for respective infobox titles, +/// or infobox KeyEvent descriptions that in themselves trigger the opening of another infobox. +/// See remapping.md for a further explanation of how descriptions are used. +#[derive(Debug, Clone)] +pub enum KeyTrieNode { + MappableCommand(MappableCommand), + CommandSequence(CommandSequence), + KeyTrie(KeyTrie), +} + +impl KeyTrieNode { + pub fn get_description(&self) -> Option<&str> { + match self { + Self::MappableCommand(mappable_command) => Some(mappable_command.get_description()), + Self::CommandSequence(command_sequence) => command_sequence.get_description(), + Self::KeyTrie(node) => Some(node.get_description()), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CommandSequence { + description: Option, + commands: Vec, +} + +impl CommandSequence { + pub fn descriptionless(commands: Vec) -> Self { + Self { + description: None, + commands, + } + } + + pub fn get_commands(&self) -> &Vec { + &self.commands + } + + pub fn get_description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +impl<'de> Deserialize<'de> for KeyTrieNode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(KeyTrieNodeVisitor) + } +} + +impl PartialEq for KeyTrieNode { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (KeyTrieNode::MappableCommand(_self), KeyTrieNode::MappableCommand(_other)) => { + _self == _other + } + (KeyTrieNode::CommandSequence(_self), KeyTrieNode::CommandSequence(_other)) => { + _self == _other + } + (KeyTrieNode::KeyTrie(_self), KeyTrieNode::KeyTrie(_other)) => { + _self.get_children() == _other.get_children() + } + _ => false, + } + } +} + +struct KeyTrieNodeVisitor; + +impl<'de> Visitor<'de> for KeyTrieNodeVisitor { + type Value = KeyTrieNode; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a KeyTrieNode") + } + + fn visit_str(self, command: &str) -> Result + where + E: serde::de::Error, + { + command + .parse::() + .map(KeyTrieNode::MappableCommand) + .map_err(E::custom) + } + + fn visit_seq(self, mut seq: S) -> Result + where + S: serde::de::SeqAccess<'de>, + { + let mut commands = Vec::new(); + while let Some(command) = seq.next_element::()? { + commands.push( + command + .parse::() + .map_err(serde::de::Error::custom)?, + ) + } + Ok(KeyTrieNode::CommandSequence(CommandSequence { + description: None, + commands, + })) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let into_keytrie = |peeked_key: String, + mut map: M, + description: &str, + user_explicit_description: bool| + -> Result { + let mut children = Vec::new(); + let mut child_order = HashMap::new(); + let mut keytrie_is_sticky = false; + let mut user_explicit_sticky = false; + let mut next_key = Some(peeked_key); + + while let Some(ref peeked_key) = next_key { + if peeked_key == "sticky" { + keytrie_is_sticky = map.next_value::()?; + user_explicit_sticky = true; + } else { + let key_event = peeked_key + .parse::() + .map_err(serde::de::Error::custom)?; + let keytrie_node = map.next_value::()?; + child_order.insert(key_event, children.len()); + children.push(keytrie_node); + } + next_key = map.next_key::()?; + } + + let mut keytrie = KeyTrie::new(description, child_order, children); + keytrie.is_sticky = keytrie_is_sticky; + keytrie.explicitly_set_sticky = user_explicit_sticky; + keytrie.explicitly_set_description = user_explicit_description; + Ok(KeyTrieNode::KeyTrie(keytrie)) + }; + + let Some(first_key) = map.next_key::()? else { + return Err(serde::de::Error::custom("Maps without keys are undefined keymap remapping behaviour.")) + }; + + if first_key != "description" { + return into_keytrie(first_key, map, "", false); + } + + let description = map.next_value::()?; + + if let Some(second_key) = map.next_key::()? { + if &second_key != "exec" { + return into_keytrie(second_key, map, &description, true); + } + let keytrie_node: KeyTrieNode = map.next_value::()?; + match keytrie_node { + KeyTrieNode::KeyTrie(_) => Err(serde::de::Error::custom( + "'exec' key reserved for command(s) only, omit when adding custom descriptions to nested remappings.", + )), + KeyTrieNode::MappableCommand(mappable_command) => { + match mappable_command { + MappableCommand::Typable { name, args, .. } => { + Ok(KeyTrieNode::MappableCommand(MappableCommand::Typable { + name, + args, + description, + })) + } + MappableCommand::Static { .. } => { + Err(serde::de::Error::custom("Currently not possible to rename static commands, only typables. (Those that begin with a colon.) ")) + } + } + }, + KeyTrieNode::CommandSequence(command_sequence) => { + Ok(KeyTrieNode::CommandSequence(CommandSequence { + description: Some(description), + commands: command_sequence.commands, + })) + } + } + } else { + let mut keytrie_node = KeyTrie::new(&description, HashMap::new(), Vec::new()); + keytrie_node.explicitly_set_description = true; + Ok(KeyTrieNode::KeyTrie(keytrie_node)) + } + } +} diff --git a/helix-term/src/keymap/macros.rs b/helix-term/src/keymap/macros.rs index c4a1bfbb3064..c8f52128e3e4 100644 --- a/helix-term/src/keymap/macros.rs +++ b/helix-term/src/keymap/macros.rs @@ -1,8 +1,8 @@ #[macro_export] macro_rules! key { - ($key:ident) => { + ($key_event:ident) => { ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, + code: ::helix_view::keyboard::KeyCode::$key_event, modifiers: ::helix_view::keyboard::KeyModifiers::NONE, } }; @@ -16,9 +16,9 @@ macro_rules! key { #[macro_export] macro_rules! shift { - ($key:ident) => { + ($key_event:ident) => { ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, + code: ::helix_view::keyboard::KeyCode::$key_event, modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, } }; @@ -32,9 +32,9 @@ macro_rules! shift { #[macro_export] macro_rules! ctrl { - ($key:ident) => { + ($key_event:ident) => { ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, + code: ::helix_view::keyboard::KeyCode::$key_event, modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, } }; @@ -48,9 +48,9 @@ macro_rules! ctrl { #[macro_export] macro_rules! alt { - ($key:ident) => { + ($key_event:ident) => { ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, + code: ::helix_view::keyboard::KeyCode::$key_event, modifiers: ::helix_view::keyboard::KeyModifiers::ALT, } }; @@ -62,13 +62,12 @@ macro_rules! alt { }; } -/// Macro for defining the root of a `Keymap` object. Example: +/// Macro for defining the root of a `KeyTrie` object. Example: /// /// ``` /// # use helix_core::hashmap; -/// # use helix_term::keymap; -/// # use helix_term::keymap::Keymap; -/// let normal_mode = keymap!({ "Normal mode" +/// # use helix_term::keymap::{keytrie::KeyTrie, macros::keytrie}; +/// let normal_mode = keytrie!({ "Normal mode" /// "i" => insert_mode, /// "g" => { "Goto" /// "g" => goto_file_start, @@ -76,52 +75,46 @@ macro_rules! alt { /// }, /// "j" | "down" => move_line_down, /// }); -/// let keymap = Keymap::new(normal_mode); +/// let keymap = normal_mode; /// ``` #[macro_export] -macro_rules! keymap { - (@trie $cmd:ident) => { - $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) - }; - - (@trie - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { - keymap!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ }) - }; - - (@trie [$($cmd:ident),* $(,)?]) => { - $crate::keymap::KeyTrie::Sequence(vec![$($crate::commands::Command::$cmd),*]) - }; - - ( - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { - // modified from the hashmap! macro +macro_rules! keytrie { + // Sub key_trie + ({ $label:literal $(sticky=$sticky:literal)? $($($key_event:literal)|+ => $value:tt,)+ }) => { { - let _cap = hashmap!(@count $($($key),+),*); - let mut _map = ::std::collections::HashMap::with_capacity(_cap); - let mut _order = ::std::vec::Vec::with_capacity(_cap); + let _cap = hashmap!(@count $($($key_event),+),*); + let mut _children: Vec<$crate::keymap::keytrienode::KeyTrieNode> = ::std::vec::Vec::new(); + let mut _child_order: ::std::collections::HashMap<::helix_view::input::KeyEvent, usize> = ::std::collections::HashMap::with_capacity(_cap); $( $( - let _key = $key.parse::<::helix_view::input::KeyEvent>().unwrap(); - let _duplicate = _map.insert( - _key, - keymap!(@trie $value) - ); - assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); - _order.push(_key); + let _key_event = $key_event.parse::<::helix_view::input::KeyEvent>().unwrap(); + let _potential_duplicate = _child_order.insert(_key_event, _children.len()); + assert!(_potential_duplicate.is_none(), "Duplicate key found: {:?}", _potential_duplicate.unwrap()); + _children.push(keytrie!(@trie $value)); )+ )* - let mut _node = $crate::keymap::KeyTrieNode::new($label, _map, _order); + + let mut _node = $crate::keymap::keytrie::KeyTrie::new($label, _child_order, _children); $( _node.is_sticky = $sticky; )? - $crate::keymap::KeyTrie::Node(_node) + _node } }; + + (@trie {$label:literal $(sticky=$sticky:literal)? $($($key_event:literal)|+ => $value:tt,)+ }) => { + $crate::keymap::keytrienode::KeyTrieNode::KeyTrie(keytrie!({ $label $(sticky=$sticky)? $($($key_event)|+ => $value,)+ })) + }; + + (@trie $cmd:ident) => { + $crate::keymap::keytrienode::KeyTrieNode::MappableCommand($crate::commands::MappableCommand::$cmd) + }; + + (@trie [$($cmd:ident),* $(,)?]) => { + $crate::keymap::keytrienode::KeyTrieNode::CommandSequence(vec![$($crate::commands::Command::$cmd),*]) + }; } pub use alt; pub use ctrl; pub use key; -pub use keymap; +pub use keytrie; pub use shift; diff --git a/helix-term/src/keymap/tests.rs b/helix-term/src/keymap/tests.rs new file mode 100644 index 000000000000..e829fe083f8f --- /dev/null +++ b/helix-term/src/keymap/tests.rs @@ -0,0 +1,120 @@ +use crate::{ + commands::MappableCommand, + key, + keymap::{ + keytrie::KeyTrie, + keytrienode::{CommandSequence, KeyTrieNode}, + macros::keytrie, + Keymap, + }, +}; +use arc_swap::ArcSwap; +use helix_core::hashmap; +use helix_view::{document::Mode, input::KeyEvent}; +use std::{collections::HashMap, sync::Arc}; + +#[test] +#[should_panic] +fn duplicate_keys_should_panic() { + keytrie!({ "Normal mode" + "i" => normal_mode, + "i" => goto_definition, + }); +} + +#[test] +fn check_duplicate_keys_in_default_keymap() { + // will panic on duplicate keys, assumes that `Keymap` uses keymap! macro + Keymap::default(); +} + +#[test] +fn aliased_modes_are_same_in_default_keymap() { + let normal_mode_keytrie_root = Keymap::default().get_keytrie(&Mode::Normal); + assert_eq!( + normal_mode_keytrie_root + .traverse(&[key!(' '), key!('w')]) + .unwrap(), + normal_mode_keytrie_root + .traverse(&["C-w".parse::().unwrap()]) + .unwrap(), + "Mismatch for window mode on `Space-w` and `Ctrl-w`." + ); + assert_eq!( + normal_mode_keytrie_root.traverse(&[key!('z')]).unwrap(), + normal_mode_keytrie_root.traverse(&[key!('Z')]).unwrap(), + "Mismatch for view mode on `z` and `Z`." + ); +} + +#[test] +fn command_list() { + let normal_mode = keytrie!({ "Normal mode" + "i" => insert_mode, + "g" => { "Goto" + "g" => goto_file_start, + "e" => goto_file_end, + }, + "j" | "k" => move_line_down, + }); + + let keymap = Keymap::new(Box::new(ArcSwap::new(Arc::new( + hashmap!(Mode::Normal => normal_mode), + )))); + let mut command_list = keymap.command_list(&Mode::Normal); + + // sort keybindings in order to have consistent tests + // HashMaps can be compared but we can still get different ordering of bindings + // for commands that have multiple bindings assigned + for v in command_list.values_mut() { + v.sort() + } + + assert_eq!( + command_list, + HashMap::from([ + ("insert_mode".to_string(), vec![key!('i').to_string()]), + ( + "goto_file_start".to_string(), + vec![format!("{}→{}", key!('g'), key!('g'))] + ), + ( + "goto_file_end".to_string(), + vec![format!("{}→{}", key!('g'), key!('e'))] + ), + ( + "move_line_down".to_string(), + vec![key!('j').to_string(), key!('k').to_string()] + ) + ]), + "Mismatch" + ) +} + +#[test] +fn escaped_keymap() { + let parsed_keytrie: KeyTrie = toml::from_str( + r#" +"+" = [ + "select_all", + ":pipe sed -E 's/\\s+$//g'", +] + "#, + ) + .unwrap(); + + let command_sequence = KeyTrieNode::CommandSequence(CommandSequence::descriptionless(vec![ + MappableCommand::select_all, + MappableCommand::Typable { + name: "pipe".to_string(), + args: vec![ + "sed".to_string(), + "-E".to_string(), + "'s/\\s+$//g'".to_string(), + ], + description: "".to_string(), + }, + ])); + + assert_eq!(parsed_keytrie.get_children()[0], command_sequence); +} diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index 2f6ec12b13fd..8e2677e4ae01 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -15,17 +15,6 @@ use std::path::Path; use ignore::DirEntry; pub use keymap::macros::*; -#[cfg(not(windows))] -fn true_color() -> bool { - std::env::var("COLORTERM") - .map(|v| matches!(v.as_str(), "truecolor" | "24bit")) - .unwrap_or(false) -} -#[cfg(windows)] -fn true_color() -> bool { - true -} - /// Function used for filtering dir entries in the various file pickers. fn filter_picker_entry(entry: &DirEntry, root: &Path, dedup_symlinks: bool) -> bool { // We always want to ignore the .git directory, otherwise if diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index aac5c5379f37..19eb084f2f50 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,39 +1,15 @@ -use anyhow::{Context, Error, Result}; +mod help; + +use anyhow::{Context, Result}; use crossterm::event::EventStream; +use helix_core::syntax::LanguageConfigurations; use helix_loader::VERSION_AND_GIT_HASH; use helix_term::application::Application; use helix_term::args::Args; use helix_term::config::Config; +use helix_view::Theme; use std::path::PathBuf; -fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { - let mut base_config = fern::Dispatch::new(); - - base_config = match verbosity { - 0 => base_config.level(log::LevelFilter::Warn), - 1 => base_config.level(log::LevelFilter::Info), - 2 => base_config.level(log::LevelFilter::Debug), - _3_or_more => base_config.level(log::LevelFilter::Trace), - }; - - // Separate file config so we can include year, month and day in file logs - let file_config = fern::Dispatch::new() - .format(|out, message, record| { - out.finish(format_args!( - "{} {} [{}] {}", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), - record.target(), - record.level(), - message - )) - }) - .chain(fern::log_file(logpath)?); - - base_config.chain(file_config).apply()?; - - Ok(()) -} - fn main() -> Result<()> { let exit_code = main_impl()?; std::process::exit(exit_code); @@ -41,59 +17,17 @@ fn main() -> Result<()> { #[tokio::main] async fn main_impl() -> Result { - let logpath = helix_loader::log_file(); - let parent = logpath.parent().unwrap(); - if !parent.exists() { - std::fs::create_dir_all(parent).ok(); - } - - let help = format!( - "\ -{} {} -{} -{} + let args = Args::parse_args().context("failed to parse arguments")?; + setup_logging(args.log_file.clone(), args.verbosity).context("failed to initialize logging")?; -USAGE: - hx [FLAGS] [files]... - -ARGS: - ... Sets the input file to use, position can also be specified via file[:row[:col]] - -FLAGS: - -h, --help Prints help information - --tutor Loads the tutorial - --health [CATEGORY] Checks for potential errors in editor setup - CATEGORY can be a language or one of 'clipboard', 'languages' - or 'all'. 'all' is the default if not specified. - -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml - -c, --config Specifies a file to use for configuration - -v Increases logging verbosity each use for up to 3 times - --log Specifies a file to use for logging - (default file: {}) - -V, --version Prints version information - --vsplit Splits all given files vertically into different windows - --hsplit Splits all given files horizontally into different windows -", - env!("CARGO_PKG_NAME"), - VERSION_AND_GIT_HASH, - env!("CARGO_PKG_AUTHORS"), - env!("CARGO_PKG_DESCRIPTION"), - logpath.display(), - ); - - let args = Args::parse_args().context("could not parse arguments")?; - - // Help has a higher priority and should be handled separately. if args.display_help { - print!("{}", help); - std::process::exit(0); + print!("{}", help::help()); + return Ok(0); } - if args.display_version { println!("helix {}", VERSION_AND_GIT_HASH); - std::process::exit(0); + return Ok(0); } - if args.health { if let Err(err) = helix_term::health::print_health(args.health_arg) { // Piping to for example `head -10` requires special handling: @@ -102,58 +36,90 @@ FLAGS: return Err(err.into()); } } - - std::process::exit(0); + return Ok(0); } - if args.fetch_grammars { helix_loader::grammar::fetch_grammars()?; return Ok(0); } - if args.build_grammars { helix_loader::grammar::build_grammars(None)?; return Ok(0); } - let logpath = args.log_file.as_ref().cloned().unwrap_or(logpath); - setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; - - let config_dir = helix_loader::config_dir(); - if !config_dir.exists() { - std::fs::create_dir_all(&config_dir).ok(); + helix_loader::setup_config_file(args.config_file.clone()); + let mut config = check_config_load(Config::merged(), None, ""); + if config.editor.load_local_config { + // NOTE: deserializes user config once again + config = check_config_load(Config::merged_local_config(), Some(config), ""); } + Theme::set_true_color_support( + config.editor.true_color + || cfg!(windows) + || std::env::var("COLORTERM") + .map(|v| matches!(v.as_str(), "truecolor" | "24bit")) + .unwrap_or(false), + ); - helix_loader::initialize_config_file(args.config_file.clone()); + // TODO: use the thread local executor to spawn the application task separately from the work pool + let mut app = Application::new( + args, + check_config_load(Theme::new(&config.theme), None, "theme"), + check_config_load(LanguageConfigurations::merged(), None, "language"), + config, + ) + .context("unable to create new application")?; + app.run(&mut EventStream::new()).await +} - let config = match std::fs::read_to_string(helix_loader::config_file()) { - Ok(config) => toml::from_str(&config) - .map(helix_term::keymap::merge_keys) - .unwrap_or_else(|err| { - eprintln!("Bad config: {}", err); - eprintln!("Press to continue with default config"); - use std::io::Read; - let _ = std::io::stdin().read(&mut []); - Config::default() - }), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), - Err(err) => return Err(Error::new(err)), +fn setup_logging(_logpath: Option, verbosity: u64) -> Result<()> { + let log_level = match verbosity { + 0 => match std::env::var("HELIX_LOG_LEVEL") { + Ok(str) => str.parse()?, + Err(_) => log::LevelFilter::Warn, + }, + 1 => log::LevelFilter::Info, + 2 => log::LevelFilter::Debug, + _3_or_more => log::LevelFilter::Trace, }; - let syn_loader_conf = helix_core::config::user_syntax_loader().unwrap_or_else(|err| { - eprintln!("Bad language config: {}", err); - eprintln!("Press to continue with default language config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - helix_core::config::default_syntax_loader() - }); + #[cfg(feature = "integration")] + let logger: fern::Output = std::io::stdout().into(); - // TODO: use the thread local executor to spawn the application task separately from the work pool - let mut app = Application::new(args, config, syn_loader_conf) - .context("unable to create new application")?; + #[cfg(not(feature = "integration"))] + let logger: fern::Output = { + helix_loader::setup_log_file(_logpath); + fern::log_file(helix_loader::log_file())?.into() + }; - let exit_code = app.run(&mut EventStream::new()).await?; + fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} {} [{}] {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), + record.target(), + record.level(), + message + )) + }) + .level(log_level) + .chain(logger) + .apply() + .map_err(|err| anyhow::anyhow!(err)) +} - Ok(exit_code) +fn check_config_load( + user_load_result: Result, + alt_to_default: Option, + cfg_type: &str, +) -> T { + user_load_result.unwrap_or_else(|err| { + eprintln!("Bad {} config: {}", cfg_type, err); + eprintln!("Press to continue with default {} config", cfg_type); + let _wait_for_enter = std::io::Read::read(&mut std::io::stdin(), &mut []); + match alt_to_default { + Some(alt) => alt, + None => T::default(), + } + }) } diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index a24da20a9fac..3bcb46095564 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -412,7 +412,7 @@ impl Component for Completion { (None, Some(doc)) => doc.to_string(), (None, None) => String::new(), }; - Markdown::new(md, cx.editor.syn_loader.clone()) + Markdown::new(md, cx.editor.lang_configs_loader.clone()) }; let mut markdown_doc = match &option.documentation { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 59f371bda5dc..d31c51dd3301 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -3,7 +3,7 @@ use crate::{ compositor::{Component, Context, Event, EventResult}, job::{self, Callback}, key, - keymap::{KeymapResult, Keymaps}, + keymap::{Keymap, KeymapResult}, ui::{ document::{render_document, LinePos, TextRenderer, TranslatedPosition}, Completion, ProgressSpinners, @@ -36,7 +36,7 @@ use super::statusline; use super::{document::LineDecoration, lsp::SignatureHelp}; pub struct EditorView { - pub keymaps: Keymaps, + pub keymap: Keymap, on_next_key: Option, pseudo_pending: Vec, last_insert: (commands::MappableCommand, Vec), @@ -51,16 +51,10 @@ pub enum InsertEvent { TriggerCompletion, } -impl Default for EditorView { - fn default() -> Self { - Self::new(Keymaps::default()) - } -} - impl EditorView { - pub fn new(keymaps: Keymaps) -> Self { + pub fn new(keymap: Keymap) -> Self { Self { - keymaps, + keymap, on_next_key: None, pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), @@ -798,9 +792,13 @@ impl EditorView { event: KeyEvent, ) -> Option { let mut last_mode = mode; - self.pseudo_pending.extend(self.keymaps.pending()); - let key_result = self.keymaps.get(mode, event); - cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); + self.pseudo_pending.extend(self.keymap.pending()); + let key_result = self.keymap.get(mode, event); + let sort_infobox = cxt.editor.config().sorted_infobox; + cxt.editor.autoinfo = self + .keymap + .sticky_keytrie() + .map(|node| node.infobox(sort_infobox)); let mut execute_command = |command: &commands::MappableCommand| { command.execute(cxt); @@ -839,8 +837,8 @@ impl EditorView { KeymapResult::Matched(command) => { execute_command(command); } - KeymapResult::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), - KeymapResult::MatchedSequence(commands) => { + KeymapResult::Pending(node) => cxt.editor.autoinfo = Some(node.infobox(sort_infobox)), + KeymapResult::MatchedCommandSequence(commands) => { for command in commands { execute_command(command); } @@ -864,7 +862,7 @@ impl EditorView { Some(ch) => commands::insert::insert_char(cx, ch), None => { if let KeymapResult::Matched(command) = - self.keymaps.get(Mode::Insert, ev) + self.keymap.get(Mode::Insert, ev) { command.execute(cx); } @@ -886,7 +884,7 @@ impl EditorView { std::num::NonZeroUsize::new(cxt.editor.count.map_or(i, |c| c.get() * 10 + i)); } // special handling for repeat operator - (key!('.'), _) if self.keymaps.pending().is_empty() => { + (key!('.'), _) if self.keymap.pending().is_empty() => { for _ in 0..cxt.editor.count.map_or(1, NonZeroUsize::into) { // first execute whatever put us into insert mode self.last_insert.0.execute(cxt); @@ -933,7 +931,7 @@ impl EditorView { cxt.register = cxt.editor.selected_register.take(); self.handle_keymap_event(mode, cxt, event); - if self.keymaps.pending().is_empty() { + if self.keymap.pending().is_empty() { cxt.editor.count = None } } @@ -1394,7 +1392,7 @@ impl Component for EditorView { if let Some(count) = cx.editor.count { disp.push_str(&count.to_string()) } - for key in self.keymaps.pending() { + for key in self.keymap.pending() { disp.push_str(&key.key_sequence_format()); } for key in &self.pseudo_pending { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index d7717f8cf59c..98d9c17aff84 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -158,7 +158,10 @@ pub fn regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { +pub fn file_picker( + root: PathBuf, + config: &helix_view::editor::EditorConfig, +) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time::Instant; @@ -239,8 +242,8 @@ pub mod completers { use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; use helix_view::document::SCRATCH_BUFFER_NAME; - use helix_view::theme; - use helix_view::{editor::Config, Editor}; + use helix_view::Theme; + use helix_view::{editor::EditorConfig, Editor}; use once_cell::sync::Lazy; use std::borrow::Cow; use std::cmp::Reverse; @@ -280,16 +283,7 @@ pub mod completers { } pub fn theme(_editor: &Editor, input: &str) -> Vec { - let mut names = theme::Loader::read_names(&helix_loader::runtime_dir().join("themes")); - names.extend(theme::Loader::read_names( - &helix_loader::config_dir().join("themes"), - )); - names.push("default".into()); - names.push("base16_default".into()); - names.sort(); - names.dedup(); - - let mut names: Vec<_> = names + let mut names: Vec<_> = Theme::read_names() .into_iter() .map(|name| ((0..), Cow::from(name))) .collect(); @@ -330,7 +324,7 @@ pub mod completers { pub fn setting(_editor: &Editor, input: &str) -> Vec { static KEYS: Lazy> = Lazy::new(|| { let mut keys = Vec::new(); - let json = serde_json::json!(Config::default()); + let json = serde_json::json!(EditorConfig::default()); get_keys(&json, &mut keys, None); keys }); @@ -367,7 +361,7 @@ pub mod completers { let text: String = "text".into(); let language_ids = editor - .syn_loader + .lang_configs_loader .language_configs() .map(|config| &config.language_id) .chain(std::iter::once(&text)); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 803e2d65bb78..fc32cd4923b3 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -222,7 +222,7 @@ impl FilePicker { // Then attempt to highlight it if it has no language set if let Some(doc) = doc { if doc.language_config().is_none() { - let loader = cx.editor.syn_loader.clone(); + let loader = cx.editor.lang_configs_loader.clone(); doc.detect_language(loader); } } diff --git a/helix-term/tests/integration.rs b/helix-term/tests/integration.rs index a378af7a9b25..4780e832b1e0 100644 --- a/helix-term/tests/integration.rs +++ b/helix-term/tests/integration.rs @@ -4,8 +4,11 @@ mod test { use std::path::PathBuf; - use helix_core::{syntax::AutoPairConfig, Position, Selection}; - use helix_term::{args::Args, config::Config}; + use helix_core::{syntax::AutoPairConfig, Selection}; + use helix_term::{ + args::{Args, PositionRequest}, + config::Config, + }; use indoc::indoc; diff --git a/helix-term/tests/test/auto_indent.rs b/helix-term/tests/test/auto_indent.rs index 2d9082853dcf..1a2c1788633a 100644 --- a/helix-term/tests/test/auto_indent.rs +++ b/helix-term/tests/test/auto_indent.rs @@ -4,7 +4,7 @@ use super::*; async fn auto_indent_c() -> anyhow::Result<()> { test_with_config( Args { - files: vec![(PathBuf::from("foo.c"), Position::default())], + files: vec![(PathBuf::from("foo.c"), PositionRequest::default())], ..Default::default() }, helpers::test_config(), diff --git a/helix-term/tests/test/auto_pairs.rs b/helix-term/tests/test/auto_pairs.rs index e18c71195fb4..4b3d83d89872 100644 --- a/helix-term/tests/test/auto_pairs.rs +++ b/helix-term/tests/test/auto_pairs.rs @@ -1,4 +1,5 @@ use helix_core::{auto_pairs::DEFAULT_PAIRS, hashmap}; +use helix_view::editor::EditorConfig; use super::*; @@ -32,7 +33,7 @@ async fn insert_configured_multi_byte_chars() -> anyhow::Result<()> { let pairs = hashmap!('„' => '“', '‚' => '‘', '「' => '」'); let config = Config { - editor: helix_view::editor::Config { + editor: EditorConfig { auto_pairs: AutoPairConfig::Pairs(pairs.clone()), ..Default::default() }, @@ -172,7 +173,7 @@ async fn insert_auto_pairs_disabled() -> anyhow::Result<()> { test_with_config( Args::default(), Config { - editor: helix_view::editor::Config { + editor: EditorConfig { auto_pairs: AutoPairConfig::Enable(false), ..Default::default() }, diff --git a/helix-term/tests/test/helpers.rs b/helix-term/tests/test/helpers.rs index fb12ef12cff2..a0c54a7382fe 100644 --- a/helix-term/tests/test/helpers.rs +++ b/helix-term/tests/test/helpers.rs @@ -7,9 +7,11 @@ use std::{ use anyhow::bail; use crossterm::event::{Event, KeyEvent}; -use helix_core::{diagnostic::Severity, test, Selection, Transaction}; -use helix_term::{application::Application, args::Args, config::Config, keymap::merge_keys}; -use helix_view::{doc, editor::LspConfig, input::parse_macro, Editor}; +use helix_core::{ + diagnostic::Severity, syntax::LanguageConfigurations, test, Selection, Transaction, +}; +use helix_term::{application::Application, args::Args, config::Config}; +use helix_view::{doc, input::parse_macro, Editor, Theme}; use tempfile::NamedTempFile; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -118,7 +120,12 @@ pub async fn test_key_sequence_with_input_text>( let test_case = test_case.into(); let mut app = match app { Some(app) => app, - None => Application::new(Args::default(), test_config(), test_syntax_conf(None))?, + None => Application::new( + Args::default(), + Theme::default(), + test_syntax_conf(None), + test_config(), + )?, }; let (view, doc) = helix_view::current!(app.editor); @@ -143,8 +150,15 @@ pub async fn test_key_sequence_with_input_text>( /// Generates language configs that merge in overrides, like a user language /// config. The argument string must be a raw TOML document. -pub fn test_syntax_conf(overrides: Option) -> helix_core::syntax::Configuration { - let mut lang = helix_loader::config::default_lang_config(); +/// +/// By default, language server configuration is dropped from the languages.toml +/// document. If a language-server is necessary for a test, it must be explicitly +/// added in `overrides`. +pub fn test_syntax_conf(overrides: Option) -> LanguageConfigurations { + let mut lang: toml::Value = toml::from_str( + &std::fs::read_to_string(helix_loader::repo_paths::default_lang_configs()).unwrap(), + ) + .unwrap(); if let Some(overrides) = overrides { let override_toml = toml::from_str(&overrides).unwrap(); @@ -154,18 +168,21 @@ pub fn test_syntax_conf(overrides: Option) -> helix_core::syntax::Config lang.try_into().unwrap() } +static SETUP: std::sync::Once = std::sync::Once::new(); /// Use this for very simple test cases where there is one input /// document, selection, and sequence of key presses, and you just /// want to verify the resulting document and selection. pub async fn test_with_config>( args: Args, - mut config: Config, - syn_conf: helix_core::syntax::Configuration, + config: Config, + syn_conf: LanguageConfigurations, test_case: T, ) -> anyhow::Result<()> { + SETUP.call_once(|| { + Theme::set_true_color_support(true); + }); let test_case = test_case.into(); - config = helix_term::keymap::merge_keys(config); - let app = Application::new(args, config, syn_conf)?; + let app = Application::new(args, Theme::default(), syn_conf, config)?; test_key_sequence_with_input_text( Some(app), @@ -211,16 +228,9 @@ pub fn temp_file_with_contents>( /// Generates a config with defaults more suitable for integration tests pub fn test_config() -> Config { - merge_keys(Config { - editor: helix_view::editor::Config { - lsp: LspConfig { - enable: false, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }) + let mut config = Config::default(); + config.editor.lsp.enable = false; + config } /// Replaces all LF chars with the system's appropriate line feed @@ -254,7 +264,7 @@ pub fn new_readonly_tempfile() -> anyhow::Result { pub struct AppBuilder { args: Args, config: Config, - syn_conf: helix_core::syntax::Configuration, + syn_conf: LanguageConfigurations, input: Option<(String, Selection)>, } @@ -279,7 +289,9 @@ impl AppBuilder { path: P, pos: Option, ) -> Self { - self.args.files.push((path.into(), pos.unwrap_or_default())); + self.args + .files + .push((path.into(), pos.unwrap_or_default().into())); self } @@ -295,13 +307,13 @@ impl AppBuilder { self } - pub fn with_lang_config(mut self, syn_conf: helix_core::syntax::Configuration) -> Self { + pub fn with_lang_config(mut self, syn_conf: LanguageConfigurations) -> Self { self.syn_conf = syn_conf; self } pub fn build(self) -> anyhow::Result { - let mut app = Application::new(self.args, self.config, self.syn_conf)?; + let mut app = Application::new(self.args, Theme::default(), self.syn_conf, self.config)?; if let Some((text, selection)) = self.input { let (view, doc) = helix_view::current!(app.editor); diff --git a/helix-term/tests/test/movement.rs b/helix-term/tests/test/movement.rs index e6ea3f951592..649af197066c 100644 --- a/helix-term/tests/test/movement.rs +++ b/helix-term/tests/test/movement.rs @@ -414,7 +414,7 @@ async fn cursor_position_append_eof() -> anyhow::Result<()> { async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow::Result<()> { test_with_config( Args { - files: vec![(PathBuf::from("foo.rs"), Position::default())], + files: vec![(PathBuf::from("foo.rs"), PositionRequest::default())], ..Default::default() }, Config::default(), @@ -446,7 +446,7 @@ async fn select_mode_tree_sitter_next_function_is_union_of_objects() -> anyhow:: async fn select_mode_tree_sitter_prev_function_unselects_object() -> anyhow::Result<()> { test_with_config( Args { - files: vec![(PathBuf::from("foo.rs"), Position::default())], + files: vec![(PathBuf::from("foo.rs"), PositionRequest::default())], ..Default::default() }, Config::default(), @@ -479,7 +479,7 @@ async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> any // Note: the anchor stays put and the head moves back. test_with_config( Args { - files: vec![(PathBuf::from("foo.rs"), Position::default())], + files: vec![(PathBuf::from("foo.rs"), PositionRequest::default())], ..Default::default() }, Config::default(), @@ -510,7 +510,7 @@ async fn select_mode_tree_sitter_prev_function_goes_backwards_to_object() -> any test_with_config( Args { - files: vec![(PathBuf::from("foo.rs"), Position::default())], + files: vec![(PathBuf::from("foo.rs"), PositionRequest::default())], ..Default::default() }, Config::default(), diff --git a/helix-tui/src/terminal.rs b/helix-tui/src/terminal.rs index 22e9232f3f86..ef365d68c003 100644 --- a/helix-tui/src/terminal.rs +++ b/helix-tui/src/terminal.rs @@ -139,7 +139,7 @@ where /// Queries the backend for size and resizes if it doesn't match the previous size. pub fn autoresize(&mut self) -> io::Result { - let size = self.size()?; + let size = self.size(); if size != self.viewport.area { self.resize(size)?; }; @@ -219,7 +219,7 @@ where } /// Queries the real size of the backend. - pub fn size(&self) -> io::Result { - self.backend.size() + pub fn size(&self) -> Rect { + self.backend.size().expect("couldn't get terminal size") } } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 579c6725063b..555007e03bc3 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -31,7 +31,7 @@ use helix_core::{ DEFAULT_LINE_ENDING, }; -use crate::editor::{Config, RedrawHandle}; +use crate::editor::{EditorConfig, RedrawHandle}; use crate::{DocumentId, Editor, Theme, View, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. @@ -134,7 +134,7 @@ pub struct Document { // it back as it separated from the edits. We could split out the parts manually but that will // be more troublesome. pub history: Cell, - pub config: Arc>, + pub config: Arc>, pub savepoint: Option, @@ -367,7 +367,7 @@ impl Document { pub fn from( text: Rope, encoding: Option<&'static encoding::Encoding>, - config: Arc>, + config: Arc>, ) -> Self { let encoding = encoding.unwrap_or(encoding::UTF_8); let changes = ChangeSet::new(&text); @@ -398,7 +398,7 @@ impl Document { config, } } - pub fn default(config: Arc>) -> Self { + pub fn default(config: Arc>) -> Self { let text = Rope::from(DEFAULT_LINE_ENDING.as_str()); Self::from(text, None, config) } @@ -408,8 +408,8 @@ impl Document { pub fn open( path: &Path, encoding: Option<&'static encoding::Encoding>, - config_loader: Option>, - config: Arc>, + lang_configs_loader: Option>, + config: Arc>, ) -> Result { // Open the file if it exists, otherwise assume it is a new file (and thus empty). let (rope, encoding) = if path.exists() { @@ -425,7 +425,7 @@ impl Document { // set the path and try detecting the language doc.set_path(Some(path))?; - if let Some(loader) = config_loader { + if let Some(loader) = lang_configs_loader { doc.detect_language(loader); } @@ -1313,7 +1313,7 @@ mod test { let mut doc = Document::from( text, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); let view = ViewId::default(); doc.set_selection(view, Selection::single(0, 0)); @@ -1351,7 +1351,7 @@ mod test { let mut doc = Document::from( text, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); let view = ViewId::default(); doc.set_selection(view, Selection::single(5, 5)); @@ -1465,7 +1465,7 @@ mod test { #[test] fn test_line_ending() { assert_eq!( - Document::default(Arc::new(ArcSwap::new(Arc::new(Config::default())))) + Document::default(Arc::new(ArcSwap::new(Arc::new(EditorConfig::default())))) .text() .to_string(), DEFAULT_LINE_ENDING.as_str() diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 50da3ddeac2d..616e1d587c54 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -5,7 +5,7 @@ use crate::{ graphics::{CursorKind, Rect}, info::Info, input::KeyEvent, - theme::{self, Theme}, + theme::Theme, tree::{self, Tree}, view::ViewPosition, Align, Document, DocumentId, View, ViewId, @@ -212,7 +212,7 @@ impl Default for FilePickerConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct Config { +pub struct EditorConfig { /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. pub scrolloff: usize, /// Number of lines to scroll at once. Defaults to 3 @@ -241,6 +241,8 @@ pub struct Config { pub auto_format: bool, /// Automatic save on focus lost. Defaults to false. pub auto_save: bool, + pub load_local_config: bool, + //pub load_local_languages: bool, //TODO: implement /// Time in milliseconds since last keypress before idle timers trigger. /// Used for autocompletion, set to 0 for instant. Defaults to 400ms. #[serde( @@ -251,6 +253,8 @@ pub struct Config { pub completion_trigger_len: u8, /// Whether to display infoboxes. Defaults to true. pub auto_info: bool, + /// Sort infoboxes alphabetically rather than by predefined categories. Defaults to `false`. + pub sorted_infobox: bool, pub file_picker: FilePickerConfig, /// Configuration of the statusline elements pub statusline: StatusLineConfig, @@ -261,6 +265,7 @@ pub struct Config { /// Search configuration. #[serde(default)] pub search: SearchConfig, + /// Security settings (i.e. loading TOML files from $PWD/.helix) pub lsp: LspConfig, pub terminal: Option, /// Column numbers at which to draw the rulers. Default to `[]`, meaning no rulers. @@ -451,52 +456,36 @@ impl Default for ModeConfig { pub enum StatusLineElement { /// The editor mode (Normal, Insert, Visual/Selection) Mode, - /// The LSP activity spinner Spinner, - /// The base file name, including a dirty flag if it's unsaved FileBaseName, - /// The relative file path, including a dirty flag if it's unsaved FileName, - // The file modification indicator FileModificationIndicator, - /// The file encoding FileEncoding, - /// The file line endings (CRLF or LF) FileLineEnding, - /// The file type (language ID or "text") FileType, - /// A summary of the number of errors and warnings Diagnostics, - /// A summary of the number of errors and warnings on file and workspace WorkspaceDiagnostics, - /// The number of selections (cursors) Selections, - /// The number of characters currently in primary selection PrimarySelectionLength, - /// The cursor position Position, - /// The separator string Separator, - /// The cursor position as a percent of the total file PositionPercentage, - /// The total line numbers of the current file TotalLineNumbers, - /// A single space Spacer, } @@ -736,7 +725,7 @@ impl Default for IndentGuidesConfig { } } -impl Default for Config { +impl Default for EditorConfig { fn default() -> Self { Self { scrolloff: 5, @@ -756,6 +745,7 @@ impl Default for Config { auto_completion: true, auto_format: true, auto_save: false, + load_local_config: false, idle_timeout: Duration::from_millis(400), completion_trigger_len: 2, auto_info: true, @@ -771,6 +761,7 @@ impl Default for Config { bufferline: BufferLine::default(), indent_guides: IndentGuidesConfig::default(), color_modes: false, + sorted_infobox: false, soft_wrap: SoftWrap::default(), } } @@ -840,8 +831,7 @@ pub struct Editor { pub clipboard_provider: Box, - pub syn_loader: Arc, - pub theme_loader: Arc, + pub lang_configs_loader: Arc, /// last_theme is used for theme previews. We store the current theme here, /// and if previewing is cancelled, we can return to it. pub last_theme: Option, @@ -852,7 +842,7 @@ pub struct Editor { pub status_msg: Option<(Cow<'static, str>, Severity)>, pub autoinfo: Option, - pub config: Arc>, + pub config: Arc>, pub auto_pairs: Option, pub idle_timer: Pin>, @@ -897,7 +887,7 @@ pub enum EditorEvent { #[derive(Debug, Clone)] pub enum ConfigEvent { Refresh, - Update(Box), + Update(Box), } enum ThemeAction { @@ -932,17 +922,17 @@ pub enum CloseError { impl Editor { pub fn new( mut area: Rect, - theme_loader: Arc, - syn_loader: Arc, - config: Arc>, + config: Arc>, + theme: Theme, + lang_configs_loader: Arc, ) -> Self { let conf = config.load(); - let auto_pairs = (&conf.auto_pairs).into(); // HAXX: offset the render area height by 1 to account for prompt/commandline area.height -= 1; - Self { + // TEMP: until its decided on what to do with set_theme + let mut editor_temp = Self { mode: Mode::Normal, tree: Tree::new(area), next_document_id: DocumentId::default(), @@ -954,15 +944,14 @@ impl Editor { selected_register: None, macro_recording: None, macro_replaying: Vec::new(), - theme: theme_loader.default(), + theme: theme.clone(), language_servers: helix_lsp::Registry::new(), diagnostics: BTreeMap::new(), diff_providers: DiffProviderRegistry::default(), debugger: None, debugger_events: SelectAll::new(), breakpoints: HashMap::new(), - syn_loader, - theme_loader, + lang_configs_loader, last_theme: None, last_line_number: None, registers: Registers::default(), @@ -973,13 +962,15 @@ impl Editor { last_motion: None, last_completion: None, config, - auto_pairs, + auto_pairs: (&conf.auto_pairs).into(), exit_code: 0, config_events: unbounded_channel(), redraw_handle: Default::default(), needs_redraw: false, cursor_cache: Cell::new(None), - } + }; + editor_temp.set_theme(theme); + editor_temp } /// Current editing mode for the [`Editor`]. @@ -987,7 +978,7 @@ impl Editor { self.mode } - pub fn config(&self) -> DynGuard { + pub fn config(&self) -> DynGuard { self.config.load() } @@ -1068,9 +1059,7 @@ impl Editor { return; } - let scopes = theme.scopes(); - self.syn_loader.set_scopes(scopes.to_vec()); - + self.lang_configs_loader.set_scopes(theme.scopes().to_vec()); match preview { ThemeAction::Preview => { let last_theme = std::mem::replace(&mut self.theme, theme); @@ -1306,7 +1295,7 @@ impl Editor { let mut doc = Document::open( &path, None, - Some(self.syn_loader.clone()), + Some(self.lang_configs_loader.clone()), self.config.clone(), )?; diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index cb9e43336f40..08903463223d 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -317,7 +317,7 @@ mod tests { use super::*; use crate::document::Document; - use crate::editor::{Config, GutterConfig, GutterLineNumbersConfig}; + use crate::editor::{EditorConfig, GutterConfig, GutterLineNumbersConfig}; use crate::graphics::Rect; use crate::DocumentId; use arc_swap::ArcSwap; @@ -332,7 +332,7 @@ mod tests { let doc = Document::from( rope, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); assert_eq!(view.gutters.layout.len(), 5); @@ -357,7 +357,7 @@ mod tests { let doc = Document::from( rope, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); assert_eq!(view.gutters.layout.len(), 1); @@ -375,7 +375,7 @@ mod tests { let doc = Document::from( rope, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); assert_eq!(view.gutters.layout.len(), 2); @@ -397,14 +397,14 @@ mod tests { let doc_short = Document::from( rope, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); let rope = Rope::from_str("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np"); let doc_long = Document::from( rope, None, - Arc::new(ArcSwap::new(Arc::new(Config::default()))), + Arc::new(ArcSwap::new(Arc::new(EditorConfig::default()))), ); assert_eq!(view.gutters.layout.len(), 2); diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs index 3080cf8e1b2f..1503e855e362 100644 --- a/helix-view/src/info.rs +++ b/helix-view/src/info.rs @@ -1,6 +1,5 @@ -use crate::input::KeyEvent; use helix_core::{register::Registers, unicode::width::UnicodeWidthStr}; -use std::{collections::BTreeSet, fmt::Write}; +use std::fmt::Write; #[derive(Debug)] /// Info box used in editor. Rendering logic will be in other crate. @@ -55,18 +54,6 @@ impl Info { } } - pub fn from_keymap(title: &str, body: Vec<(&str, BTreeSet)>) -> Self { - let body: Vec<_> = body - .into_iter() - .map(|(desc, events)| { - let events = events.iter().map(ToString::to_string).collect::>(); - (events.join(", "), desc) - }) - .collect(); - - Self::new(title, &body) - } - pub fn from_registers(registers: &Registers) -> Self { let body: Vec<_> = registers .inner() diff --git a/helix-view/src/keyboard.rs b/helix-view/src/keyboard.rs index 04a9922a99bb..14d4c6c8b1fa 100644 --- a/helix-view/src/keyboard.rs +++ b/helix-view/src/keyboard.rs @@ -1,11 +1,10 @@ use bitflags::bitflags; bitflags! { - /// Represents key modifiers (shift, control, alt). pub struct KeyModifiers: u8 { const SHIFT = 0b0000_0001; - const CONTROL = 0b0000_0010; - const ALT = 0b0000_0100; + const ALT = 0b0000_0010; + const CONTROL = 0b0000_0100; const NONE = 0b0000_0000; } } @@ -55,31 +54,18 @@ impl From for KeyModifiers { /// Represents a media key (as part of [`KeyCode::Media`]). #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Clone, Copy, Hash)] pub enum MediaKeyCode { - /// Play media key. Play, - /// Pause media key. Pause, - /// Play/Pause media key. PlayPause, - /// Reverse media key. Reverse, - /// Stop media key. Stop, - /// Fast-forward media key. FastForward, - /// Rewind media key. Rewind, - /// Next-track media key. TrackNext, - /// Previous-track media key. TrackPrevious, - /// Record media key. Record, - /// Lower-volume media key. LowerVolume, - /// Raise-volume media key. RaiseVolume, - /// Mute media key. MuteVolume, } @@ -132,33 +118,19 @@ impl From for MediaKeyCode { /// Represents a media key (as part of [`KeyCode::Modifier`]). #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Clone, Copy, Hash)] pub enum ModifierKeyCode { - /// Left Shift key. LeftShift, - /// Left Control key. LeftControl, - /// Left Alt key. LeftAlt, - /// Left Super key. LeftSuper, - /// Left Hyper key. LeftHyper, - /// Left Meta key. LeftMeta, - /// Right Shift key. RightShift, - /// Right Control key. RightControl, - /// Right Alt key. RightAlt, - /// Right Super key. RightSuper, - /// Right Hyper key. RightHyper, - /// Right Meta key. RightMeta, - /// Iso Level3 Shift key. IsoLevel3Shift, - /// Iso Level5 Shift key. IsoLevel5Shift, } @@ -210,64 +182,34 @@ impl From for ModifierKeyCode { } } -/// Represents a key. +/// Variant order determines order in keymap infobox if sorted_infobox is set to true. #[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Clone, Copy, Hash)] pub enum KeyCode { - /// Backspace key. - Backspace, - /// Enter key. - Enter, - /// Left arrow key. - Left, - /// Right arrow key. - Right, - /// Up arrow key. + Char(char), + F(u8), Up, - /// Down arrow key. Down, - /// Home key. + Left, + Right, + Enter, + Esc, + Tab, + Backspace, + Insert, + Delete, Home, - /// End key. End, - /// Page up key. PageUp, - /// Page down key. PageDown, - /// Tab key. - Tab, - /// Delete key. - Delete, - /// Insert key. - Insert, - /// F key. - /// - /// `KeyCode::F(1)` represents F1 key, etc. - F(u8), - /// A character. - /// - /// `KeyCode::Char('c')` represents `c` character, etc. - Char(char), - /// Null. Null, - /// Escape key. - Esc, - /// CapsLock key. CapsLock, - /// ScrollLock key. ScrollLock, - /// NumLock key. NumLock, - /// PrintScreen key. - PrintScreen, - /// Pause key. - Pause, - /// Menu key. Menu, - /// KeypadBegin key. + Pause, + PrintScreen, KeypadBegin, - /// A media key. Media(MediaKeyCode), - /// A modifier key. Modifier(ModifierKeyCode), } diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index ce061babead8..cbac83879455 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -1,12 +1,12 @@ use std::{ - collections::HashMap, - path::{Path, PathBuf}, + collections::{HashMap, HashSet}, + path::PathBuf, str, }; use anyhow::{anyhow, Result}; use helix_core::hashmap; -use helix_loader::merge_toml_values; +use helix_loader::{merge_toml_values, repo_paths}; use log::warn; use once_cell::sync::Lazy; use serde::{Deserialize, Deserializer}; @@ -16,13 +16,13 @@ use crate::graphics::UnderlineStyle; pub use crate::graphics::{Color, Modifier, Style}; pub static DEFAULT_THEME_DATA: Lazy = Lazy::new(|| { - let bytes = include_bytes!("../../theme.toml"); - toml::from_str(str::from_utf8(bytes).unwrap()).expect("Failed to parse base default theme") + toml::from_str(&std::fs::read_to_string(repo_paths::default_theme()).unwrap()) + .expect("Failed to parse default theme") }); pub static BASE16_DEFAULT_THEME_DATA: Lazy = Lazy::new(|| { - let bytes = include_bytes!("../../base16_theme.toml"); - toml::from_str(str::from_utf8(bytes).unwrap()).expect("Failed to parse base 16 default theme") + toml::from_str(&std::fs::read_to_string(repo_paths::default_base16_theme()).unwrap()) + .expect("Failed to parse base 16 default theme") }); pub static DEFAULT_THEME: Lazy = Lazy::new(|| Theme { @@ -35,48 +35,75 @@ pub static BASE16_DEFAULT_THEME: Lazy = Lazy::new(|| Theme { ..Theme::from(BASE16_DEFAULT_THEME_DATA.clone()) }); +static TRUE_COLOR_SUPPORT: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); + #[derive(Clone, Debug)] -pub struct Loader { - user_dir: PathBuf, - default_dir: PathBuf, +pub struct Theme { + name: String, + // UI styles are stored in a HashMap + styles: HashMap, + // tree-sitter highlight styles are stored in a Vec to optimize lookups + scopes: Vec, + highlights: Vec