diff --git a/Cargo.lock b/Cargo.lock index e97daba35..22ea56404 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,11 +1053,15 @@ version = "23.10.0" dependencies = [ "ahash", "anyhow", + "globset", "hashbrown 0.14.3", "indexmap", "parking_lot", + "regex", + "regex-syntax", "serde", "serde_json", + "which", ] [[package]] @@ -1102,6 +1106,7 @@ version = "23.10.0" dependencies = [ "anyhow", "fern", + "helix-config", "helix-core", "log", "serde", @@ -1250,6 +1255,7 @@ dependencies = [ "clipboard-win", "crossterm", "futures-util", + "helix-config", "helix-core", "helix-dap", "helix-event", diff --git a/helix-config/Cargo.toml b/helix-config/Cargo.toml index ba9bbb5d8..86bcceaf4 100644 --- a/helix-config/Cargo.toml +++ b/helix-config/Cargo.toml @@ -19,6 +19,10 @@ anyhow = "1.0.79" indexmap = { version = "2.1.0", features = ["serde"] } serde = { version = "1.0" } serde_json = "1.0" +globset = "0.4.14" +regex = "1.10.2" +regex-syntax = "0.8.2" +which = "5.0.0" regex-syntax = "0.8.2" which = "5.0.0" diff --git a/helix-config/src/definition.rs b/helix-config/src/definition.rs new file mode 100644 index 000000000..b51f2d750 --- /dev/null +++ b/helix-config/src/definition.rs @@ -0,0 +1,113 @@ +use std::time::Duration; + +use crate::*; + +mod language; +mod lsp; +mod ui; + +pub use lsp::init_language_server_config; + +options! { + use ui::*; + use lsp::*; + use language::*; + + struct WrapConfig { + /// Soft wrap lines that exceed viewport width. + enable: bool = false, + /// Maximum free space left at the end of the line. + /// Automatically limited to a quarter of the viewport. + max_wrap: u16 = 20, + /// Maximum indentation to carry over when soft wrapping a line. + /// Automatically limited to a quarter of the viewport. + max_indent_retain: u16 = 40, + /// Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap`. + wrap_indicator: String = "↪", + /// Soft wrap at `text-width` instead of using the full viewport size. + wrap_at_text_width: bool = false, + /// Maximum line length. Used for the `:reflow` command and + /// soft-wrapping if `soft-wrap.wrap-at-text-width` is set + text_width: usize = 80, + } + + struct MouseConfig { + /// Enable mouse mode + #[read = copy] + mouse: bool = true, + /// Number of lines to scroll per scroll wheel step. + #[read = copy] + scroll_lines: usize = 3, + /// Middle click paste support + #[read = copy] + middle_click_paste: bool = true, + } + struct SmartTabConfig { + /// If set to true, then when the cursor is in a position with + /// non-whitespace to its left, instead of inserting a tab, it will run + /// `move_parent_node_end`. If there is only whitespace to the left, + /// then it inserts a tab as normal. With the default bindings, to + /// explicitly insert a tab character, press Shift-tab. + #[name = "smart-tab.enable"] + #[read = copy] + enable: bool = true, + /// Normally, when a menu is on screen, such as when auto complete + /// is triggered, the tab key is bound to cycling through the items. + /// This means when menus are on screen, one cannot use the tab key + /// to trigger the `smart-tab` command. If this option is set to true, + /// the `smart-tab` command always takes precedence, which means one + /// cannot use the tab key to cycle through menu items. One of the other + /// bindings must be used instead, such as arrow keys or `C-n`/`C-p`. + #[name = "smart-tab.supersede-menu"] + #[read = copy] + supersede_menu: bool = false, + } + + struct SearchConfig { + /// Enable smart case regex searching (case-insensitive unless pattern + /// contains upper case characters) + #[name = "search.smart-case"] + #[read = copy] + smart_case: bool = true, + /// Whether the search should wrap after depleting the matches + #[name = "search.wrap-round"] + #[read = copy] + wrap_round: bool = true, + } + + struct MiscConfig { + /// Number of lines of padding around the edge of the screen when scrolling. + #[read = copy] + scrolloff: usize = 5, + /// Shell to use when running external commands + #[read = deref] + shell: List = if cfg!(windows) { + &["cmd", "/C"] + } else { + &["sh", "-c"] + }, + /// Enable automatic saving on the focus moving away from Helix. + /// Requires [focus event support](https://github.com/helix-editor/ + /// helix/wiki/Terminal-Support) from your terminal + #[read = copy] + auto_save: bool = false, + /// Whether to automatically insert a trailing line-ending on write + /// if missing + #[read = copy] + insert_final_newline: bool = true, + /// Time in milliseconds since last keypress before idle timers trigger. + /// Used for autocompletion, set to 0 for instant + #[read = copy] + idle_timeout: Duration = Duration::from_millis(250), + } +} + +impl Ty for Duration { + fn from_value(val: Value) -> anyhow::Result { + let val: usize = val.typed()?; + Ok(Duration::from_millis(val as _)) + } + fn to_value(&self) -> Value { + Value::Int(self.as_millis().try_into().unwrap()) + } +} diff --git a/helix-config/src/definition/language.rs b/helix-config/src/definition/language.rs new file mode 100644 index 000000000..5042823a5 --- /dev/null +++ b/helix-config/src/definition/language.rs @@ -0,0 +1,27 @@ +use crate::*; + +options! { + struct LanguageConfig { + /// regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. + #[validator = regex_str_validator()] + injection_regex: Option = None, + /// The interpreters from the shebang line, for example `["sh", "bash"]` + #[read = deref] + shebangs: List = List::default(), + /// The token to use as a comment-token + #[read = deref] + comment_token: String = "//", + /// The tree-sitter grammar to use (defaults to the language name) + grammar: Option = None, + } + + struct FormatterConfiguration { + #[read = copy] + auto_format: bool = true, + #[name = "formatter.command"] + formatter_command: Option = None, + #[name = "formatter.args"] + #[read = deref] + formatter_args: List = List::default(), + } +} diff --git a/helix-config/src/definition/lsp.rs b/helix-config/src/definition/lsp.rs new file mode 100644 index 000000000..2c6845f49 --- /dev/null +++ b/helix-config/src/definition/lsp.rs @@ -0,0 +1,266 @@ +use std::fmt::{self, Display}; + +use serde::{Deserialize, Serialize}; + +use crate::*; + +/// Describes the severity level of a [`Diagnostic`]. +#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)] +pub enum Severity { + Hint, + Info, + Warning, + Error, +} + +impl Ty for Severity { + fn from_value(val: Value) -> anyhow::Result { + let val: String = val.typed()?; + match &*val { + "hint" => Ok(Severity::Hint), + "info" => Ok(Severity::Info), + "warning" => Ok(Severity::Warning), + "error" => Ok(Severity::Error), + _ => bail!("expected one of 'hint', 'info', 'warning' or 'error' (got {val:?})"), + } + } + + fn to_value(&self) -> Value { + match self { + Severity::Hint => "hint".into(), + Severity::Info => "info".into(), + Severity::Warning => "warning".into(), + Severity::Error => "error".into(), + } + } +} + +// TODO: move to stdx +/// Helper macro that automatically generates an array +/// that contains all variants of an enum +macro_rules! variant_list { + ( + $(#[$outer:meta])* + $vis: vis enum $name: ident { + $($(#[$inner: meta])* $variant: ident $(= $_: literal)?),*$(,)? + } + ) => { + $(#[$outer])* + $vis enum $name { + $($(#[$inner])* $variant),* + } + impl $name { + $vis const ALL: &[$name] = &[$(Self::$variant),*]; + } + } +} +variant_list! { + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + pub enum LanguageServerFeature { + Format, + GotoDeclaration, + GotoDefinition, + GotoTypeDefinition, + GotoReference, + GotoImplementation, + // Goto, use bitflags, combining previous Goto members? + SignatureHelp, + Hover, + DocumentHighlight, + Completion, + CodeAction, + WorkspaceCommand, + DocumentSymbols, + WorkspaceSymbols, + // Symbols, use bitflags, see above? + Diagnostics, + RenameSymbol, + InlayHints, + } +} + +impl LanguageServerFeature { + fn to_str(self) -> &'static str { + use LanguageServerFeature::*; + + match self { + Format => "format", + GotoDeclaration => "goto-declaration", + GotoDefinition => "goto-definition", + GotoTypeDefinition => "goto-type-definition", + GotoReference => "goto-reference", + GotoImplementation => "goto-implementation", + SignatureHelp => "signature-help", + Hover => "hover", + DocumentHighlight => "document-highlight", + Completion => "completion", + CodeAction => "code-action", + WorkspaceCommand => "workspace-command", + DocumentSymbols => "document-symbols", + WorkspaceSymbols => "workspace-symbols", + Diagnostics => "diagnostics", + RenameSymbol => "rename-symbol", + InlayHints => "inlay-hints", + } + } + fn description(self) -> &'static str { + use LanguageServerFeature::*; + + match self { + Format => "Use this language server for autoformatting.", + GotoDeclaration => "Use this language server for the goto_declaration command.", + GotoDefinition => "Use this language server for the goto_definition command.", + GotoTypeDefinition => "Use this language server for the goto_type_definition command.", + GotoReference => "Use this language server for the goto_reference command.", + GotoImplementation => "Use this language server for the goto_implementation command.", + SignatureHelp => "Use this language server to display signature help.", + Hover => "Use this language server to display hover information.", + DocumentHighlight => { + "Use this language server for the select_references_to_symbol_under_cursor command." + } + Completion => "Request completion items from this language server.", + CodeAction => "Use this language server for the code_action command.", + WorkspaceCommand => "Use this language server for :lsp-workspace-command.", + DocumentSymbols => "Use this language server for the symbol_picker command.", + WorkspaceSymbols => "Use this language server for the workspace_symbol_picker command.", + Diagnostics => "Display diagnostics emitted by this language server.", + RenameSymbol => "Use this language server for the rename_symbol command.", + InlayHints => "Display inlay hints form this language server.", + } + } +} + +impl Display for LanguageServerFeature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let feature = self.to_str(); + write!(f, "{feature}",) + } +} + +impl Debug for LanguageServerFeature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} + +impl Ty for LanguageServerFeature { + fn from_value(val: Value) -> anyhow::Result { + let val: String = val.typed()?; + use LanguageServerFeature::*; + + match &*val { + "format" => Ok(Format), + "goto-declaration" => Ok(GotoDeclaration), + "goto-definition" => Ok(GotoDefinition), + "goto-type-definition" => Ok(GotoTypeDefinition), + "goto-reference" => Ok(GotoReference), + "goto-implementation" => Ok(GotoImplementation), + "signature-help" => Ok(SignatureHelp), + "hover" => Ok(Hover), + "document-highlight" => Ok(DocumentHighlight), + "completion" => Ok(Completion), + "code-action" => Ok(CodeAction), + "workspace-command" => Ok(WorkspaceCommand), + "document-symbols" => Ok(DocumentSymbols), + "workspace-symbols" => Ok(WorkspaceSymbols), + "diagnostics" => Ok(Diagnostics), + "rename-symbol" => Ok(RenameSymbol), + "inlay-hints" => Ok(InlayHints), + _ => bail!("invalid language server feature {val}"), + } + } + + fn to_value(&self) -> Value { + Value::String(self.to_str().into()) + } +} + +pub fn init_language_server_config(registry: &mut OptionRegistry, languag_server: &str) { + registry.register( + &format!("language-servers.{languag_server}.active"), + "Wether this language servers is used for a buffer", + false, + ); + for &feature in LanguageServerFeature::ALL { + registry.register( + &format!("language-servers.{languag_server}.{feature}"), + feature.description(), + true, + ); + } +} + +options! { + struct LspConfig { + /// Enables LSP integration. Setting to false will completely disable language servers. + #[name = "lsp.enable"] + #[read = copy] + enable: bool = true, + /// Enables LSP integration. Setting to false will completely disable language servers. + #[name = "lsp.display-messages"] + #[read = copy] + display_messages: bool = false, + /// Enable automatic popup of signature help (parameter hints) + #[name = "lsp.auto-signature-help"] + #[read = copy] + auto_signature_help: bool = true, + /// Enable automatic popup of signature help (parameter hints) + #[name = "lsp.display-inlay-hints"] + #[read = copy] + display_inlay_hints: bool = false, + /// Display docs under signature help popup + #[name = "lsp.display-signature-help-docs"] + #[read = copy] + display_signature_help_docs: bool = true, + /// Enables snippet completions. Requires a server restart + /// (`:lsp-restart`) to take effect after `:config-reload`/`:set`. + #[name = "lsp.snippets"] + #[read = copy] + snippets: bool = true, + /// Include declaration in the goto references popup. + #[name = "lsp.goto-reference-include-declaration"] + #[read = copy] + goto_reference_include_declaration: bool = true, + // TODO(breaing): prefix all options below with `lsp.` + /// The language-id for language servers, checkout the + /// table at [TextDocumentItem](https://microsoft.github.io/ + /// language-server-protocol/specifications/lsp/3.17/specification/ + /// #textDocumentItem) for the right id + #[name = "languague-id"] + language_server_id: Option = None, + // TODO(breaking): rename to root-markers to differentiate from workspace-roots + // TODO: also makes this setteble on the language server + /// A set of marker files to look for when trying to find the workspace + /// root. For example `Cargo.lock`, `yarn.lock` + roots: List = List::default(), + // TODO: also makes this setteble on the language server + /// Directories relative to the workspace root that are treated as LSP + /// roots. The search for root markers (starting at the path of the + /// file) will stop at these paths. + #[name = "workspace-lsp-roots"] + workspace_roots: List = List::default(), + /// An array of LSP diagnostic sources assumed unchanged when the + /// language server resends the same set of diagnostics. Helix can track + /// the position for these diagnostics internally instead. Useful for + /// diagnostics that are recomputed on save. + persistent_diagnostic_sources: List = List::default(), + /// Minimal severity of diagnostic for it to be displayed. (Allowed + /// values: `error`, `warning`, `info`, `hint`) + diagnostic_severity: Severity = Severity::Hint, + } + + struct CompletionConfig { + /// Automatic auto-completion, automatically pop up without user trigger. + #[read = copy] + auto_completion: bool = true, + /// Whether to apply completion item instantly when selected + #[read = copy] + preview_completion_insert: bool = true, + /// Whether to apply completion item instantly when selected + #[read = copy] + completion_replace: bool = false, + /// Whether to apply completion item instantly when selected + #[read = copy] + completion_trigger_len: u8 = 2, + } +} diff --git a/helix-config/src/definition/ui.rs b/helix-config/src/definition/ui.rs new file mode 100644 index 000000000..378d4e059 --- /dev/null +++ b/helix-config/src/definition/ui.rs @@ -0,0 +1,291 @@ +use serde::{Deserialize, Serialize}; + +use crate::*; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StatusLineElement { + /// The editor mode (Normal, Insert, Visual/Selection) + Mode, + /// The LSP activity spinner + Spinner, + /// The file basename (the leaf of the open file's path) + FileBaseName, + /// The relative file path + FileName, + // The file modification indicator + FileModificationIndicator, + /// An indicator that shows `"[readonly]"` when a file cannot be written + ReadOnlyIndicator, + /// 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, + /// Current version control information + VersionControl, + /// Indicator for selected register + Register, +} + +config_serde_adapter!(StatusLineElement); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +/// UNSTABLE +pub enum CursorKind { + /// █ + Block, + /// | + Bar, + /// _ + Underline, + /// Hidden cursor, can set cursor position with this to let IME have correct cursor position. + Hidden, +} + +impl Default for CursorKind { + fn default() -> Self { + Self::Block + } +} + +config_serde_adapter!(CursorKind); + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum WhitespaceRenderValue { + None, + // TODO + // Selection, + All, +} + +config_serde_adapter!(WhitespaceRenderValue); + +/// bufferline render modes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BufferLine { + /// Don't render bufferline + Never, + /// Always render + Always, + /// Only if multiple buffers are open + Multiple, +} + +config_serde_adapter!(BufferLine); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PopupBorderConfig { + None, + All, + Popup, + Menu, +} + +config_serde_adapter!(PopupBorderConfig); + +options! { + struct UiConfig { + /// Whether to display info boxes + #[read = copy] + auto_info: bool = true, + /// 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) + #[read = copy] + bufferline: BufferLine = BufferLine::Never, + /// Highlight all lines with a cursor + #[read = copy] + cursorline: bool = false, + /// Highlight all columns with a cursor + #[read = copy] + cursorcolumn: bool = false, + /// List of column positions at which to display the rulers. + #[read = deref] + rulers: List = List::default(), + /// Whether to color the mode indicator with different colors depending on the mode itself + #[read = copy] + popup_border: bool = false, + /// Whether to color the mode indicator with different colors depending on the mode itself + #[read = copy] + color_modes: bool = false, + } + + struct WhiteSpaceRenderConfig { + #[name = "whitespace.characters.space"] + #[read = copy] + space_char: char = '·', // U+00B7 + #[name = "whitespace.characters.nbsp"] + #[read = copy] + nbsp_char: char = '⍽', // U+237D + #[name = "whitespace.characters.tab"] + #[read = copy] + tab_char: char = '→', // U+2192 + #[name = "whitespace.characters.tabpad"] + #[read = copy] + tabpad_char: char = '⏎', // U+23CE + #[name = "whitespace.characters.newline"] + #[read = copy] + newline_char: char = ' ', + #[name = "whitespace.render.default"] + #[read = copy] + render: WhitespaceRenderValue = WhitespaceRenderValue::None, + #[name = "whitespace.render.space"] + #[read = copy] + render_space: Option = None, + #[name = "whitespace.render.nbsp"] + #[read = copy] + render_nbsp: Option = None, + #[name = "whitespace.render.tab"] + #[read = copy] + render_tab: Option = None, + #[name = "whitespace.render.newline"] + #[read = copy] + render_newline: Option = None, + } + + struct TerminfoConfig { + /// Set to `true` to override automatic detection of terminal truecolor + /// support in the event of a false negative + #[name = "true-color"] + #[read = copy] + force_true_color: bool = false, + /// Set to `true` to override automatic detection of terminal undercurl + /// support in the event of a false negative + #[name = "undercurl"] + #[read = copy] + force_undercurl: bool = false, + } + + struct IndentGuidesConfig { + /// Whether to render indent guides + #[read = copy] + render: bool = false, + /// Character to use for rendering indent guides + #[read = copy] + character: char = '│', + /// Number of indent levels to skip + #[read = copy] + skip_levels: u8 = 0, + } + + struct CursorShapeConfig { + /// Cursor shape in normal mode + #[name = "cursor-shape.normal"] + #[read = copy] + normal_mode_cursor: CursorKind = CursorKind::Block, + /// Cursor shape in select mode + #[name = "cursor-shape.select"] + #[read = copy] + select_mode_cursor: CursorKind = CursorKind::Block, + /// Cursor shape in insert mode + #[name = "cursor-shape.insert"] + #[read = copy] + insert_mode_cursor: CursorKind = CursorKind::Block, + } + + struct FilePickerConfig { + /// Whether to exclude hidden files from any file pickers. + #[name = "file-picker.hidden"] + #[read = copy] + hidden: bool = true, + /// Follow symlinks instead of ignoring them + #[name = "file-picker.follow-symlinks"] + #[read = copy] + follow_symlinks: bool = true, + /// Ignore symlinks that point at files already shown in the picker + #[name = "file-picker.deduplicate-links"] + #[read = copy] + deduplicate_links: bool = true, + /// Enables reading ignore files from parent directories. + #[name = "file-picker.parents"] + #[read = copy] + parents: bool = true, + /// Enables reading `.ignore` files. + #[name = "file-picker.ignore"] + #[read = copy] + ignore: bool = true, + /// Enables reading `.gitignore` files. + #[name = "file-picker.git-ignore"] + #[read = copy] + git_ignore: bool = true, + /// Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` option. + #[name = "file-picker.git-global"] + #[read = copy] + git_global: bool = true, + /// Enables reading `.git/info/exclude` files. + #[name = "file-picker.git-exclude"] + #[read = copy] + git_exclude: bool = true, + /// Maximum Depth to recurse directories in file picker and global search. + #[name = "file-picker.max-depth"] + #[read = copy] + max_depth: Option = None, + } + + struct StatusLineConfig{ + /// A list of elements aligned to the left of the statusline + #[name = "statusline.left"] + #[read = deref] + left: List = &[ + StatusLineElement::Mode, + StatusLineElement::Spinner, + StatusLineElement::FileName, + StatusLineElement::ReadOnlyIndicator, + StatusLineElement::FileModificationIndicator, + ], + /// A list of elements aligned to the middle of the statusline + #[name = "statusline.center"] + #[read = deref] + center: List = List::default(), + /// A list of elements aligned to the right of the statusline + #[name = "statusline.right"] + #[read = deref] + right: List = &[ + StatusLineElement::Diagnostics, + StatusLineElement::Selections, + StatusLineElement::Register, + StatusLineElement::Position, + StatusLineElement::FileEncoding, + ], + /// The character used to separate elements in the statusline + #[name = "statusline.seperator"] + #[read = deref] + seperator: String = "│", + /// The text shown in the `mode` element for normal mode + #[name = "statusline.mode.normal"] + #[read = deref] + mode_indicator_normal: String = "NOR", + /// The text shown in the `mode` element for insert mode + #[name = "statusline.mode.insert"] + #[read = deref] + mode_indicator_insert: String = "INS", + /// The text shown in the `mode` element for select mode + #[name = "statusline.mode.select"] + #[read = deref] + mode_indicator_select: String = "SEL", + } +} diff --git a/helix-config/src/env.rs b/helix-config/src/env.rs new file mode 100644 index 000000000..537db384e --- /dev/null +++ b/helix-config/src/env.rs @@ -0,0 +1,10 @@ +// TOOD: move to stdx + +pub fn binary_exists(binary_name: &str) -> bool { + which::which(binary_name).is_ok() +} + +#[cfg(not(windows))] +pub fn env_var_is_set(env_var_name: &str) -> bool { + std::env::var_os(env_var_name).is_some() +} diff --git a/helix-config/src/lib.rs b/helix-config/src/lib.rs index 8f27e41ec..336a57f1b 100644 --- a/helix-config/src/lib.rs +++ b/helix-config/src/lib.rs @@ -13,13 +13,15 @@ use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; use any::ConfigData; use convert::ty_into_value; pub use convert::IntoTy; -pub use definition::init_config; +pub use definition::{init_config, init_language_server_config}; use validator::StaticValidator; pub use validator::{regex_str_validator, ty_validator, IntegerRangeValidator, Ty, Validator}; pub use value::{from_value, to_value, Value}; mod any; mod convert; +mod definition; +pub mod env; mod macros; mod validator; mod value; diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index 31f9d3649..b8223893e 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -2,8 +2,10 @@ //! this module provides the functionality to insert the paired closing character. use crate::{graphemes, movement::Direction, Range, Rope, Selection, Tendril, Transaction}; -use std::collections::HashMap; +use anyhow::{bail, ensure}; +use helix_config::options; +use indexmap::IndexMap; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ @@ -19,7 +21,7 @@ pub const DEFAULT_PAIRS: &[(char, char)] = &[ /// The type that represents the collection of auto pairs, /// keyed by both opener and closer. #[derive(Debug, Clone)] -pub struct AutoPairs(HashMap); +pub struct AutoPairs(IndexMap); /// Represents the config for a particular pairing. #[derive(Debug, Clone, Copy)] @@ -75,15 +77,15 @@ impl From<(&char, &char)> for Pair { impl AutoPairs { /// Make a new AutoPairs set with the given pairs and default conditions. - pub fn new<'a, V: 'a, A>(pairs: V) -> Self + pub fn new<'a, V: 'a, A>(pairs: V) -> anyhow::Result where - V: IntoIterator, + V: IntoIterator>, A: Into, { - let mut auto_pairs = HashMap::new(); + let mut auto_pairs = IndexMap::default(); for pair in pairs.into_iter() { - let auto_pair = pair.into(); + let auto_pair = pair?.into(); auto_pairs.insert(auto_pair.open, auto_pair); @@ -92,7 +94,7 @@ impl AutoPairs { } } - Self(auto_pairs) + Ok(Self(auto_pairs)) } pub fn get(&self, ch: char) -> Option<&Pair> { @@ -102,7 +104,7 @@ impl AutoPairs { impl Default for AutoPairs { fn default() -> Self { - AutoPairs::new(DEFAULT_PAIRS.iter()) + AutoPairs::new(DEFAULT_PAIRS.iter().map(Ok)).unwrap() } } @@ -371,3 +373,43 @@ fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { log::debug!("auto pair transaction: {:#?}", t); t } + +options! { + struct AutopairConfig { + /// Mapping of character pairs like `{ '(' = ')', '`' = '`' }` that are + /// automatically closed by the editor when typed. + auto_pairs: AutoPairs = AutoPairs::default(), + } +} + +impl helix_config::Ty for AutoPairs { + fn from_value(val: helix_config::Value) -> anyhow::Result { + let map = match val { + helix_config::Value::Map(map) => map, + helix_config::Value::Bool(false) => return Ok(Self(IndexMap::default())), + _ => bail!("expect 'false' or a map of pairs"), + }; + let pairs = map.into_iter().map(|(open, close)| { + let open = helix_config::Value::String(open.into_string()); + Ok(Pair { + open: open.typed()?, + close: close.typed()?, + }) + }); + AutoPairs::new(pairs) + } + + fn to_value(&self) -> helix_config::Value { + let map = self + .0 + .values() + .map(|pair| { + ( + pair.open.to_string().into(), + helix_config::Value::String(pair.close.into()), + ) + }) + .collect(); + helix_config::Value::Map(Box::new(map)) + } +} diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 1e90db472..2a5f5d93c 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -1,16 +1,36 @@ use std::{borrow::Cow, collections::HashMap}; +use anyhow::{anyhow, bail}; +use helix_config::{config_serde_adapter, options, IntegerRangeValidator}; +use serde::{Deserialize, Serialize}; use tree_sitter::{Query, QueryCursor, QueryPredicateArg}; use crate::{ chars::{char_is_line_ending, char_is_whitespace}, find_first_non_whitespace_char, graphemes::{grapheme_width, tab_width_at}, - syntax::{IndentationHeuristic, LanguageConfiguration, RopeProvider, Syntax}, + syntax::{LanguageConfiguration, RopeProvider, Syntax}, tree_sitter::Node, Position, Rope, RopeGraphemes, RopeSlice, }; +/// How the indentation for a newly inserted line should be determined. +/// If the selected heuristic is not available (e.g. because the current +/// language has no tree-sitter indent queries), a simpler one will be used. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IndentationHeuristic { + /// Just copy the indentation of the line that the cursor is currently on. + Simple, + /// Use tree-sitter indent queries to compute the expected absolute indentation level of the new line. + TreeSitter, + /// Use tree-sitter indent queries to compute the expected difference in indentation between the new line + /// and the line before. Add this to the actual indentation level of the line before. + #[default] + Hybrid, +} +config_serde_adapter!(IndentationHeuristic); + /// Enum representing indentation style. /// /// Only values 1-8 are valid for the `Spaces` variant. @@ -20,6 +40,50 @@ pub enum IndentStyle { Spaces(u8), } +options! { + struct IndentationConfig { + /// The number columns that a tabs are aligned to. + #[name = "ident.tab_width"] + #[read = copy] + tab_width: usize = 4, + /// Indentation inserted/removed into the document when indenting/dedenting. + /// This can be set to an integer representing N spaces or "tab" for tabs. + #[name = "ident.unit"] + #[read = copy] + indent_style: IndentStyle = IndentStyle::Tabs, + /// How the indentation for a newly inserted line is computed: + /// `simple` just copies the indentation level from the previous line, + /// `tree-sitter` computes the indentation based on the syntax tree and + /// `hybrid` combines both approaches. + /// If the chosen heuristic is not available, a different one will + /// be used as a fallback (the fallback order being `hybrid` -> + /// `tree-sitter` -> `simple`). + #[read = copy] + indent_heuristic: IndentationHeuristic = IndentationHeuristic::Hybrid + } +} + +impl helix_config::Ty for IndentStyle { + fn from_value(val: helix_config::Value) -> anyhow::Result { + match val { + helix_config::Value::String(s) if s == "t" || s == "tab" => Ok(IndentStyle::Tabs), + helix_config::Value::Int(_) => { + let spaces = IntegerRangeValidator::new(0, MAX_INDENT) + .validate(val) + .map_err(|err| anyhow!("invalid number of spaces! {err}"))?; + Ok(IndentStyle::Spaces(spaces)) + } + _ => bail!("expected an integer (spaces) or 'tab'"), + } + } + fn to_value(&self) -> helix_config::Value { + match *self { + IndentStyle::Tabs => helix_config::Value::String("tab".into()), + IndentStyle::Spaces(spaces) => helix_config::Value::Int(spaces as _), + } + } +} + // 16 spaces const INDENTS: &str = " "; pub const MAX_INDENT: u8 = 16; diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index b93ee8008..8b2edf58a 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -35,6 +35,7 @@ pub mod unicode { pub use unicode_width as width; } +use helix_config::OptionRegistry; pub use helix_loader::find_workspace; pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option { @@ -69,3 +70,9 @@ pub use diagnostic::Diagnostic; pub use line_ending::{LineEnding, NATIVE_LINE_ENDING}; pub use transaction::{Assoc, Change, ChangeSet, Deletion, Operation, Transaction}; + +pub fn init_config(registry: &mut OptionRegistry) { + line_ending::init_config(registry); + auto_pairs::init_config(registry); + indent::init_config(registry); +} diff --git a/helix-core/src/line_ending.rs b/helix-core/src/line_ending.rs index 36c02a941..aa1e7480d 100644 --- a/helix-core/src/line_ending.rs +++ b/helix-core/src/line_ending.rs @@ -1,3 +1,6 @@ +use anyhow::bail; +use helix_config::{options, Ty}; + use crate::{Rope, RopeSlice}; #[cfg(target_os = "windows")] @@ -5,6 +8,61 @@ pub const NATIVE_LINE_ENDING: LineEnding = LineEnding::Crlf; #[cfg(not(target_os = "windows"))] pub const NATIVE_LINE_ENDING: LineEnding = LineEnding::LF; +options! { + struct LineEndingConfig { + /// The line ending to use for new documents. Can be `lf` or `crlf`. If + /// helix was compiled with the `unicode-lines` feature then `vt`, `ff`, + /// `cr`, `nel`, `ls` or `ps` are also allowed. + #[read = copy] + default_line_ending: LineEnding = NATIVE_LINE_ENDING, + } +} + +impl Ty for LineEnding { + fn from_value(val: helix_config::Value) -> anyhow::Result { + let val: String = val.typed()?; + match &*val { + "crlf" => Ok(LineEnding::Crlf), + "lf" => Ok(LineEnding::LF), + #[cfg(feature = "unicode-lines")] + "vt" => Ok(LineEnding::VT), + #[cfg(feature = "unicode-lines")] + "ff" => Ok(LineEnding::FF), + #[cfg(feature = "unicode-lines")] + "cr" => Ok(LineEnding::CR), + #[cfg(feature = "unicode-lines")] + "nel" => Ok(LineEnding::Nel), + #[cfg(feature = "unicode-lines")] + "ls" => Ok(LineEnding::LS), + #[cfg(feature = "unicode-lines")] + "ps" => Ok(LineEnding::PS), + #[cfg(feature = "unicode-lines")] + _ => bail!("expecte one of 'lf', 'crlf', 'vt', 'ff', 'cr', 'nel', 'ls' or 'ps'"), + #[cfg(not(feature = "unicode-lines"))] + _ => bail!("expecte one of 'lf' or 'crlf'"), + } + } + + fn to_value(&self) -> helix_config::Value { + match self { + LineEnding::Crlf => "crlf".into(), + LineEnding::LF => "lf".into(), + #[cfg(feature = "unicode-lines")] + VT => "vt".into(), + #[cfg(feature = "unicode-lines")] + FF => "ff".into(), + #[cfg(feature = "unicode-lines")] + CR => "cr".into(), + #[cfg(feature = "unicode-lines")] + Nel => "nel".into(), + #[cfg(feature = "unicode-lines")] + LS => "ls".into(), + #[cfg(feature = "unicode-lines")] + PS => "ps".into(), + } + } +} + /// Represents one of the valid Unicode line endings. #[derive(PartialEq, Eq, Copy, Clone, Debug)] pub enum LineEnding { diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index f7acb0032..3ae4d330d 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true [dependencies] helix-core = { path = "../helix-core" } +helix-config = { path = "../helix-config" } anyhow = "1.0" log = "0.4" diff --git a/helix-dap/src/config.rs b/helix-dap/src/config.rs new file mode 100644 index 000000000..9644c63b1 --- /dev/null +++ b/helix-dap/src/config.rs @@ -0,0 +1,146 @@ +use anyhow::bail; +use helix_config::*; +use serde::{Deserialize, Serialize}; + +options! { + struct DebugAdapterConfig { + #[name = "debugger.name"] + name: Option = None, + #[name = "debugger.transport"] + #[read = copy] + transport: Transport = Transport::Stdio, + #[name = "debugger.command"] + #[read = deref] + command: String = "", + #[name = "debugger.args"] + #[read = deref] + args: List = List::default(), + #[name = "debugger.port-arg"] + #[read = deref] + port_arg: String = "", + #[name = "debugger.templates"] + #[read = deref] + templates: List = List::default(), + #[name = "debugger.quirks.absolut-path"] + #[read = copy] + absolut_path: bool = false, + #[name = "terminal.command"] + terminal_command: Option = get_terminal_provider().map(|term| term.command), + #[name = "terminal.args"] + #[read = deref] + terminal_args: List = get_terminal_provider().map(|term| term.args.into_boxed_slice()).unwrap_or_default(), + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Transport { + Stdio, + Tcp, +} + +impl Ty for Transport { + fn from_value(val: Value) -> anyhow::Result { + match &*String::from_value(val)? { + "stdio" => Ok(Transport::Stdio), + "tcp" => Ok(Transport::Tcp), + val => bail!("expected 'stdio' or 'tcp' (got {val:?})"), + } + } + fn to_value(&self) -> Value { + match self { + Transport::Stdio => "stdio".into(), + Transport::Tcp => "tcp".into(), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum DebugArgumentValue { + String(String), + Array(Vec), + Boolean(bool), +} + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AdvancedCompletion { + pub name: Option, + pub completion: Option, + pub default: Option, +} + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum DebugConfigCompletion { + Named(String), + Advanced(AdvancedCompletion), +} + +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct DebugTemplate { + pub name: String, + pub request: String, + pub completion: Vec, + pub args: Map, +} + +// TODO: integrate this better with the new config system (less nesting) +// the best way to do that is probably a rewrite. I think these templates +// are probably overkill here. This may be easier to solve by moving the logic +// to scheme +config_serde_adapter!(DebugTemplate); + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct TerminalConfig { + pub command: String, + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, +} + +#[cfg(windows)] +pub fn get_terminal_provider() -> Option { + use helix_config::env::binary_exists; + + if binary_exists("wt") { + return Some(TerminalConfig { + command: "wt".into(), + args: vec![ + "new-tab".into(), + "--title".into(), + "DEBUG".into(), + "cmd".into(), + "/C".into(), + ], + }); + } + + Some(TerminalConfig { + command: "conhost".into(), + args: vec!["cmd".into(), "/C".into()], + }) +} + +#[cfg(not(any(windows, target_os = "wasm32")))] +fn get_terminal_provider() -> Option { + use helix_config::env::{binary_exists, env_var_is_set}; + + if env_var_is_set("TMUX") && binary_exists("tmux") { + return Some(TerminalConfig { + command: "tmux".into(), + args: vec!["split-window".into()], + }); + } + + if env_var_is_set("WEZTERM_UNIX_SOCKET") && binary_exists("wezterm") { + return Some(TerminalConfig { + command: "wezterm".into(), + args: vec!["cli".into(), "split-pane".into()], + }); + } + + None +} diff --git a/helix-dap/src/lib.rs b/helix-dap/src/lib.rs index 21162cb86..d3ec1e361 100644 --- a/helix-dap/src/lib.rs +++ b/helix-dap/src/lib.rs @@ -1,4 +1,5 @@ mod client; +mod config; mod transport; mod types; diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index db53b54cc..bae2731cf 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -16,6 +16,7 @@ term = ["crossterm"] [dependencies] helix-core = { path = "../helix-core" } +helix-config = { path = "../helix-config" } helix-event = { path = "../helix-event" } helix-loader = { path = "../helix-loader" } helix-lsp = { path = "../helix-lsp" } diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index ebdac9e23..8c9ab8185 100644 --- a/helix-view/src/gutter.rs +++ b/helix-view/src/gutter.rs @@ -1,13 +1,96 @@ use std::fmt::Write; +use helix_config::{config_serde_adapter, options, List}; use helix_core::syntax::LanguageServerFeature; +use serde::{Deserialize, Serialize}; use crate::{ - editor::GutterType, graphics::{Style, UnderlineStyle}, Document, Editor, Theme, View, }; +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LineNumber { + /// Show absolute line number + #[serde(alias = "abs")] + Absolute, + /// If focused and in normal/select mode, show relative line number to the primary cursor. + /// If unfocused or in insert mode, show absolute line number. + #[serde(alias = "rel")] + Relative, +} + +config_serde_adapter!(LineNumber); + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GutterType { + /// Show diagnostics and other features like breakpoints + Diagnostics, + /// Show line numbers + LineNumbers, + /// Show one blank space + Spacer, + /// Highlight local changes + Diff, +} + +impl std::str::FromStr for GutterType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "diagnostics" => Ok(Self::Diagnostics), + "spacer" => Ok(Self::Spacer), + "line-numbers" => Ok(Self::LineNumbers), + "diff" => Ok(Self::Diff), + _ => anyhow::bail!( + "expected one of `diagnostics`, `spacer`, `line-numbers` or `diff` (found {s:?})" + ), + } + } +} + +impl helix_config::Ty for GutterType { + fn from_value(val: helix_config::Value) -> anyhow::Result { + let val: String = val.typed()?; + val.parse() + } + + fn to_value(&self) -> helix_config::Value { + match self { + GutterType::Diagnostics => "diagnostics".into(), + GutterType::LineNumbers => "lineNumbers".into(), + GutterType::Spacer => "spacer".into(), + GutterType::Diff => "diff".into(), + } + } +} + +options! { + struct GutterConfig { + /// A list of gutters to display + #[name = "gutters.layout"] + layout: List = &[ + GutterType::Diagnostics, + GutterType::Spacer, + GutterType::LineNumbers, + GutterType::Spacer, + GutterType::Diff, + ], + /// The minimum number of characters the line number gutter should take up. + #[name = "gutters.line-numbers.min-width"] + line_number_min_width: usize = 3, + /// Line number display: `absolute` simply shows each line's number, + /// while `relative` shows the distance from the current line. When + /// unfocused or in insert mode, `relative` will still show absolute + /// line numbers + #[name = "line-number"] + line_number_mode: LineNumber = LineNumber::Absolute, + } +} + fn count_digits(n: usize) -> usize { (usize::checked_ilog10(n).unwrap_or(0) + 1) as usize }