diff --git a/Cargo.lock b/Cargo.lock index a7e06b6ef8bf..7a1fcdd02fd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.78" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca87830a3e3fb156dc96cfbd31cb620265dd053be734723f22b760d6cc3c3051" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "arc-swap" @@ -1047,11 +1047,29 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "helix-config" +version = "23.10.0" +dependencies = [ + "ahash", + "anyhow", + "globset", + "hashbrown 0.14.3", + "indexmap", + "parking_lot", + "regex", + "regex-syntax", + "serde", + "serde_json", + "which", +] + [[package]] name = "helix-core" version = "23.10.0" dependencies = [ "ahash", + "anyhow", "arc-swap", "bitflags 2.4.1", "chrono", @@ -1059,8 +1077,10 @@ dependencies = [ "encoding_rs", "etcetera", "hashbrown 0.14.3", + "helix-config", "helix-loader", "imara-diff", + "indexmap", "indoc", "log", "nucleo", @@ -1088,6 +1108,7 @@ version = "23.10.0" dependencies = [ "anyhow", "fern", + "helix-config", "helix-core", "log", "serde", @@ -1128,13 +1149,16 @@ dependencies = [ name = "helix-lsp" version = "23.10.0" dependencies = [ + "ahash", "anyhow", "futures-executor", "futures-util", "globset", + "helix-config", "helix-core", "helix-loader", "helix-parsec", + "indexmap", "log", "lsp-types", "parking_lot", @@ -1235,6 +1259,7 @@ dependencies = [ "clipboard-win", "crossterm", "futures-util", + "helix-config", "helix-core", "helix-dap", "helix-event", @@ -1337,12 +1362,13 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", "hashbrown 0.14.3", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6c006fbb4ff4..77092ba78597 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "helix-core", + "helix-config", "helix-view", "helix-term", "helix-tui", diff --git a/helix-config/Cargo.toml b/helix-config/Cargo.toml new file mode 100644 index 000000000000..86bcceaf4411 --- /dev/null +++ b/helix-config/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "helix-config" +description = "Helix editor core editing primitives" +include = ["src/**/*", "README.md"] +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +ahash = "0.8.6" +hashbrown = { version = "0.14.3", features = ["raw"] } +parking_lot = "0.12" +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/any.rs b/helix-config/src/any.rs new file mode 100644 index 000000000000..891d9e8c5142 --- /dev/null +++ b/helix-config/src/any.rs @@ -0,0 +1,76 @@ +/// this is a reimplementation of dynamic dispatch that only stores the +/// information we need and stores everythin inline. Values that are smaller or +/// the same size as a slice (2 usize) are also stored inline. This avoids +/// significant overallocation when setting lots of simple config +/// options (integers, strings, lists, enums) +use std::any::{Any, TypeId}; +use std::mem::{align_of, size_of, MaybeUninit}; + +pub struct ConfigData { + data: MaybeUninit<[usize; 2]>, + ty: TypeId, + drop_fn: unsafe fn(MaybeUninit<[usize; 2]>), +} + +const fn store_inline() -> bool { + size_of::() <= size_of::<[usize; 2]>() && align_of::() <= align_of::<[usize; 2]>() +} + +impl ConfigData { + unsafe fn drop_impl(mut data: MaybeUninit<[usize; 2]>) { + if store_inline::() { + data.as_mut_ptr().cast::().drop_in_place(); + } else { + let ptr = data.as_mut_ptr().cast::<*mut T>().read(); + drop(Box::from_raw(ptr)); + } + } + + pub fn get(&self) -> &T { + assert_eq!(TypeId::of::(), self.ty); + unsafe { + if store_inline::() { + return &*self.data.as_ptr().cast(); + } + let data: *const T = self.data.as_ptr().cast::<*const T>().read(); + &*data + } + } + pub fn new(val: T) -> Self { + let mut data = MaybeUninit::uninit(); + if store_inline::() { + let data: *mut T = data.as_mut_ptr() as _; + unsafe { + data.write(val); + } + } else { + assert!(store_inline::<*const T>()); + let data: *mut *const T = data.as_mut_ptr() as _; + unsafe { + data.write(Box::into_raw(Box::new(val))); + } + }; + Self { + data, + ty: TypeId::of::(), + drop_fn: ConfigData::drop_impl::, + } + } +} + +impl Drop for ConfigData { + fn drop(&mut self) { + unsafe { + (self.drop_fn)(self.data); + } + } +} + +impl std::fmt::Debug for ConfigData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConfigData").finish_non_exhaustive() + } +} + +unsafe impl Send for ConfigData {} +unsafe impl Sync for ConfigData {} diff --git a/helix-config/src/convert.rs b/helix-config/src/convert.rs new file mode 100644 index 000000000000..1ee3b6f74755 --- /dev/null +++ b/helix-config/src/convert.rs @@ -0,0 +1,42 @@ +use crate::any::ConfigData; +use crate::validator::Ty; +use crate::Value; + +pub trait IntoTy: Clone { + type Ty: Ty; + fn into_ty(self) -> Self::Ty; +} + +impl IntoTy for T { + type Ty = Self; + + fn into_ty(self) -> Self::Ty { + self + } +} +impl IntoTy for &[T] { + type Ty = Box<[T::Ty]>; + + fn into_ty(self) -> Self::Ty { + self.iter().cloned().map(T::into_ty).collect() + } +} +impl IntoTy for &[T; N] { + type Ty = Box<[T::Ty]>; + + fn into_ty(self) -> Self::Ty { + self.iter().cloned().map(T::into_ty).collect() + } +} + +impl IntoTy for &str { + type Ty = Box; + + fn into_ty(self) -> Self::Ty { + self.into() + } +} + +pub(super) fn ty_into_value(val: &ConfigData) -> Value { + T::to_value(val.get()) +} diff --git a/helix-config/src/definition.rs b/helix-config/src/definition.rs new file mode 100644 index 000000000000..b51f2d750b88 --- /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 000000000000..5042823a5288 --- /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 000000000000..2c6845f493fa --- /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 000000000000..378d4e0597f8 --- /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 000000000000..537db384efbc --- /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 new file mode 100644 index 000000000000..9fe65b7e474f --- /dev/null +++ b/helix-config/src/lib.rs @@ -0,0 +1,246 @@ +use std::any::Any; +use std::fmt::Debug; +use std::marker::PhantomData; +use std::ops::Deref; +use std::sync::Arc; + +use anyhow::bail; +use hashbrown::hash_map::Entry; +use hashbrown::HashMap; +use indexmap::IndexMap; +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard}; + +use any::ConfigData; +use convert::ty_into_value; +pub use convert::IntoTy; +pub use definition::{init_config, init_language_server_config}; +pub use toml::read_toml_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 toml; +mod validator; +mod value; + +pub type Guard<'a, T> = MappedRwLockReadGuard<'a, T>; +pub type Map = IndexMap, T, ahash::RandomState>; +pub type String = Box; +pub type List = Box<[T]>; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub struct OptionInfo { + pub name: Arc, + pub description: Box, + pub validator: Box, + pub into_value: fn(&ConfigData) -> Value, +} + +#[derive(Debug)] +pub struct OptionManager { + vals: RwLock, ConfigData>>, + parent: Option>, +} + +impl OptionManager { + pub fn get(&self, option: &str) -> Guard<'_, T> { + Guard::map(self.get_data(option), ConfigData::get) + } + + pub fn get_data(&self, option: &str) -> Guard<'_, ConfigData> { + let mut current_scope = self; + loop { + let lock = current_scope.vals.read(); + if let Ok(res) = RwLockReadGuard::try_map(lock, |options| options.get(option)) { + return res; + } + let Some(new_scope) = current_scope.parent.as_deref() else{ + unreachable!("option must be atleast defined in the global scope") + }; + current_scope = new_scope; + } + } + + pub fn get_deref(&self, option: &str) -> Guard<'_, T::Target> { + Guard::map(self.get::(option), T::deref) + } + + pub fn get_folded( + &self, + option: &str, + init: R, + mut fold: impl FnMut(&T, R) -> R, + ) -> R { + let mut res = init; + let mut current_scope = self; + loop { + let options = current_scope.vals.read(); + if let Some(option) = options.get(option).map(|val| val.get()) { + res = fold(option, res); + } + let Some(new_scope) = current_scope.parent.as_deref() else{ + break + }; + current_scope = new_scope; + } + res + } + + pub fn get_value( + &self, + option: impl Into>, + registry: &OptionRegistry, + ) -> anyhow::Result { + let option: Arc = option.into(); + let Some(opt) = registry.get(&option) else { bail!("unknown option {option:?}") }; + let data = self.get_data(&option); + let val = (opt.into_value)(&data); + Ok(val) + } + + pub fn create_scope(self: &Arc) -> OptionManager { + OptionManager { + vals: RwLock::default(), + parent: Some(self.clone()), + } + } + + pub fn set_parent_scope(&mut self, parent: Arc) { + self.parent = Some(parent) + } + + pub fn set_unchecked(&self, option: Arc, val: ConfigData) { + self.vals.write().insert(option, val); + } + + pub fn append( + &self, + option: impl Into>, + val: impl Into, + registry: &OptionRegistry, + max_depth: usize, + ) -> anyhow::Result<()> { + let val = val.into(); + let option: Arc = option.into(); + let Some(opt) = registry.get(&option) else { bail!("unknown option {option:?}") }; + let old_data = self.get_data(&option); + let mut old = (opt.into_value)(&old_data); + old.append(val, max_depth); + let val = opt.validator.validate(old)?; + self.set_unchecked(option, val); + Ok(()) + } + + /// Sets the value of a config option. Returns an error if this config + /// option doesn't exist or the provided value is not valid. + pub fn set( + &self, + option: impl Into>, + val: impl Into, + registry: &OptionRegistry, + ) -> anyhow::Result<()> { + let option: Arc = option.into(); + let val = val.into(); + let Some(opt) = registry.get(&option) else { bail!("unknown option {option:?}") }; + let val = opt.validator.validate(val)?; + self.set_unchecked(option, val); + Ok(()) + } + + /// unsets an options so that its value will be read from + /// the parent scope instead + pub fn unset(&self, option: &str) { + self.vals.write().remove(option); + } +} + +#[derive(Debug)] +pub struct OptionRegistry { + options: HashMap, OptionInfo>, + defaults: Arc, +} + +impl OptionRegistry { + pub fn new() -> Self { + Self { + options: HashMap::with_capacity(1024), + defaults: Arc::new(OptionManager { + vals: RwLock::new(HashMap::with_capacity(1024)), + parent: None, + }), + } + } + + pub fn register(&mut self, name: &str, description: &str, default: T) { + self.register_with_validator( + name, + description, + default, + StaticValidator:: { ty: PhantomData }, + ); + } + + pub fn register_with_validator( + &mut self, + name: &str, + description: &str, + default: T, + validator: impl Validator, + ) { + let mut name: Arc = name.into(); + // convert from snake case to kebab case in place without an additional + // allocation this is save since we only replace ascii with ascii in + // place std really ougth to have a function for this :/ + // TODO: move to stdx as extension trait + for byte in unsafe { Arc::get_mut(&mut name).unwrap().as_bytes_mut() } { + if *byte == b'-' { + *byte = b'_'; + } + } + let default = default.into_ty(); + match self.options.entry(name.clone()) { + Entry::Vacant(e) => { + // make sure the validator is correct + if cfg!(debug_assertions) { + validator.validate(T::Ty::to_value(&default)).unwrap(); + } + let opt = OptionInfo { + name: name.clone(), + description: description.into(), + validator: Box::new(validator), + into_value: ty_into_value::, + }; + e.insert(opt); + } + Entry::Occupied(ent) => { + ent.get() + .validator + .validate(T::Ty::to_value(&default)) + .unwrap(); + } + } + self.defaults.set_unchecked(name, ConfigData::new(default)); + } + + pub fn global_scope(&self) -> Arc { + self.defaults.clone() + } + + pub fn get(&self, name: &str) -> Option<&OptionInfo> { + self.options.get(name) + } +} + +impl Default for OptionRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/helix-config/src/macros.rs b/helix-config/src/macros.rs new file mode 100644 index 000000000000..69e9c75ccd5f --- /dev/null +++ b/helix-config/src/macros.rs @@ -0,0 +1,130 @@ +/// This macro allows specifiying a trait of related config +/// options with a struct like syntax. From that information +/// two things are generated: +/// +/// * A `init_config` function that registers the config options with the +/// `OptionRegistry` registry. +/// * A **trait** definition with an accessor for every config option that is +/// implemented for `OptionManager`. +/// +/// The accessors on the trait allow convenient statically typed access to +/// config fields. The accessors return `Guard` (which allows derferecning to +/// &T). Any type that implements copy can be returned as a copy instead by +/// specifying `#[read = copy]`. Collections like `List` and `String` are not +/// copy However, they usually implement deref (to &[T] and &str respectively). +/// Working with the dereferneced &str/&[T] is more convenient then &String and &List. The +/// accessor will return these if `#[read = deref]` is specified. +/// +/// The doc comments will be retained for the accessors and also stored in the +/// option registrry for dispaly in the UI and documentation. +/// +/// The name of a config option can be changed with #[name = ""], +/// otherwise the name of the field is used directly. The OptionRegistry +/// automatically converts all names to kebab-case so a name attribute is only +/// required if the name is supposed to be significantly altered. +/// +/// In some cases more complex validation may be necssary. In that case the +/// valiidtator can be provided with an exprission that implements the `Validator` +/// trait: `#[validator = create_validator()]`. +#[macro_export] +macro_rules! options { + ( + $(use $use: ident::*;)* + $($(#[$($meta: tt)*])* struct $ident: ident { + $( + $(#[doc = $option_desc: literal])* + $(#[name = $option_name: literal])? + $(#[validator = $option_validator: expr])? + $(#[read = $($extra: tt)*])? + $option: ident: $ty: ty = $default: expr + ),+$(,)? + })+ + ) => { + $(pub use $use::*;)* + $($(#[$($meta)*])* pub trait $ident { + $( + $(#[doc = $option_desc])* + fn $option(&self) -> $crate::options!(@ret_ty $($($extra)*)? $ty); + )+ + })+ + pub fn init_config(registry: &mut $crate::OptionRegistry) { + $($use::init_config(registry);)* + $($( + let name = $crate::options!(@name $option $($option_name)?); + let docs = concat!("" $(,$option_desc,)" "*); + $crate::options!(@register registry name docs $default, $ty $(,$option_validator)?); + )+)+ + } + $(impl $ident for $crate::OptionManager { + $( + $(#[doc = $option_desc])* + fn $option(&self) -> $crate::options!(@ret_ty $($($extra)*)? $ty) { + let name = $crate::options!(@name $option $($option_name)?); + $crate::options!(@get $($($extra)*)? self, $ty, name) + } + )+ + })+ + }; + (@register $registry: ident $name: ident $desc: ident $default: expr, $ty:ty) => {{ + use $crate::IntoTy; + let val: $ty = $default.into_ty(); + $registry.register($name, $desc, val); + }}; + (@register $registry: ident $name: ident $desc: ident $default: expr, $ty:ty, $validator: expr) => {{ + use $crate::IntoTy; + let val: $ty = $default.into_ty(); + $registry.register_with_validator($name, $desc, val, $validator); + }}; + (@name $ident: ident) => { + ::std::stringify!($ident) + }; + (@name $ident: ident $name: literal) => { + $name + }; + (@ret_ty copy $ty: ty) => { + $ty + }; + (@ret_ty map($fn: expr, $ret_ty: ty) $ty: ty) => { + $ret_ty + }; + (@ret_ty fold($init: expr, $fn: expr, $ret_ty: ty) $ty: ty) => { + $ret_ty + }; + (@ret_ty deref $ty: ty) => { + $crate::Guard<'_, <$ty as ::std::ops::Deref>::Target> + }; + (@ret_ty $ty: ty) => { + $crate::Guard<'_, $ty> + }; + (@get map($fn: expr, $ret_ty: ty) $config: ident, $ty: ty, $name: ident) => { + let val = $config.get::<$ty>($name); + $fn(val) + }; + (@get fold($init: expr, $fn: expr, $ret_ty: ty) $config: ident, $ty: ty, $name: ident) => { + $config.get_folded::<$ty, $ret_ty>($name, $init, $fn) + }; + (@get copy $config: ident, $ty: ty, $name: ident) => { + *$config.get::<$ty>($name) + }; + (@get deref $config: ident, $ty: ty, $name: ident) => { + $config.get_deref::<$ty>($name) + }; + (@get $config: ident, $ty: ty, $name: ident) => { + $config.get::<$ty>($name) + }; +} + +#[macro_export] +macro_rules! config_serde_adapter { + ($ty: ident) => { + impl $crate::Ty for $ty { + fn to_value(&self) -> $crate::Value { + $crate::to_value(self).unwrap() + } + fn from_value(val: $crate::Value) -> ::anyhow::Result { + let val = $crate::from_value(val)?; + Ok(val) + } + } + }; +} diff --git a/helix-config/src/tests.rs b/helix-config/src/tests.rs new file mode 100644 index 000000000000..6a724bd64292 --- /dev/null +++ b/helix-config/src/tests.rs @@ -0,0 +1,80 @@ +use std::ops::Deref; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::config_serde_adapter; +use crate::OptionRegistry; + +#[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); + +fn setup_registry() -> OptionRegistry { + let mut registry = OptionRegistry::new(); + registry.register( + "scrolloff", + "Number of lines of padding around the edge of the screen when scrolling", + 5usize, + ); + registry.register( + "shell", + "Shell to use when running external commands", + &["sh", "-c"], + ); + registry.register("mouse", "Enable mouse mode", true); + registry.register( + "line-number", + "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", + LineNumber::Absolute, + ); + registry +} + +#[test] +fn default_values() { + let registry = setup_registry(); + let global_scope = registry.global_scope(); + let scrolloff: usize = *global_scope.get("scrolloff"); + let shell_ = global_scope.get_deref::>("shell"); + let shell: &[Box] = &shell_; + let mouse: bool = *global_scope.get("mouse"); + let line_number: LineNumber = *global_scope.get("line-number"); + assert_eq!(scrolloff, 5); + assert!(shell.iter().map(Box::deref).eq(["sh", "-c"])); + assert!(mouse); + assert_eq!(line_number, LineNumber::Absolute); +} + +#[test] +fn scope_overwrite() { + let registry = setup_registry(); + let global_scope = registry.global_scope(); + let scope_1 = Arc::new(global_scope.create_scope()); + let scope_2 = Arc::new(global_scope.create_scope()); + let mut scope_3 = scope_1.create_scope(); + scope_1.set("line-number", "rel", ®istry).unwrap(); + let line_number: LineNumber = *scope_3.get("line-number"); + assert_eq!(line_number, LineNumber::Relative); + scope_3.set_parent_scope(scope_2.clone()); + let line_number: LineNumber = *scope_3.get("line-number"); + assert_eq!(line_number, LineNumber::Absolute); + scope_2.set("line-number", "rel", ®istry).unwrap(); + let line_number: LineNumber = *scope_3.get("line-number"); + assert_eq!(line_number, LineNumber::Relative); + scope_2.set("line-number", "abs", ®istry).unwrap(); + let line_number: LineNumber = *scope_3.get("line-number"); + assert_eq!(line_number, LineNumber::Absolute); +} diff --git a/helix-config/src/toml.rs b/helix-config/src/toml.rs new file mode 100644 index 000000000000..e79d42acd04c --- /dev/null +++ b/helix-config/src/toml.rs @@ -0,0 +1,69 @@ +use crate::{Map, OptionManager, OptionRegistry, Value}; + +/// Inserts the config declaration from a map deserialized from toml into +/// options manager. Returns an error if any of theu config options are +/// invalid The convresion may not be exactly one-to-one to retain backwards +/// compatibility +pub fn read_toml_config( + config_entries: Map, + options: &OptionManager, + registry: &OptionRegistry, +) -> anyhow::Result<()> { + let mut buf = String::new(); + for (key, val) in config_entries { + if matches!(val, Value::Map(_)) { + buf.push_str(&key); + visit(&mut buf, val, options, registry)?; + buf.clear(); + } else { + visit(&mut key.to_string(), val, options, registry)?; + } + } + Ok(()) +} + +fn visit( + path: &mut String, + val: Value, + options: &OptionManager, + registry: &OptionRegistry, +) -> anyhow::Result<()> { + match &**path { + // don't descend + "auto-format" => { + // treat as unset + if Value::Bool(true) == val { + return Ok(()); + } + } + "auto-pairs" => return options.set("auto-pairs", val, registry), + "enviorment" => return options.set("enviorment", val, registry), + "config" => return options.set("config", val, registry), + "gutters" if matches!(val, Value::List(_)) => { + return options.set("gutters.layout", val, registry); + } + "gutters" if matches!(val, Value::List(_)) => { + return options.set("gutters.layout", val, registry); + } + "whitespace.render" if matches!(val, Value::String(_)) => { + return options.set("whitespace.render.default", val, registry); + } + "language-servers" => { + // merge list/map of language servers but if "only" and "except" are specified overwrite + return options.append("language-servers", val, registry, 0); + } + _ => (), + }; + if let Value::Map(val) = val { + let old_path_len = path.len(); + for (key, val) in val.into_iter() { + path.push('.'); + path.push_str(&key); + visit(path, val, options, registry)?; + path.truncate(old_path_len); + } + Ok(()) + } else { + options.set(&**path, val, registry) + } +} diff --git a/helix-config/src/validator.rs b/helix-config/src/validator.rs new file mode 100644 index 000000000000..7c56c3587692 --- /dev/null +++ b/helix-config/src/validator.rs @@ -0,0 +1,296 @@ +use std::any::{type_name, Any}; +use std::error::Error; +use std::fmt::Debug; +use std::marker::PhantomData; + +use anyhow::{bail, ensure, Result}; + +use crate::any::ConfigData; +use crate::Value; + +pub trait Validator: 'static + Debug { + fn validate(&self, val: Value) -> Result; +} + +pub trait Ty: Sized + Clone + 'static { + fn from_value(val: Value) -> Result; + fn to_value(&self) -> Value; +} + +#[derive(Clone, Copy)] +pub struct IntegerRangeValidator { + pub min: isize, + pub max: isize, + ty: PhantomData, +} +impl IntegerRangeValidator +where + E: Debug, + T: TryInto, +{ + pub fn new(min: T, max: T) -> Self { + Self { + min: min.try_into().unwrap(), + max: max.try_into().unwrap(), + ty: PhantomData, + } + } +} + +impl Debug for IntegerRangeValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IntegerRangeValidator") + .field("min", &self.min) + .field("max", &self.max) + .field("ty", &type_name::()) + .finish() + } +} + +impl IntegerRangeValidator +where + E: Error + Sync + Send + 'static, + T: Any + TryFrom, +{ + pub fn validate(&self, val: Value) -> Result { + let IntegerRangeValidator { min, max, .. } = *self; + let Value::Int(val) = val else { + bail!("expected an integer") + }; + ensure!( + min <= val && val <= max, + "expected an integer between {min} and {max} (got {val})", + ); + Ok(T::try_from(val)?) + } +} +impl Validator for IntegerRangeValidator +where + E: Error + Sync + Send + 'static, + T: Any + TryFrom, +{ + fn validate(&self, val: Value) -> Result { + Ok(ConfigData::new(self.validate(val))) + } +} + +macro_rules! integer_tys { + ($($ty: ident),*) => { + $( + impl Ty for $ty { + fn to_value(&self) -> Value { + Value::Int((*self).try_into().unwrap()) + } + + fn from_value(val: Value) -> Result { + IntegerRangeValidator::new($ty::MIN, $ty::MAX).validate(val) + } + } + )* + + }; +} + +integer_tys! { + i8, i16, i32, isize, + u8, u16, u32 +} + +impl Ty for usize { + fn to_value(&self) -> Value { + Value::Int((*self).try_into().unwrap()) + } + + fn from_value(val: Value) -> Result { + IntegerRangeValidator::new(0usize, isize::MAX as usize).validate(val) + } +} + +impl Ty for u64 { + fn to_value(&self) -> Value { + Value::Int((*self).try_into().unwrap()) + } + + fn from_value(val: Value) -> Result { + IntegerRangeValidator::new(0u64, isize::MAX as u64).validate(val) + } +} + +impl Ty for bool { + fn to_value(&self) -> Value { + Value::Bool(*self) + } + fn from_value(val: Value) -> Result { + let Value::Bool(val) = val else { + bail!("expected a boolean") + }; + Ok(val) + } +} + +impl Ty for Box { + fn to_value(&self) -> Value { + Value::String(self.clone().into_string()) + } + fn from_value(val: Value) -> Result { + let Value::String(val) = val else { + bail!("expected a string") + }; + Ok(val.into_boxed_str()) + } +} + +impl Ty for char { + fn to_value(&self) -> Value { + Value::String(self.to_string()) + } + + fn from_value(val: Value) -> Result { + let Value::String(val) = val else { + bail!("expected a string") + }; + ensure!( + val.chars().count() == 1, + "expecet a single character (got {val:?})" + ); + Ok(val.chars().next().unwrap()) + } +} + +impl Ty for std::string::String { + fn to_value(&self) -> Value { + Value::String(self.clone()) + } + fn from_value(val: Value) -> Result { + let Value::String(val) = val else { + bail!("expected a string") + }; + Ok(val) + } +} + +impl Ty for Option { + fn to_value(&self) -> Value { + match self { + Some(_) => todo!(), + None => todo!(), + } + } + + fn from_value(val: Value) -> Result { + if val == Value::Null { + return Ok(None); + } + Ok(Some(T::from_value(val)?)) + } +} + +impl Ty for Box { + fn from_value(val: Value) -> Result { + Ok(Box::new(T::from_value(val)?)) + } + + fn to_value(&self) -> Value { + T::to_value(self) + } +} + +impl Ty for indexmap::IndexMap, T, ahash::RandomState> { + fn from_value(val: Value) -> Result { + let Value::Map(map) = val else { + bail!("expected a map"); + }; + map.into_iter() + .map(|(k, v)| Ok((k, T::from_value(v)?))) + .collect() + } + + fn to_value(&self) -> Value { + let map = self + .iter() + .map(|(k, v)| (k.clone(), v.to_value())) + .collect(); + Value::Map(Box::new(map)) + } +} + +impl Ty for Box<[T]> { + fn to_value(&self) -> Value { + Value::List(self.iter().map(T::to_value).collect()) + } + fn from_value(val: Value) -> Result { + let Value::List(val) = val else { + bail!("expected a list") + }; + val.iter().cloned().map(T::from_value).collect() + } +} + +impl Ty for serde_json::Value { + fn from_value(val: Value) -> Result { + Ok(val.into()) + } + + fn to_value(&self) -> Value { + self.into() + } +} + +pub(super) struct StaticValidator { + pub(super) ty: PhantomData, +} + +impl Debug for StaticValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticValidator") + .field("ty", &type_name::()) + .finish() + } +} + +impl Validator for StaticValidator { + fn validate(&self, val: Value) -> Result { + let val = ::from_value(val)?; + Ok(ConfigData::new(val)) + } +} + +pub struct TyValidator { + pub(super) ty: PhantomData, + f: F, +} + +impl Debug for TyValidator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TyValidator") + .field("ty", &type_name::()) + .finish() + } +} + +impl Validator for TyValidator +where + T: Ty, + F: Fn(&T) -> anyhow::Result<()> + 'static, +{ + fn validate(&self, val: Value) -> Result { + let val = ::from_value(val)?; + (self.f)(&val)?; + Ok(ConfigData::new(val)) + } +} + +pub fn ty_validator(f: F) -> impl Validator +where + T: Ty, + F: Fn(&T) -> anyhow::Result<()> + 'static, +{ + TyValidator { ty: PhantomData, f } +} + +pub fn regex_str_validator() -> impl Validator { + ty_validator(|val: &crate::String| { + regex_syntax::parse(val)?; + Ok(()) + }) +} diff --git a/helix-config/src/value.rs b/helix-config/src/value.rs new file mode 100644 index 000000000000..be4ce095cf94 --- /dev/null +++ b/helix-config/src/value.rs @@ -0,0 +1,448 @@ +use std::fmt::Display; + +use indexmap::IndexMap; +use serde::de::DeserializeOwned; +use serde::ser::{Error as _, Impossible}; +use serde::{Deserialize, Serialize}; +use serde_json::{Error, Result}; + +use crate::Ty; + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Value { + List(Vec), + Map(Box, Value, ahash::RandomState>>), + Int(isize), + Float(f64), + Bool(bool), + String(String), + Null, +} + +impl Value { + pub fn typed(self) -> anyhow::Result { + T::from_value(self) + } + + pub fn append(&mut self, val: Value, depth: usize) { + match (self, val) { + (Value::List(dst), Value::List(ref mut val)) => dst.append(val), + (Value::Map(dst), Value::Map(val)) if depth == 0 || dst.is_empty() => { + dst.extend(val.into_iter()) + } + (Value::Map(dst), Value::Map(val)) => { + dst.reserve(val.len()); + for (k, v) in val.into_iter() { + // we don't use the entry api because we want + // to maintain thhe ordering + let merged = match dst.shift_remove(&k) { + Some(mut old) => { + old.append(v, depth - 1); + old + } + None => v, + }; + dst.insert(k, merged); + } + } + (dst, val) => *dst = val, + } + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Value::String(value.to_owned()) + } +} + +macro_rules! from_int { + ($($ty: ident),*) => { + $( + impl From<$ty> for Value { + fn from(value: $ty) -> Self { + Value::Int(value.try_into().unwrap()) + } + } + )* + }; +} + +impl From for Value { + fn from(value: serde_json::Value) -> Self { + to_value(value).unwrap() + } +} +impl From<&serde_json::Value> for Value { + fn from(value: &serde_json::Value) -> Self { + to_value(value).unwrap() + } +} + +impl From for serde_json::Value { + fn from(value: Value) -> Self { + serde_json::to_value(value).unwrap() + } +} + +from_int!(isize, usize, u32, i32, i16, u16, i8, u8); + +pub fn to_value(value: T) -> Result +where + T: Serialize, +{ + value.serialize(Serializer) +} + +pub fn from_value(value: Value) -> Result +where + T: DeserializeOwned, +{ + // roundtripping trough json is very inefficient *and incorrect* (captures + // json semantics that don't apply to us) + // TODO: implement a custom deserializer just like serde_json does + serde_json::from_value(value.into()) +} + +// We only use our own error type; no need for From conversions provided by the +// standard library's try! macro. This reduces lines of LLVM IR by 4%. +macro_rules! tri { + ($e:expr $(,)?) => { + match $e { + core::result::Result::Ok(val) => val, + core::result::Result::Err(err) => return core::result::Result::Err(err), + } + }; +} + +/// Serializer whose output is a `Value`. +/// +/// This is the serializer that backs [`serde_json::to_value`][crate::to_value]. +/// Unlike the main serde_json serializer which goes from some serializable +/// value of type `T` to JSON text, this one goes from `T` to +/// `serde_json::Value`. +/// +/// The `to_value` function is implementable as: +/// +/// ``` +/// use serde::Serialize; +/// use serde_json::{Error, Value}; +/// +/// pub fn to_value(input: T) -> Result +/// where +/// T: Serialize, +/// { +/// input.serialize(serde_json::value::Serializer) +/// } +/// ``` +pub struct Serializer; + +impl serde::Serializer for Serializer { + type Ok = Value; + type Error = Error; + + type SerializeSeq = SerializeVec; + type SerializeTuple = SerializeVec; + type SerializeTupleStruct = SerializeVec; + type SerializeTupleVariant = Impossible; + type SerializeMap = SerializeMap; + type SerializeStruct = SerializeMap; + type SerializeStructVariant = Impossible; + + #[inline] + fn serialize_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + #[inline] + fn serialize_i8(self, value: i8) -> Result { + self.serialize_i64(value as i64) + } + + #[inline] + fn serialize_i16(self, value: i16) -> Result { + self.serialize_i64(value as i64) + } + + #[inline] + fn serialize_i32(self, value: i32) -> Result { + self.serialize_i64(value as i64) + } + + fn serialize_i64(self, value: i64) -> Result { + Ok(Value::Int(value.try_into().unwrap())) + } + + fn serialize_i128(self, _value: i128) -> Result { + unreachable!() + } + + #[inline] + fn serialize_u8(self, value: u8) -> Result { + self.serialize_u64(value as u64) + } + + #[inline] + fn serialize_u16(self, value: u16) -> Result { + self.serialize_u64(value as u64) + } + + #[inline] + fn serialize_u32(self, value: u32) -> Result { + self.serialize_u64(value as u64) + } + + #[inline] + fn serialize_u64(self, value: u64) -> Result { + Ok(Value::Int(value.try_into().unwrap())) + } + + fn serialize_u128(self, _value: u128) -> Result { + unreachable!() + } + + #[inline] + fn serialize_f32(self, float: f32) -> Result { + Ok(Value::Float(float as f64)) + } + + #[inline] + fn serialize_f64(self, float: f64) -> Result { + Ok(Value::Float(float)) + } + + #[inline] + fn serialize_char(self, value: char) -> Result { + let mut s = String::new(); + s.push(value); + Ok(Value::String(s)) + } + + #[inline] + fn serialize_str(self, value: &str) -> Result { + Ok(Value::String(value.into())) + } + + fn serialize_bytes(self, value: &[u8]) -> Result { + let vec = value.iter().map(|&b| Value::Int(b.into())).collect(); + Ok(Value::List(vec)) + } + + #[inline] + fn serialize_unit(self) -> Result { + Ok(Value::Null) + } + + #[inline] + fn serialize_unit_struct(self, _name: &'static str) -> Result { + unimplemented!() + } + + #[inline] + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> Result { + self.serialize_str(variant) + } + + #[inline] + fn serialize_newtype_struct(self, _name: &'static str, _value: &T) -> Result + where + T: ?Sized + Serialize, + { + unimplemented!() + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result + where + T: ?Sized + Serialize, + { + unimplemented!() + } + + #[inline] + fn serialize_none(self) -> Result { + self.serialize_unit() + } + + #[inline] + fn serialize_some(self, value: &T) -> Result + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, len: Option) -> Result { + Ok(SerializeVec { + vec: Vec::with_capacity(len.unwrap_or(0)), + }) + } + + fn serialize_tuple(self, len: usize) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + len: usize, + ) -> Result { + self.serialize_seq(Some(len)) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + unimplemented!() + } + + fn serialize_map(self, _len: Option) -> Result { + Ok(SerializeMap { + map: IndexMap::default(), + next_key: None, + }) + } + + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result { + unreachable!() + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + unreachable!() + } + + fn collect_str(self, value: &T) -> Result + where + T: ?Sized + Display, + { + Ok(Value::String(value.to_string())) + } +} + +pub struct SerializeVec { + vec: Vec, +} + +impl serde::ser::SerializeSeq for SerializeVec { + type Ok = Value; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + self.vec.push(tri!(to_value(value))); + Ok(()) + } + + fn end(self) -> Result { + Ok(Value::List(self.vec)) + } +} + +impl serde::ser::SerializeTuple for SerializeVec { + type Ok = Value; + type Error = Error; + + fn serialize_element(&mut self, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeSeq::end(self) + } +} + +impl serde::ser::SerializeTupleStruct for SerializeVec { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + serde::ser::SerializeSeq::serialize_element(self, value) + } + + fn end(self) -> Result { + serde::ser::SerializeSeq::end(self) + } +} + +pub struct SerializeMap { + map: IndexMap, Value, ahash::RandomState>, + next_key: Option>, +} + +impl serde::ser::SerializeMap for SerializeMap { + type Ok = Value; + type Error = Error; + + fn serialize_key(&mut self, key: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + let key = to_value(key)?; + let Value::String(val) = key else { + return Err(Error::custom("only string keys are supported")); + }; + self.next_key = Some(val.into_boxed_str()); + Ok(()) + } + + fn serialize_value(&mut self, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + let key = self.next_key.take(); + // Panic because this indicates a bug in the program rather than an + // expected failure. + let key = key.expect("serialize_value called before serialize_key"); + self.map.insert(key, tri!(to_value(value))); + Ok(()) + } + + fn end(self) -> Result { + Ok(Value::Map(Box::new(self.map))) + } +} + +impl serde::ser::SerializeStruct for SerializeMap { + type Ok = Value; + type Error = Error; + + fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<()> + where + T: ?Sized + Serialize, + { + serde::ser::SerializeMap::serialize_entry(self, key, value) + } + + fn end(self) -> Result { + serde::ser::SerializeMap::end(self) + } +} diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index d7fff6c6f597..918359c135c5 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -17,6 +17,7 @@ integration = [] [dependencies] helix-loader = { path = "../helix-loader" } +helix-config = { path = "../helix-config" } ropey = { version = "1.6.1", default-features = false, features = ["simd"] } smallvec = "1.11" @@ -51,6 +52,8 @@ textwrap = "0.16.0" nucleo.workspace = true parking_lot = "0.12" +anyhow = "1.0.79" +indexmap = { version = "2.1.0", features = ["serde"] } [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index 31f9d3649314..b8223893eb45 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/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/indent.rs b/helix-core/src/indent.rs index 1e90db472f0a..2a5f5d93cd82 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 0acdb238054c..8b2edf58a507 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; @@ -36,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 { @@ -70,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 36c02a94185f..aa1e7480d3bf 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-core/src/syntax.rs b/helix-core/src/syntax.rs index 102ecb15d34e..73ea7ea82e54 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -84,422 +84,8 @@ pub struct Configuration { impl Default for Configuration { fn default() -> Self { - crate::config::default_syntax_loader() - } -} - -// largely based on tree-sitter/cli/src/loader.rs -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct LanguageConfiguration { - #[serde(rename = "name")] - pub language_id: String, // c-sharp, rust, tsx - #[serde(rename = "language-id")] - // see the table under https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem - pub language_server_language_id: Option, // csharp, rust, typescriptreact, for the language-server - pub scope: String, // source.rust - pub file_types: Vec, // filename extension or ends_with? - #[serde(default)] - pub shebangs: Vec, // interpreter(s) associated with language - #[serde(default)] - pub roots: Vec, // these indicate project roots <.git, Cargo.toml> - pub comment_token: Option, - pub text_width: Option, - pub soft_wrap: Option, - - #[serde(default)] - pub auto_format: bool, - - #[serde(skip_serializing_if = "Option::is_none")] - pub formatter: Option, - - #[serde(default)] - pub diagnostic_severity: Severity, - - pub grammar: Option, // tree-sitter grammar name, defaults to language_id - - // content_regex - #[serde(default, skip_serializing, deserialize_with = "deserialize_regex")] - pub injection_regex: Option, - // first_line_regex - // - #[serde(skip)] - pub(crate) highlight_config: OnceCell>>, - // tags_config OnceCell<> https://github.com/tree-sitter/tree-sitter/pull/583 - #[serde( - default, - skip_serializing_if = "Vec::is_empty", - serialize_with = "serialize_lang_features", - deserialize_with = "deserialize_lang_features" - )] - pub language_servers: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub indent: Option, - - #[serde(skip)] - pub(crate) indent_query: OnceCell>, - #[serde(skip)] - pub(crate) textobject_query: OnceCell>, - #[serde(skip_serializing_if = "Option::is_none")] - pub debugger: Option, - - /// Automatic insertion of pairs to parentheses, brackets, - /// etc. Defaults to true. Optionally, this can be a list of 2-tuples - /// to specify a list of characters to pair. This overrides the - /// global setting. - #[serde(default, skip_serializing, deserialize_with = "deserialize_auto_pairs")] - pub auto_pairs: Option, - - pub rulers: Option>, // if set, override editor's rulers - - /// Hardcoded LSP root directories relative to the workspace root, like `examples` or `tools/fuzz`. - /// Falling back to the current working directory if none are configured. - pub workspace_lsp_roots: Option>, - #[serde(default)] - pub persistent_diagnostic_sources: Vec, -} - -#[derive(Debug, PartialEq, Eq, Hash)] -pub enum FileType { - /// The extension of the file, either the `Path::extension` or the full - /// filename if the file does not have an extension. - Extension(String), - /// The suffix of a file. This is compared to a given file's absolute - /// path, so it can be used to detect files based on their directories. - Suffix(String), -} - -impl Serialize for FileType { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - - match self { - FileType::Extension(extension) => serializer.serialize_str(extension), - FileType::Suffix(suffix) => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("suffix", &suffix.replace(std::path::MAIN_SEPARATOR, "/"))?; - map.end() - } - } - } -} - -impl<'de> Deserialize<'de> for FileType { - fn deserialize(deserializer: D) -> Result - where - D: serde::de::Deserializer<'de>, - { - struct FileTypeVisitor; - - impl<'de> serde::de::Visitor<'de> for FileTypeVisitor { - type Value = FileType; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("string or table") - } - - fn visit_str(self, value: &str) -> Result - where - E: serde::de::Error, - { - Ok(FileType::Extension(value.to_string())) - } - - fn visit_map(self, mut map: M) -> Result - where - M: serde::de::MapAccess<'de>, - { - match map.next_entry::()? { - Some((key, suffix)) if key == "suffix" => Ok(FileType::Suffix({ - suffix.replace('/', std::path::MAIN_SEPARATOR_STR) - })), - Some((key, _value)) => Err(serde::de::Error::custom(format!( - "unknown key in `file-types` list: {}", - key - ))), - None => Err(serde::de::Error::custom( - "expected a `suffix` key in the `file-types` entry", - )), - } - } - } - - deserializer.deserialize_any(FileTypeVisitor) - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "kebab-case")] -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 Display for LanguageServerFeature { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - use LanguageServerFeature::*; - let feature = match self { - Format => "format", - GotoDeclaration => "goto-declaration", - GotoDefinition => "goto-definition", - GotoTypeDefinition => "goto-type-definition", - GotoReference => "goto-type-definition", - 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", - }; - write!(f, "{feature}",) - } -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)] -enum LanguageServerFeatureConfiguration { - #[serde(rename_all = "kebab-case")] - Features { - #[serde(default, skip_serializing_if = "HashSet::is_empty")] - only_features: HashSet, - #[serde(default, skip_serializing_if = "HashSet::is_empty")] - except_features: HashSet, - name: String, - }, - Simple(String), -} - -#[derive(Debug, Default)] -pub struct LanguageServerFeatures { - pub name: String, - pub only: HashSet, - pub excluded: HashSet, -} - -impl LanguageServerFeatures { - pub fn has_feature(&self, feature: LanguageServerFeature) -> bool { - (self.only.is_empty() || self.only.contains(&feature)) && !self.excluded.contains(&feature) - } -} - -fn deserialize_lang_features<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let raw: Vec = Deserialize::deserialize(deserializer)?; - let res = raw - .into_iter() - .map(|config| match config { - LanguageServerFeatureConfiguration::Simple(name) => LanguageServerFeatures { - name, - ..Default::default() - }, - LanguageServerFeatureConfiguration::Features { - only_features, - except_features, - name, - } => LanguageServerFeatures { - name, - only: only_features, - excluded: except_features, - }, - }) - .collect(); - Ok(res) -} -fn serialize_lang_features( - map: &Vec, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - let mut serializer = serializer.serialize_seq(Some(map.len()))?; - for features in map { - let features = if features.only.is_empty() && features.excluded.is_empty() { - LanguageServerFeatureConfiguration::Simple(features.name.to_owned()) - } else { - LanguageServerFeatureConfiguration::Features { - only_features: features.only.clone(), - except_features: features.excluded.clone(), - name: features.name.to_owned(), - } - }; - serializer.serialize_element(&features)?; - } - serializer.end() -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct LanguageServerConfiguration { - pub command: String, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - pub args: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub environment: HashMap, - #[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")] - pub config: Option, - #[serde(default = "default_timeout")] - pub timeout: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct FormatterConfiguration { - pub command: String, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - pub args: Vec, -} - -#[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(untagged)] -pub enum DebugArgumentValue { - String(String), - Array(Vec), - Boolean(bool), -} - -#[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: HashMap, -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct DebugAdapterConfig { - pub name: String, - pub transport: String, - #[serde(default)] - pub command: String, - #[serde(default)] - pub args: Vec, - pub port_arg: Option, - pub templates: Vec, - #[serde(default)] - pub quirks: DebuggerQuirks, -} - -// Different workarounds for adapters' differences -#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub struct DebuggerQuirks { - #[serde(default)] - pub absolute_paths: bool, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct IndentationConfiguration { - #[serde(deserialize_with = "deserialize_tab_width")] - pub tab_width: usize, - pub unit: String, -} - -/// 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, 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, -} - -/// Configuration for auto pairs -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] -pub enum AutoPairConfig { - /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. - Enable(bool), - - /// The mappings of pairs. - Pairs(HashMap), -} - -impl Default for AutoPairConfig { - fn default() -> Self { - AutoPairConfig::Enable(true) - } -} - -impl From<&AutoPairConfig> for Option { - fn from(auto_pair_config: &AutoPairConfig) -> Self { - match auto_pair_config { - AutoPairConfig::Enable(false) => None, - AutoPairConfig::Enable(true) => Some(AutoPairs::default()), - AutoPairConfig::Pairs(pairs) => Some(AutoPairs::new(pairs.iter())), - } - } -} - -impl From for Option { - fn from(auto_pairs_config: AutoPairConfig) -> Self { - (&auto_pairs_config).into() - } -} - -impl FromStr for AutoPairConfig { - type Err = std::str::ParseBoolError; - - // only do bool parsing for runtime setting - fn from_str(s: &str) -> Result { - let enable: bool = s.parse()?; - Ok(AutoPairConfig::Enable(enable)) + todo!() + // crate::config::default_syntax_loader() } } @@ -700,10 +286,6 @@ impl LanguageConfiguration { .as_ref() } - pub fn scope(&self) -> &str { - &self.scope - } - fn load_query(&self, kind: &str) -> Option { let query_text = read_query(&self.language_id, kind); if query_text.is_empty() { @@ -722,6 +304,7 @@ impl LanguageConfiguration { .ok() } } + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] pub struct SoftWrap { @@ -848,13 +431,6 @@ impl Loader { configuration_id.and_then(|&id| self.language_configs.get(id).cloned()) } - pub fn language_config_for_scope(&self, scope: &str) -> Option> { - self.language_configs - .iter() - .find(|config| config.scope == scope) - .cloned() - } - pub fn language_config_for_language_id(&self, id: &str) -> Option> { self.language_configs .iter() diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index f7acb00323e0..3ae4d330d95c 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 000000000000..9644c63b1109 --- /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 21162cb86e72..d3ec1e361a2f 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-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 851351e0e9bc..e40659109ec7 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -14,6 +14,7 @@ homepage.workspace = true [dependencies] helix-core = { path = "../helix-core" } +helix-config = { path = "../helix-config" } helix-loader = { path = "../helix-loader" } helix-parsec = { path = "../helix-parsec" } @@ -30,3 +31,5 @@ tokio = { version = "1.35", features = ["rt", "rt-multi-thread", "io-util", "io- tokio-stream = "0.1.14" which = "5.0.0" parking_lot = "0.12.1" +ahash = "0.8.6" +indexmap = { version = "2.1.0", features = ["serde"] } diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 682d4db66488..f3f7e279b23b 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -1,9 +1,12 @@ use crate::{ + config::LanguageServerConfig, find_lsp_workspace, jsonrpc, transport::{Payload, Transport}, Call, Error, OffsetEncoding, Result, }; +use anyhow::Context; +use helix_config::{self as config, OptionManager}; use helix_core::{find_workspace, path, syntax::LanguageServerFeature, ChangeSet, Rope}; use helix_loader::{self, VERSION_AND_GIT_HASH}; use lsp::{ @@ -13,15 +16,14 @@ use lsp::{ }; use lsp_types as lsp; use parking_lot::Mutex; -use serde::Deserialize; use serde_json::Value; use std::future::Future; +use std::path::PathBuf; use std::process::Stdio; use std::sync::{ atomic::{AtomicU64, Ordering}, Arc, }; -use std::{collections::HashMap, path::PathBuf}; use tokio::{ io::{BufReader, BufWriter}, process::{Child, Command}, @@ -50,13 +52,11 @@ pub struct Client { server_tx: UnboundedSender, request_counter: AtomicU64, pub(crate) capabilities: OnceCell, - config: Option, root_path: std::path::PathBuf, root_uri: Option, workspace_folders: Mutex>, initialize_notify: Arc, - /// workspace folders added while the server is still initializing - req_timeout: u64, + config: Arc, } impl Client { @@ -170,23 +170,20 @@ impl Client { #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn start( - cmd: &str, - args: &[String], - config: Option, - server_environment: HashMap, + config: Arc, root_markers: &[String], manual_roots: &[PathBuf], id: usize, name: String, - req_timeout: u64, doc_path: Option<&std::path::PathBuf>, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { // Resolve path to the binary - let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; + let cmd = which::which(config.command().as_deref().context("no command defined")?) + .map_err(|err| anyhow::anyhow!(err))?; let process = Command::new(cmd) - .envs(server_environment) - .args(args) + .envs(config.enviorment().iter().map(|(k, v)| (&**k, &**v))) + .args(config.args().iter().map(|v| &**v)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -233,7 +230,6 @@ impl Client { request_counter: AtomicU64::new(0), capabilities: OnceCell::new(), config, - req_timeout, root_path, root_uri, workspace_folders: Mutex::new(workspace_folders), @@ -374,8 +370,8 @@ impl Client { .unwrap_or_default() } - pub fn config(&self) -> Option<&Value> { - self.config.as_ref() + pub fn config(&self) -> config::Guard>> { + self.config.server_config() } pub async fn workspace_folders( @@ -404,7 +400,7 @@ impl Client { where R::Params: serde::Serialize, { - self.call_with_timeout::(params, self.req_timeout) + self.call_with_timeout::(params, self.config.timeout()) } fn call_with_timeout( @@ -512,7 +508,7 @@ impl Client { // ------------------------------------------------------------------------------------------- pub(crate) async fn initialize(&self, enable_snippets: bool) -> Result { - if let Some(config) = &self.config { + if let Some(config) = &*self.config() { log::info!("Using custom LSP config: {}", config); } @@ -524,7 +520,7 @@ impl Client { // clients will prefer _uri if possible root_path: self.root_path.to_str().map(|path| path.to_owned()), root_uri: self.root_uri.clone(), - initialization_options: self.config.clone(), + initialization_options: self.config().as_deref().cloned(), capabilities: lsp::ClientCapabilities { workspace: Some(lsp::WorkspaceClientCapabilities { configuration: Some(true), @@ -1152,17 +1148,12 @@ impl Client { }; // merge FormattingOptions with 'config.format' - let config_format = self - .config - .as_ref() - .and_then(|cfg| cfg.get("format")) - .and_then(|fmt| HashMap::::deserialize(fmt).ok()); - - let options = if let Some(mut properties) = config_format { + let mut config_format = self.config.format(); + let options = if !config_format.is_empty() { // passed in options take precedence over 'config.format' - properties.extend(options.properties); + config_format.extend(options.properties); lsp::FormattingOptions { - properties, + properties: config_format, ..options } } else { diff --git a/helix-lsp/src/config.rs b/helix-lsp/src/config.rs new file mode 100644 index 000000000000..6d9f80080601 --- /dev/null +++ b/helix-lsp/src/config.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; + +use anyhow::bail; +use helix_config::{options, List, Map, String, Ty, Value}; + +use crate::lsp; + +// TODO: differentiating between Some(null) and None is not really practical +// since the distinction is lost on a roundtrip trough config::Value. +// Porbably better to change our code to treat null the way we currently +// treat None +options! { + struct LanguageServerConfig { + /// The name or path of the language server binary to execute. Binaries must be in `$PATH` + command: Option = None, + /// A list of arguments to pass to the language server binary + #[read = deref] + args: List = List::default(), + /// Any environment variables that will be used when starting the language server + enviorment: Map = Map::default(), + /// LSP initialization options + #[name = "config"] + server_config: Option> = None, + /// LSP initialization options + #[read = copy] + timeout: u64 = 20, + // TODO: merge + /// LSP formatting options + #[name = "config.format"] + #[read = fold(HashMap::new(), fold_format_config, FormatConfig)] + format: Map = Map::default() + } +} + +type FormatConfig = HashMap; + +fn fold_format_config(config: &Map, mut res: FormatConfig) -> FormatConfig { + for (k, v) in config.iter() { + res.entry(k.to_string()).or_insert_with(|| v.0.clone()); + } + res +} + +// damm orphan rules :/ +#[derive(Debug, PartialEq, Clone)] +struct FormattingProperty(lsp::FormattingProperty); + +impl Ty for FormattingProperty { + fn from_value(val: Value) -> anyhow::Result { + match val { + Value::Int(_) => Ok(FormattingProperty(lsp::FormattingProperty::Number( + i32::from_value(val)?, + ))), + Value::Bool(val) => Ok(FormattingProperty(lsp::FormattingProperty::Bool(val))), + Value::String(val) => Ok(FormattingProperty(lsp::FormattingProperty::String(val))), + _ => bail!("expected a string, boolean or integer"), + } + } + + fn to_value(&self) -> Value { + match self.0 { + lsp::FormattingProperty::Bool(val) => Value::Bool(val), + lsp::FormattingProperty::Number(val) => Value::Int(val as _), + lsp::FormattingProperty::String(ref val) => Value::String(val.clone()), + } + } +} diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 34278cd54ffa..92dab5966208 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -1,4 +1,5 @@ mod client; +mod config; pub mod file_event; pub mod jsonrpc; pub mod snippet; @@ -11,6 +12,7 @@ pub use lsp::{Position, Url}; pub use lsp_types as lsp; use futures_util::stream::select_all::SelectAll; +use helix_config::OptionRegistry; use helix_core::{ path, syntax::{LanguageConfiguration, LanguageServerConfiguration, LanguageServerFeatures}, @@ -26,6 +28,8 @@ use std::{ use thiserror::Error; use tokio_stream::wrappers::UnboundedReceiverStream; +use crate::config::init_config; + pub type Result = core::result::Result; pub type LanguageServerName = String; @@ -636,17 +640,25 @@ pub struct Registry { counter: usize, pub incoming: SelectAll>, pub file_event_handler: file_event::Handler, + pub config: OptionRegistry, } impl Registry { pub fn new(syn_loader: Arc) -> Self { - Self { + let mut res = Self { inner: HashMap::new(), syn_loader, counter: 0, incoming: SelectAll::new(), file_event_handler: file_event::Handler::new(), - } + config: OptionRegistry::new(), + }; + res.reset_config(); + res + } + + pub fn reset_config(&mut self) { + init_config(&mut self.config); } pub fn get_by_id(&self, id: usize) -> Option<&Client> { @@ -882,15 +894,11 @@ fn start_client( enable_snippets: bool, ) -> Result { let (client, incoming, initialize_notify) = Client::start( - &ls_config.command, - &ls_config.args, - ls_config.config.clone(), - ls_config.environment.clone(), + todo!(), &config.roots, config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), id, name, - ls_config.timeout, doc_path, )?; diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 4eda8097c687..7e6cdc8806a4 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -699,7 +699,7 @@ impl Application { // Trigger a workspace/didChangeConfiguration notification after initialization. // This might not be required by the spec but Neovim does this as well, so it's // probably a good idea for compatibility. - if let Some(config) = language_server.config() { + if let Some(config) = language_server.config().as_deref() { tokio::spawn(language_server.did_change_configuration(config.clone())); } @@ -1023,7 +1023,8 @@ impl Application { .items .iter() .map(|item| { - let mut config = language_server.config()?; + let config = language_server.config(); + let mut config = config.as_deref()?; if let Some(section) = item.section.as_ref() { // for some reason some lsps send an empty string (observed in 'vscode-eslint-language-server') if !section.is_empty() { @@ -1032,7 +1033,7 @@ impl Application { } } } - Some(config) + Some(config.to_owned()) }) .collect(); Ok(json!(result)) diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index db53b54cc907..bae2731cf974 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/document.rs b/helix-view/src/document.rs index 0de0cd172e06..4ddaa4420fbc 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1518,13 +1518,6 @@ impl Document { current_revision } - /// Corresponding language scope name. Usually `source.`. - pub fn language_scope(&self) -> Option<&str> { - self.language - .as_ref() - .map(|language| language.scope.as_str()) - } - /// Language name for the document. Corresponds to the `name` key in /// `languages.toml` configuration. pub fn language_name(&self) -> Option<&str> { diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index f13df2135180..be383d91287c 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -41,833 +41,13 @@ use anyhow::{anyhow, bail, Error}; pub use helix_core::diagnostic::Severity; use helix_core::{ - auto_pairs::AutoPairs, - syntax::{self, AutoPairConfig, IndentationHeuristic, LanguageServerFeature, SoftWrap}, - Change, LineEnding, Position, Selection, NATIVE_LINE_ENDING, + syntax::{self, LanguageServerFeature}, + Change, Position, Selection, }; use helix_dap as dap; use helix_lsp::lsp; -use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; - -use arc_swap::access::{DynAccess, DynGuard}; - -fn deserialize_duration_millis<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - let millis = u64::deserialize(deserializer)?; - Ok(Duration::from_millis(millis)) -} - -fn serialize_duration_millis(duration: &Duration, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_u64( - duration - .as_millis() - .try_into() - .map_err(|_| serde::ser::Error::custom("duration value overflowed u64"))?, - ) -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct GutterConfig { - /// Gutter Layout - pub layout: Vec, - /// Options specific to the "line-numbers" gutter - pub line_numbers: GutterLineNumbersConfig, -} - -impl Default for GutterConfig { - fn default() -> Self { - Self { - layout: vec![ - GutterType::Diagnostics, - GutterType::Spacer, - GutterType::LineNumbers, - GutterType::Spacer, - GutterType::Diff, - ], - line_numbers: GutterLineNumbersConfig::default(), - } - } -} - -impl From> for GutterConfig { - fn from(x: Vec) -> Self { - GutterConfig { - layout: x, - ..Default::default() - } - } -} - -fn deserialize_gutter_seq_or_struct<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct GutterVisitor; - - impl<'de> serde::de::Visitor<'de> for GutterVisitor { - type Value = GutterConfig; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - formatter, - "an array of gutter names or a detailed gutter configuration" - ) - } - - fn visit_seq(self, mut seq: S) -> Result - where - S: serde::de::SeqAccess<'de>, - { - let mut gutters = Vec::new(); - while let Some(gutter) = seq.next_element::()? { - gutters.push( - gutter - .parse::() - .map_err(serde::de::Error::custom)?, - ) - } - - Ok(gutters.into()) - } - - fn visit_map(self, map: M) -> Result - where - M: serde::de::MapAccess<'de>, - { - let deserializer = serde::de::value::MapAccessDeserializer::new(map); - Deserialize::deserialize(deserializer) - } - } - - deserializer.deserialize_any(GutterVisitor) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct GutterLineNumbersConfig { - /// Minimum number of characters to use for line number gutter. Defaults to 3. - pub min_width: usize, -} - -impl Default for GutterLineNumbersConfig { - fn default() -> Self { - Self { min_width: 3 } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct FilePickerConfig { - /// IgnoreOptions - /// Enables ignoring hidden files. - /// Whether to hide hidden files in file picker and global search results. Defaults to true. - pub hidden: bool, - /// Enables following symlinks. - /// Whether to follow symbolic links in file picker and file or directory completions. Defaults to true. - pub follow_symlinks: bool, - /// Hides symlinks that point into the current directory. Defaults to true. - pub deduplicate_links: bool, - /// Enables reading ignore files from parent directories. Defaults to true. - pub parents: bool, - /// Enables reading `.ignore` files. - /// Whether to hide files listed in .ignore in file picker and global search results. Defaults to true. - pub ignore: bool, - /// Enables reading `.gitignore` files. - /// Whether to hide files listed in .gitignore in file picker and global search results. Defaults to true. - pub git_ignore: bool, - /// Enables reading global .gitignore, whose path is specified in git's config: `core.excludefile` option. - /// Whether to hide files listed in global .gitignore in file picker and global search results. Defaults to true. - pub git_global: bool, - /// Enables reading `.git/info/exclude` files. - /// Whether to hide files listed in .git/info/exclude in file picker and global search results. Defaults to true. - pub git_exclude: bool, - /// WalkBuilder options - /// Maximum Depth to recurse directories in file picker and global search. Defaults to `None`. - pub max_depth: Option, -} - -impl Default for FilePickerConfig { - fn default() -> Self { - Self { - hidden: true, - follow_symlinks: true, - deduplicate_links: true, - parents: true, - ignore: true, - git_ignore: true, - git_global: true, - git_exclude: true, - max_depth: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct Config { - /// Padding to keep between the edge of the screen and the cursor when scrolling. Defaults to 5. - pub scrolloff: usize, - /// Number of lines to scroll at once. Defaults to 3 - pub scroll_lines: isize, - /// Mouse support. Defaults to true. - pub mouse: bool, - /// Shell to use for shell commands. Defaults to ["cmd", "/C"] on Windows and ["sh", "-c"] otherwise. - pub shell: Vec, - /// Line number mode. - pub line_number: LineNumber, - /// Highlight the lines cursors are currently on. Defaults to false. - pub cursorline: bool, - /// Highlight the columns cursors are currently on. Defaults to false. - pub cursorcolumn: bool, - #[serde(deserialize_with = "deserialize_gutter_seq_or_struct")] - pub gutters: GutterConfig, - /// Middle click paste support. Defaults to true. - pub middle_click_paste: bool, - /// Automatic insertion of pairs to parentheses, brackets, - /// etc. Optionally, this can be a list of 2-tuples to specify a - /// global list of characters to pair. Defaults to true. - pub auto_pairs: AutoPairConfig, - /// Automatic auto-completion, automatically pop up without user trigger. Defaults to true. - pub auto_completion: bool, - /// Automatic formatting on save. Defaults to true. - pub auto_format: bool, - /// Automatic save on focus lost. Defaults to false. - pub auto_save: bool, - /// Set a global text_width - pub text_width: usize, - /// Time in milliseconds since last keypress before idle timers trigger. - /// Used for autocompletion, set to 0 for instant. Defaults to 250ms. - #[serde( - serialize_with = "serialize_duration_millis", - deserialize_with = "deserialize_duration_millis" - )] - pub idle_timeout: Duration, - /// Whether to insert the completion suggestion on hover. Defaults to true. - pub preview_completion_insert: bool, - pub completion_trigger_len: u8, - /// Whether to instruct the LSP to replace the entire word when applying a completion - /// or to only insert new text - pub completion_replace: bool, - /// Whether to display infoboxes. Defaults to true. - pub auto_info: bool, - pub file_picker: FilePickerConfig, - /// Configuration of the statusline elements - pub statusline: StatusLineConfig, - /// Shape for cursor in each mode - pub cursor_shape: CursorShapeConfig, - /// Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. Defaults to `false`. - pub true_color: bool, - /// Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative. Defaults to `false`. - pub undercurl: bool, - /// Search configuration. - #[serde(default)] - pub search: SearchConfig, - pub lsp: LspConfig, - pub terminal: Option, - /// Column numbers at which to draw the rulers. Defaults to `[]`, meaning no rulers. - pub rulers: Vec, - #[serde(default)] - pub whitespace: WhitespaceConfig, - /// Persistently display open buffers along the top - pub bufferline: BufferLine, - /// Vertical indent width guides. - pub indent_guides: IndentGuidesConfig, - /// Whether to color modes with different colors. Defaults to `false`. - pub color_modes: bool, - pub soft_wrap: SoftWrap, - /// Workspace specific lsp ceiling dirs - pub workspace_lsp_roots: Vec, - /// Which line ending to choose for new documents. Defaults to `native`. i.e. `crlf` on Windows, otherwise `lf`. - pub default_line_ending: LineEndingConfig, - /// Whether to automatically insert a trailing line-ending on write if missing. Defaults to `true`. - pub insert_final_newline: bool, - /// Enables smart tab - pub smart_tab: Option, - /// Draw border around popups. - pub popup_border: PopupBorderConfig, - /// Which indent heuristic to use when a new line is inserted - #[serde(default)] - pub indent_heuristic: IndentationHeuristic, -} - -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] -#[serde(rename_all = "kebab-case", default)] -pub struct SmartTabConfig { - pub enable: bool, - pub supersede_menu: bool, -} - -impl Default for SmartTabConfig { - fn default() -> Self { - SmartTabConfig { - enable: true, - supersede_menu: false, - } - } -} - -#[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 crate::env::binary_exists; - - if binary_exists("wt") { - return Some(TerminalConfig { - command: "wt".to_string(), - args: vec![ - "new-tab".to_string(), - "--title".to_string(), - "DEBUG".to_string(), - "cmd".to_string(), - "/C".to_string(), - ], - }); - } - - Some(TerminalConfig { - command: "conhost".to_string(), - args: vec!["cmd".to_string(), "/C".to_string()], - }) -} - -#[cfg(not(any(windows, target_os = "wasm32")))] -pub fn get_terminal_provider() -> Option { - use crate::env::{binary_exists, env_var_is_set}; - - if env_var_is_set("TMUX") && binary_exists("tmux") { - return Some(TerminalConfig { - command: "tmux".to_string(), - args: vec!["split-window".to_string()], - }); - } - - if env_var_is_set("WEZTERM_UNIX_SOCKET") && binary_exists("wezterm") { - return Some(TerminalConfig { - command: "wezterm".to_string(), - args: vec!["cli".to_string(), "split-pane".to_string()], - }); - } - - None -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] -pub struct LspConfig { - /// Enables LSP - pub enable: bool, - /// Display LSP progress messages below statusline - pub display_messages: bool, - /// Enable automatic pop up of signature help (parameter hints) - pub auto_signature_help: bool, - /// Display docs under signature help popup - pub display_signature_help_docs: bool, - /// Display inlay hints - pub display_inlay_hints: bool, - /// Whether to enable snippet support - pub snippets: bool, - /// Whether to include declaration in the goto reference query - pub goto_reference_include_declaration: bool, -} - -impl Default for LspConfig { - fn default() -> Self { - Self { - enable: true, - display_messages: false, - auto_signature_help: true, - display_signature_help_docs: true, - display_inlay_hints: false, - snippets: true, - goto_reference_include_declaration: true, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct SearchConfig { - /// Smart case: Case insensitive searching unless pattern contains upper case characters. Defaults to true. - pub smart_case: bool, - /// Whether the search should wrap after depleting the matches. Default to true. - pub wrap_around: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct StatusLineConfig { - pub left: Vec, - pub center: Vec, - pub right: Vec, - pub separator: String, - pub mode: ModeConfig, -} - -impl Default for StatusLineConfig { - fn default() -> Self { - use StatusLineElement as E; - - Self { - left: vec![ - E::Mode, - E::Spinner, - E::FileName, - E::ReadOnlyIndicator, - E::FileModificationIndicator, - ], - center: vec![], - right: vec![ - E::Diagnostics, - E::Selections, - E::Register, - E::Position, - E::FileEncoding, - ], - separator: String::from("│"), - mode: ModeConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", default, deny_unknown_fields)] -pub struct ModeConfig { - pub normal: String, - pub insert: String, - pub select: String, -} - -impl Default for ModeConfig { - fn default() -> Self { - Self { - normal: String::from("NOR"), - insert: String::from("INS"), - select: String::from("SEL"), - } - } -} - -#[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, -} - -// Cursor shape is read and used on every rendered frame and so needs -// to be fast. Therefore we avoid a hashmap and use an enum indexed array. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CursorShapeConfig([CursorKind; 3]); - -impl CursorShapeConfig { - pub fn from_mode(&self, mode: Mode) -> CursorKind { - self.get(mode as usize).copied().unwrap_or_default() - } -} - -impl<'de> Deserialize<'de> for CursorShapeConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let m = HashMap::::deserialize(deserializer)?; - let into_cursor = |mode: Mode| m.get(&mode).copied().unwrap_or_default(); - Ok(CursorShapeConfig([ - into_cursor(Mode::Normal), - into_cursor(Mode::Select), - into_cursor(Mode::Insert), - ])) - } -} - -impl Serialize for CursorShapeConfig { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut map = serializer.serialize_map(Some(self.len()))?; - let modes = [Mode::Normal, Mode::Select, Mode::Insert]; - for mode in modes { - map.serialize_entry(&mode, &self.from_mode(mode))?; - } - map.end() - } -} - -impl std::ops::Deref for CursorShapeConfig { - type Target = [CursorKind; 3]; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Default for CursorShapeConfig { - fn default() -> Self { - Self([CursorKind::Block; 3]) - } -} - -/// bufferline render modes -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum BufferLine { - /// Don't render bufferline - #[default] - Never, - /// Always render - Always, - /// Only if multiple buffers are open - Multiple, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum LineNumber { - /// Show absolute line number - 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. - Relative, -} - -impl std::str::FromStr for LineNumber { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "absolute" | "abs" => Ok(Self::Absolute), - "relative" | "rel" => Ok(Self::Relative), - _ => anyhow::bail!("Line number can only be `absolute` or `relative`."), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, 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!( - "Gutter type can only be `diagnostics`, `spacer`, `line-numbers` or `diff`." - ), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct WhitespaceConfig { - pub render: WhitespaceRender, - pub characters: WhitespaceCharacters, -} - -impl Default for WhitespaceConfig { - fn default() -> Self { - Self { - render: WhitespaceRender::Basic(WhitespaceRenderValue::None), - characters: WhitespaceCharacters::default(), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged, rename_all = "kebab-case")] -pub enum WhitespaceRender { - Basic(WhitespaceRenderValue), - Specific { - default: Option, - space: Option, - nbsp: Option, - tab: Option, - newline: Option, - }, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum WhitespaceRenderValue { - None, - // TODO - // Selection, - All, -} - -impl WhitespaceRender { - pub fn space(&self) -> WhitespaceRenderValue { - match *self { - Self::Basic(val) => val, - Self::Specific { default, space, .. } => { - space.or(default).unwrap_or(WhitespaceRenderValue::None) - } - } - } - pub fn nbsp(&self) -> WhitespaceRenderValue { - match *self { - Self::Basic(val) => val, - Self::Specific { default, nbsp, .. } => { - nbsp.or(default).unwrap_or(WhitespaceRenderValue::None) - } - } - } - pub fn tab(&self) -> WhitespaceRenderValue { - match *self { - Self::Basic(val) => val, - Self::Specific { default, tab, .. } => { - tab.or(default).unwrap_or(WhitespaceRenderValue::None) - } - } - } - pub fn newline(&self) -> WhitespaceRenderValue { - match *self { - Self::Basic(val) => val, - Self::Specific { - default, newline, .. - } => newline.or(default).unwrap_or(WhitespaceRenderValue::None), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct WhitespaceCharacters { - pub space: char, - pub nbsp: char, - pub tab: char, - pub tabpad: char, - pub newline: char, -} - -impl Default for WhitespaceCharacters { - fn default() -> Self { - Self { - space: '·', // U+00B7 - nbsp: '⍽', // U+237D - tab: '→', // U+2192 - newline: '⏎', // U+23CE - tabpad: ' ', - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "kebab-case")] -pub struct IndentGuidesConfig { - pub render: bool, - pub character: char, - pub skip_levels: u8, -} - -impl Default for IndentGuidesConfig { - fn default() -> Self { - Self { - skip_levels: 0, - render: false, - character: '│', - } - } -} - -/// Line ending configuration. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LineEndingConfig { - /// The platform's native line ending. - /// - /// `crlf` on Windows, otherwise `lf`. - #[default] - Native, - /// Line feed. - LF, - /// Carriage return followed by line feed. - Crlf, - /// Form feed. - #[cfg(feature = "unicode-lines")] - FF, - /// Carriage return. - #[cfg(feature = "unicode-lines")] - CR, - /// Next line. - #[cfg(feature = "unicode-lines")] - Nel, -} - -impl From for LineEnding { - fn from(line_ending: LineEndingConfig) -> Self { - match line_ending { - LineEndingConfig::Native => NATIVE_LINE_ENDING, - LineEndingConfig::LF => LineEnding::LF, - LineEndingConfig::Crlf => LineEnding::Crlf, - #[cfg(feature = "unicode-lines")] - LineEndingConfig::FF => LineEnding::FF, - #[cfg(feature = "unicode-lines")] - LineEndingConfig::CR => LineEnding::CR, - #[cfg(feature = "unicode-lines")] - LineEndingConfig::Nel => LineEnding::Nel, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PopupBorderConfig { - None, - All, - Popup, - Menu, -} - -impl Default for Config { - fn default() -> Self { - Self { - scrolloff: 5, - scroll_lines: 3, - mouse: true, - shell: if cfg!(windows) { - vec!["cmd".to_owned(), "/C".to_owned()] - } else { - vec!["sh".to_owned(), "-c".to_owned()] - }, - line_number: LineNumber::Absolute, - cursorline: false, - cursorcolumn: false, - gutters: GutterConfig::default(), - middle_click_paste: true, - auto_pairs: AutoPairConfig::default(), - auto_completion: true, - auto_format: true, - auto_save: false, - idle_timeout: Duration::from_millis(250), - preview_completion_insert: true, - completion_trigger_len: 2, - auto_info: true, - file_picker: FilePickerConfig::default(), - statusline: StatusLineConfig::default(), - cursor_shape: CursorShapeConfig::default(), - true_color: false, - undercurl: false, - search: SearchConfig::default(), - lsp: LspConfig::default(), - terminal: get_terminal_provider(), - rulers: Vec::new(), - whitespace: WhitespaceConfig::default(), - bufferline: BufferLine::default(), - indent_guides: IndentGuidesConfig::default(), - color_modes: false, - soft_wrap: SoftWrap { - enable: Some(false), - ..SoftWrap::default() - }, - text_width: 80, - completion_replace: false, - workspace_lsp_roots: Vec::new(), - default_line_ending: LineEndingConfig::default(), - insert_final_newline: true, - smart_tab: Some(SmartTabConfig::default()), - popup_border: PopupBorderConfig::None, - indent_heuristic: IndentationHeuristic::default(), - } - } -} - -impl Default for SearchConfig { - fn default() -> Self { - Self { - wrap_around: true, - smart_case: true, - } - } -} +use arc_swap::access::DynGuard; #[derive(Debug, Clone, Default)] pub struct Breakpoint { @@ -1236,8 +416,7 @@ impl Editor { Ok(client) => Some((lang, client)), Err(err) => { log::error!( - "Failed to initialize the language servers for `{}` - `{}` {{ {} }}", - language.scope(), + "Failed to initialize the language servers for `{}` {{ {} }}", lang, err ); diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs index ebdac9e2371a..8c9ab8185fbc 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 }